Semantica di spostamento e rvalue reference in C++

Semantica di spostamento e rvalue reference in C++

La semantica di spostamento, introdotta con lo standard C++11, è una delle innovazioni che ha maggiormente influenzato le prestazioni dei programmi moderni. Permette di trasferire le risorse da un oggetto a un altro anziché copiarle, evitando duplicazioni costose quando l'oggetto di origine non serve più. Comprendere questo meccanismo richiede prima di chiarire la distinzione tra lvalue e rvalue e il ruolo delle rvalue reference.

Lvalue e rvalue

Ogni espressione in C++ è classificabile, in modo semplificato, come lvalue o rvalue. Un lvalue è un'espressione che identifica un oggetto con una posizione stabile in memoria, tipicamente una variabile a cui si può fare riferimento anche in seguito. Un rvalue è invece un valore temporaneo, destinato a scomparire al termine dell'espressione, come il risultato di un'operazione o un letterale. Da questa distinzione nasce la possibilità di trattare diversamente i temporanei, sfruttando il fatto che il loro contenuto può essere sottratto senza conseguenze.

Le rvalue reference

Una rvalue reference è un riferimento che può legarsi soltanto a un rvalue, e si dichiara con un doppio simbolo di e commerciale. Questo tipo di riferimento consente di scrivere funzioni che riconoscono quando ricevono un temporaneo e possono quindi permettersi di svuotarlo, appropriandosi delle sue risorse.

int value = 10;

int& lvalueRef = value;        // Riferimento a lvalue
int&& rvalueRef = 20;         // Riferimento a rvalue: si lega a un temporaneo
int&& result = value + 5;     // Si lega al risultato temporaneo

Costruttore di spostamento

Il caso d'uso principale della semantica di spostamento è la definizione del costruttore di spostamento, che costruisce un nuovo oggetto sottraendo le risorse a un oggetto temporaneo in arrivo. Consideriamo una classe che gestisce un buffer allocato dinamicamente: il costruttore di copia dovrebbe duplicare l'intero buffer, mentre quello di spostamento si limita a trasferire il puntatore, lasciando l'origine in uno stato valido ma vuoto.

#include <utility>

class Buffer {
private:
    int* data;
    size_t size;

public:
    // Costruttore di spostamento: sottrae le risorse all'origine
    Buffer(Buffer&& other) noexcept
        : data(other.data), size(other.size) {
        // L'origine viene lasciata in uno stato valido e vuoto
        other.data = nullptr;
        other.size = 0;
    }
};

La parola chiave noexcept comunica che il costruttore di spostamento non lancia eccezioni: questa garanzia è importante perché consente ai contenitori della libreria standard di usare lo spostamento in sicurezza durante le riallocazioni, ottenendo prestazioni migliori.

Operatore di assegnamento per spostamento

Analogamente al costruttore, esiste l'operatore di assegnamento per spostamento, invocato quando si assegna un temporaneo a un oggetto già esistente. Deve prima rilasciare le risorse eventualmente possedute, poi acquisire quelle dell'origine e infine azzerare l'origine stessa.

Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
        delete[] data;        // Rilascia le risorse correnti
        data = other.data;   // Acquisisce quelle dell'origine
        size = other.size;
        other.data = nullptr;
        other.size = 0;
    }
    return *this;
}

La funzione std::move

A volte si desidera spostare anche un lvalue, quando si sa che non verrà più utilizzato. La funzione std::move, definita nell'intestazione utility, non sposta di per sé alcun dato: si limita a convertire il proprio argomento in una rvalue reference, segnalando al compilatore che l'oggetto può essere svuotato. Sarà poi il costruttore o l'operatore di spostamento a compiere il trasferimento effettivo.

#include <string>
#include <utility>

std::string source = "contenuto";

// Trasferisce il contenuto senza copiarlo
std::string destination = std::move(source);
// Dopo lo spostamento source e in uno stato valido ma indefinito

La regola dei cinque

Quando una classe gestisce direttamente una risorsa, come memoria allocata a mano, e si definisce anche solo una tra le operazioni speciali, generalmente occorre definirle tutte e cinque in modo coerente: distruttore, costruttore di copia, operatore di assegnamento per copia, costruttore di spostamento e operatore di assegnamento per spostamento. Questo principio è noto come regola dei cinque. Nella pratica moderna, tuttavia, la strategia migliore è spesso quella di non gestire risorse a mano affatto, delegando la gestione a componenti già dotati di corretta semantica di spostamento, come gli smart pointer e i contenitori della libreria standard. In questo modo il compilatore genera automaticamente operazioni corrette e la regola dei cinque diventa superflua.

La semantica di spostamento agisce spesso in modo trasparente: restituire un oggetto di grandi dimensioni da una funzione o inserirlo in un contenitore beneficia automaticamente dello spostamento, senza alcuno sforzo esplicito. Comprenderne il funzionamento consente comunque di scrivere classi efficienti e di capire perché il C++ moderno raggiunga prestazioni elevate senza rinunciare all'astrazione.