CodeGym /Corsi /C++ SELF /Move‑costruttore e operatore di assegnazione move

Move‑costruttore e operatore di assegnazione move

C++ SELF
Livello 45, Lezione 4
Disponibile

1. deep copy a volte è un lusso troppo costoso

Nella lezione precedente abbiamo già analizzato la situazione in cui un oggetto possiede manualmente una risorsa (ad esempio un array allocato con new[]): il distruttore deve liberare quella risorsa, e la copia "di default" di un puntatore quasi sicuramente porta a double‑free o use‑after‑free. Perciò abbiamo fatto una deep copy: costruttore di copia e operatore di assegnazione per copia, in modo che ogni oggetto possieda il suo proprio array.

Se avete appena implementato una copia profonda e vi sentite fieri — è normale, è così che deve essere. Ma poi arriva la vita reale: cominciate a scrivere codice in cui l'oggetto "viaggia" tra funzioni, variabili temporanee, viene restituito da funzioni, inserito in contenitori. E all'improvviso scoprite che state spendendo tempo a copiare dove non è necessario — spesso l'oggetto sorgente non sarà più utile.

Immaginate di avere un unico mazzo di chiavi per l'appartamento (questo è il nostro puntatore data). Copiare è come "andare a fare una duplicazione della chiave" (costoso e lento). Muovere è come "passare la chiave di mano in mano" (veloce), e chi la cede non ha più la chiave (e questo è importante!). Nel C++ moderno lo spostamento è un'idea fondamentale, quindi termini come move assignment compaiono direttamente nella documentazione dello standard e nella sua terminologia.

2. Cosa significa "spostare un oggetto"

Quando i principianti sentono "move", spesso pensano: "ah, è solo una copia, ma più veloce". È quasi vero... ed è per questo che è pericoloso. Il pensiero corretto è: transferiamo la proprietà della risorsa, non "facciamo una copia". Dopo lo spostamento rimane un solo proprietario della risorsa, mentre la sorgente diventa vuota (per esempio nullptr).

Dal punto di vista del modello di memoria appare così:

flowchart LR
    A[Buffer a] -->|data = 0xAAA| M[(heap)]
    B[Buffer b] -->|data = nullptr| N[(nessuna risorsa)]

    A2[dopo move] -->|data = nullptr| X[(nessuna risorsa)]
    B2[ricevente] -->|data = 0xAAA| M

Qui l'idea chiave è: l'indirizzo 0xAAA non "si sposta" fisicamente — rimane nello stesso posto, nell'heap. A "spostarsi" è il diritto di considerare quell'indirizzo come proprio e di liberarlo nel distruttore.

3. Rvalue‑reference T&&: "può essere smontato"

Ora arriva il momento in cui C++ alza un po' il sopracciglio e dice: "facciamo attenzione". Conosciamo T& — il riferimento normale, che si lega a un oggetto esistente e significa "è lo stesso oggetto con un altro nome". Ma T&& (rvalue‑reference) nel contesto delle operazioni di move si interpreta solitamente come: "di fronte a me c'è un oggetto sorgente da cui è permesso prendere la risorsa".

È importante tenere a mente: T&& è un tipo di riferimento distinto. Ha sfumature nel codice template (lì a volte diventa la cosiddetta forwarding reference, ed è un altro universo). Oggi non tocchiamo quei dettagli, ma il fatto che "ci sono sottigliezze per T&&" emerge persino nelle discussioni su deduction guides.

Per noi oggi basta questo modello pratico semplice:

  • const Buffer& — "fammi guardare, ma non toccare"
  • Buffer& — "dammi da lavorare, potrei modificare"
  • Buffer&& — "puoi prendere la risorsa, tanto te ne vai"

4. Bozza: Buffer con deep copy

Per non riscrivere tutto da zero, consideriamo di avere un tipo Buffer che possiede manualmente un array dinamico di int[], che viene copiato correttamente (deep copy) e liberato correttamente (RAII).

Qui sotto una versione compatta (senza fronzoli) da cui partiremo:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}

    ~Buffer() {
        delete[] data;
    }

    Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
        for (std::size_t i = 0; i < size; ++i) data[i] = other.data[i];
    }

    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;

        int* new_data = new int[other.size];
        for (std::size_t i = 0; i < other.size; ++i) new_data[i] = other.data[i];

        delete[] data;
        data = new_data;
        size = other.size;
        return *this;
    }
};

Questa versione è già corretta, ma se iniziate a passare attivamente Buffer avanti e indietro, pagherete la copia quasi ovunque: allocazione + ciclo.

5. Move‑costruttore: "prendere il puntatore e azzerare la sorgente"

Il move‑costruttore è necessario quando create un nuovo oggetto da un temporaneo o da un oggetto che avete esplicitamente permesso di "smontare". Il suo compito è concettualmente semplice: prendere le risorse da other, e portare other in uno stato tale che il suo distruttore sia sicuro. Questo stato si chiama di solito "vuoto": data == nullptr, size == 0.

L'algoritmo del move‑costruttore per il nostro Buffer è:

flowchart TD
    A["Inizio: Buffer(Buffer&& other)"] --> B[Copiare size e data da other]
    B --> C[Azzerare other.size]
    C --> D["Azzerare other.data (nullptr)"]
    D --> E[Fatto: ora la risorsa è in *this*]

Implementazione:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~Buffer() { delete[] data; }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }
};

Nota la caratteristica "audace": non allochiamo memoria, non copiamo elementi. Copiamo semplicemente due campi e rendiamo vuota la sorgente. Questo è il trasferimento di proprietà.

6. std::move: non "spostare", ma "permettere di spostare"

Qui è facile inciampare sul nome. std::move(x) di per sé non sposta nulla. Serve solo a far sì che il compilatore tratti x come un oggetto che può essere spostato (cioè come rvalue). Il vero spostamento avviene quando viene chiamato il move‑costruttore o l'operatore di assegnazione move.

Un piccolo esempio "di come appare nel codice":

#include <utility>

int main() {
    Buffer a{5};
    Buffer b = std::move(a); // invoca Buffer(Buffer&&)

    // dopo questo a.data == nullptr, a.size == 0 (per il nostro design)
}

Se dimenticate std::move, il compilatore considererà che volete copiare (perché a è una variabile nominata, quindi lvalue) e tenterà di chiamare il costruttore di copia.

7. Move‑assignment: "prima libera il tuo, poi prendi quello altrui"

L'operatore di assegnazione move (operator=(Buffer&&)) è necessario quando l'oggetto esiste già e volete sostituire la sua risorsa con la risorsa di un altro oggetto‑sorgente. Qui entra in gioco un obbligo aggiuntivo: l'oggetto a sinistra potrebbe già avere il proprio array e va liberato correttamente, altrimenti si perde memoria.

È importante anche rispettare l'ordine delle operazioni: prima liberiamo la risorsa corrente a sinistra (perché non ci serve più), poi prendiamo la nuova risorsa a destra, e rendiamo vuota la sorgente a destra. Ricordate anche il caso del "self‑assignment" (sì, anche per move — teoricamente è possibile scrivere a = std::move(a);, e meglio non trasformarlo in un rompicapo).

Diagramma di move‑assignment:

flowchart TD
    A["Inizio: operator=(Buffer&& other)"] --> B{this == &other?}
    B -->|sì| C[Ritorna *this]
    B -->|no| D[Libera l'attuale data]
    D --> E[Prendi other.size e other.data]
    E --> F[Rendi other vuoto]
    F --> G[Ritorna *this]

Implementazione (breve e concreta):

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~Buffer() { delete[] data; }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }

    Buffer& operator=(Buffer&& other) {
        if (this == &other) return *this;

        delete[] data;          // liberiamo la vecchia risorsa a sinistra
        size = other.size;      // prendiamo la nuova
        data = other.data;

        other.size = 0;         // azzeriamo la sorgente
        other.data = nullptr;
        return *this;
    }
};

Questa versione fa il necessario: previene memory leak a sinistra e previene double‑free a destra.

8. Mini‑dimostrazione in main: dove si "vede" davvero lo spostamento

Quando si studia la move‑semantica, è utile "toccare con mano". Il modo più semplice è aggiungere una piccola stampa diagnostica per vedere quale costruttore/operatore viene chiamato. Certo, in un progetto reale non farete così (o lo farete e poi lo rimuoverete), ma per imparare è molto efficace.

Ecco una versione dove stampiamo gli eventi:

#include <cstddef>
#include <iostream>
#include <utility>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {
        std::cout << "ctor(" << size << ")\n"; // ctor(3)
    }

    ~Buffer() {
        std::cout << "dtor(" << size << ")\n"; // dtor(...)
        delete[] data;
    }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        std::cout << "move-ctor\n";            // move-ctor
        other.size = 0;
        other.data = nullptr;
    }

    Buffer& operator=(Buffer&& other) {
        std::cout << "move-assign\n";          // move-assign
        if (this == &other) return *this;

        delete[] data;
        size = other.size;
        data = other.data;

        other.size = 0;
        other.data = nullptr;
        return *this;
    }
};

int main() {
    Buffer a{3};
    Buffer b{1};

    b = std::move(a);
}

L'output dipenderà dall'ordine di distruzione degli oggetti alla fine di main, ma le righe chiave move-assign e l'assenza della costosa deep copy saranno subito evidenti.

9. Contratto moved‑from e confronto copy vs move

L'oggetto sorgente dopo move rimane "vivo"

Qui si potrebbe dire "dopo std::move l'oggetto è rotto" — è un mito comune. Non è "rotto", è che non è più obbligato a conservare i vecchi dati. Il suo stato è di solito descritto come "valido ma indefinito nel contenuto" (in termini pratici: "potrebbe essere vuoto, ed è ok"). Nelle lezioni successive lo formalizzeremo meglio, ma già ora è utile pensare: un oggetto moved‑from deve poter essere distrutto in sicurezza e poter ricevere un nuovo valore.

Praticamente, per il nostro Buffer abbiamo scelto il design: lo stato moved‑from è (size = 0, data = nullptr). È comodo perché:

  • delete[] nullptr; è sicuro;
  • si può scrivere if (data == nullptr) come indicatore di vuoto;
  • si può assegnare un nuovo buffer e tutto funzionerà.

E sì, la questione "spesso le move operations sono marcate noexcept" è così importante che merita discussioni separate e scelte anche per i tipi standard (per esempio std::function e altri), ma i dettagli di noexcept li affronteremo nella lezione successiva, per non mischiare due grandi argomenti.

Copy vs move: tabella

Per fissare la differenza, è utile avere davanti questa tabella "umana":

Operazione Cosa fa Costo (di solito) Cosa succede alla sorgente
Copy (deep copy) alloca una nuova risorsa e copia i dati costoso: new[] + ciclo la sorgente non cambia
Move trasmette la proprietà della risorsa (puntatore/dimensione) economico: poche assegnazioni la sorgente diventa vuota/"irrilevante"

Il senso è che move non è un'ottimizzazione puramente per la velocità, ma un modello di proprietà: se l'oggetto è temporaneo o "può essere ceduto", copiarne il contenuto è lavoro superfluo.

10. Errori tipici

Errore №1: hai preso other.data ma non hai azzerato other.data.
Questo è il percorso classico verso il double‑free: il ricevente ha il puntatore corretto, ma la sorgente conserva lo stesso indirizzo, e quando escono di scope entrambi i distruttori tenteranno di liberare lo stesso array. Si evita con disciplina: dopo aver "rubato la risorsa" rendi la sorgente vuota subito nello stesso metodo.

Errore №2: nel move‑assignment ti dimentichi di liberare la vecchia risorsa a sinistra.
Sembra innocuo: "tanto ora sovrascrivo data". Ma l'indirizzo precedente va perso e l'array diventa una perdita di memoria. Nel move‑assignment c'è quasi sempre una riga del tipo delete[] data; prima di prendere la risorsa da other.

Errore №3: usare l'oggetto dopo std::move(x) come se avesse ancora i dati.
È un errore logico, non necessariamente fatale: il codice può "in qualche modo funzionare", ma il senso è perduto. Dopo std::move(x) conviene mentalmente cancellare x come "sorgente di dati". Sì, si può riassegnare e poi distruggere, ma leggerne il contenuto "come prima" è una cattiva abitudine che rompe il programma in punti inattesi.

Errore №4: implementare lo move ma fare internamente una deep copy.
A volte, mosso da buone intenzioni, nel move‑costruttore si fanno new[] e si copiano gli elementi, perché "così è più sicuro". Si ottiene un ibrido strano: prometti lo spostamento ma fai una copia, solo meno evidente. Le move operations valgono perché non allocano memoria e non copiano i dati, ma trasferiscono la proprietà.

Errore №5: dimenticare il controllo this == &other nel move‑assignment.
Il caso a = std::move(a); è raro ma possibile. Se non si verifica l'autoassegnazione, si può accidentalmente eliminare la propria risorsa e poi "rubare" da se stessi — cioè rubare qualcosa già eliminato. Il controllo if (this == &other) return *this; è una protezione semplice contro un insieme molto sgradevole di bug.

1
Compito
C++ SELF, livello 45, lezione 4
Bloccato
Spostamento del buffer
Spostamento del buffer
1
Compito
C++ SELF, livello 45, lezione 4
Bloccato
Trasferimento della proprietà
Trasferimento della proprietà
1
Compito
C++ SELF, livello 45, lezione 4
Bloccato
Proprietario unico
Proprietario unico
1
Compito
C++ SELF, livello 45, lezione 4
Bloccato
Scambio senza copie
Scambio senza copie
1
Sondaggio/quiz
=default/=delete, livello 45, lezione 4
Non disponibile
=default/=delete
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION