Vai al contenuto

Semantica di Copia: Profonda vs Superficiale

Impara la differenza tra copie superficiali e profonde, come implementare operazioni di copia corrette per le classi che possiedono risorse, e l'idioma copy-and-swap.

Perché la Semantica di Copia è Importante

Quando copi un oggetto in C++, che cosa viene copiato esattamente? Per i tipi semplici la risposta è immediata — tutti i membri vengono copiati bit per bit. Ma quando un oggetto possiede una risorsa attraverso un puntatore, il costruttore di copia predefinito (generato dal compilatore) esegue una copia superficiale: copia il valore del puntatore, non i dati a cui punta. Questo significa che due oggetti condividono ora la stessa risorsa, con conseguenti bug di doppia deallocazione e corruzione dei dati.

Capire la semantica di copia è essenziale, perché le copie avvengono implicitamente in molti punti: passaggio per valore, ritorno da funzioni, inserimento nei contenitori e assegnamento.

Il Problema della Copia Superficiale

Il costruttore di copia generato dal compilatore copia ogni membro. Per i puntatori, copia l'indirizzo — non i dati puntati. Questa è una copia superficiale ed è pericolosa per le classi che possiedono risorse.

shallow_copy_bug.cpp
#include <iostream>
#include <cstring>

class BrokenString {
    char* data_;
    std::size_t len_;

public:
    BrokenString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    ~BrokenString() {
        delete[] data_;  // frees the buffer
    }

    // No copy constructor defined!
    // Compiler generates shallow copy: copies the pointer

    void print() const {
        std::cout << data_ << '\n';
    }
};

int main() {
    BrokenString a{"Hello"};
    {
        BrokenString b = a;  // shallow copy: b.data_ == a.data_
        b.print();           // "Hello" — works for now
    }  // b destroyed: delete[] b.data_ (same pointer as a.data_!)

    // a.data_ is now a dangling pointer — UNDEFINED BEHAVIOR
    a.print();  // may crash, print garbage, or appear to work
    return 0;
}

Implementare la Copia Profonda

Una copia profonda alloca nuova memoria e copia i dati veri e propri. Questo garantisce che ogni oggetto possieda la propria copia indipendente della risorsa.

deep_copy.cpp
#include <iostream>
#include <cstring>
#include <algorithm>

class SafeString {
    char* data_;
    std::size_t len_;

public:
    explicit SafeString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    // Deep copy constructor
    SafeString(const SafeString& other)
        : len_{other.len_}
        , data_{new char[other.len_ + 1]}
    {
        std::strcpy(data_, other.data_);
    }

    // Deep copy assignment with self-assignment check
    SafeString& operator=(const SafeString& other) {
        if (this == &other) return *this;  // self-assignment guard

        // Allocate new resource BEFORE releasing old one
        char* new_data = new char[other.len_ + 1];
        std::strcpy(new_data, other.data_);

        // Release old resource
        delete[] data_;

        // Adopt new resource
        data_ = new_data;
        len_ = other.len_;
        return *this;
    }

    ~SafeString() { delete[] data_; }

    void print() const {
        std::cout << data_ << " (len=" << len_ << ")\n";
    }
};

int main() {
    SafeString a{"Hello"};
    SafeString b = a;     // deep copy: b gets its own buffer
    SafeString c{"World"};
    c = a;                // deep copy assignment
    a.print();  // Hello (len=5)
    b.print();  // Hello (len=5)
    c.print();  // Hello (len=5)
    return 0;
}

L'Idioma Copy-and-Swap

L'idioma copy-and-swap offre un'implementazione elegante e sicura rispetto alle eccezioni dell'operatore di assegnamento per copia. Riutilizza il costruttore di copia e una funzione swap, fornendo la garanzia forte rispetto alle eccezioni con una duplicazione di codice minima.

copy_and_swap.cpp
#include <iostream>
#include <cstring>
#include <utility>

class Buffer {
    char* data_;
    std::size_t size_;

public:
    explicit Buffer(std::size_t size)
        : size_{size}, data_{new char[size]{}} {}

    Buffer(const Buffer& other)
        : size_{other.size_}, data_{new char[other.size_]}
    {
        std::memcpy(data_, other.data_, size_);
    }

    ~Buffer() { delete[] data_; }

    // Copy-and-swap: take parameter by VALUE (invokes copy ctor)
    Buffer& operator=(Buffer other) {   // copy happens here
        swap(*this, other);             // swap resources
        return *this;
    }   // 'other' destroyed here, freeing our old resource

    // Non-throwing swap — essential for strong exception safety
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

    char& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }
};

int main() {
    Buffer a{5};
    a[0] = 'H'; a[1] = 'i';

    Buffer b{10};
    b = a;  // copy-and-swap: exception-safe, self-assignment-safe

    std::cout << b[0] << b[1] << " (size " << b.size() << ")\n";
    return 0;
}

Disabilitare la Copia

Alcuni tipi non dovrebbero essere copiabili — per esempio i tipi che rappresentano risorse uniche come handle di file, connessioni di rete o thread. Usa =delete per comunicarlo chiaramente e farlo rispettare:

class UniqueConnection {
public:
    UniqueConnection(const UniqueConnection&) = delete;
    UniqueConnection& operator=(const UniqueConnection&) = delete;
    // Le operazioni di spostamento possono comunque essere abilitate
};

Prima del C++11 questo si faceva dichiarando le operazioni di copia private senza definirle — un espediente poco chiaro che =delete sostituisce con eleganza.

Insidia

Un assegnamento per copia ingenuo che cancella la vecchia risorsa prima di allocare la nuova è sbagliato in due modi: (1) l'auto-assegnamento (a = a) cancella i dati e poi prova a copiarli da se stessi, e (2) se new lancia un'eccezione dopo il delete, l'oggetto resta in uno stato non valido. L'idioma copy-and-swap risolve entrambi i problemi: la copia viene fatta prima di qualsiasi modifica, e lo swap è noexcept.

Buona pratica

Il modo migliore per evitare bug di copia è non gestire mai direttamente risorse grezze. Usa std::string al posto di char*, std::vector al posto degli array grezzi e std::unique_ptr al posto dei puntatori grezzi proprietari. Quando tutti i tuoi membri hanno una semantica di copia corretta, le operazioni di copia generate dal compilatore sono automaticamente corrette. Questa è la Regola dello Zero messa in pratica.

Concetti chiave
  • La copia generata dal compilatore è superficiale — copia i valori dei puntatori, non i dati puntati
  • Le classi che possiedono risorse hanno bisogno di un costruttore di copia e di un operatore di assegnamento con copia profonda
  • L'idioma copy-and-swap ti regala sicurezza rispetto alle eccezioni e sicurezza nell'auto-assegnamento
  • Usa =delete per vietare esplicitamente la copia dei tipi che rappresentano risorse uniche
  • La strategia migliore è la Regola dello Zero: usa wrapper RAII così da non dover mai scrivere logica di copia personalizzata

Quiz — Metti alla prova le tue conoscenze

(15 XP)

1. Cosa succede quando copi in modo superficiale un oggetto che possiede memoria heap tramite un puntatore grezzo?

2. Perché l'idioma copy-and-swap prende il parametro per valore?

3. Quale approccio è consigliato per non aver bisogno affatto di operazioni di copia personalizzate?