Salta al contenuto
Note per Studenti Esercizio - Shallow copy e deep copy (quarto appello 2021-22, teoria)

Esercizio - Shallow copy e deep copy (quarto appello 2021-22, teoria)

Esame

Domanda aperta 1 del quarto appello 2021-22 (12 settembre 2022) del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: C++ - costruttori, inizializzazione, copia e spostamentouna buona interfaccia è completa e minimale, ha i costruttori giusti, supporta o proibisce la copia, usa tipi adeguati, dichiara const ciò che non modifica e libera le risorse nel distruttore; il costruttore di default (senza argomenti) esiste quando c'è un valore iniziale sensato, si ottiene con valori di default nei membri (in-class initializer) o argomenti di default, e explicit vieta la conversione implicita da un argomento; initializer_list permette la lista {a, b, c} e con le graffe ha la precedenza sul costruttore a un intero (vector{3} è un elemento, vector(3) sono tre); la copia di default è membro a membro (shallow copy) e con un puntatore a un buffer duplica il puntatore invece del contenuto (due oggetti che condividono i dati e una doppia delete), quindi servono il costruttore di copia e l'assegnamento di copia che fanno la deep copy (l'assegnamento prima alloca e copia, poi libera i vecchi dati, e deve tollerare l'auto-assegnamento); this è il puntatore all'oggetto corrente, parametro implicito delle funzioni membro; il costruttore e l'assegnamento di spostamento (T&&, rvalue reference) rubano il buffer della sorgente lasciandola vuota ma valida, il compilatore li sceglie da solo (con la copy elision a volte non servono nemmeno); i cinque (distruttore, copia, spostamento) vanno definiti insieme.C++ - costruttori, inizializzazione, copia e spostamento →, C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →.

Testo. Spiegare la differenza concettuale tra shallow copy e deep copy. Mostrare poi tale differenza usando due esempi di codice che implementano una classe che fa uso di deep copy e un'altra che fa uso di shallow copy: per tali classi creare il solo costruttore di copia (assumendo che tutte le altre funzioni membro necessarie siano già implementate) e il corpo della classe.

La differenza concettuale

Copiare un oggetto che contiene un puntatore a dei dati ha due possibili significati:

shallow copy (copia superficiale) deep copy (copia profonda)
che cosa si copia il valore del puntatore (l'indirizzo) i dati puntati, in un nuovo buffer
dopo la copia due oggetti puntano allo stesso buffer ogni oggetto ha il suo buffer
modifica attraverso la copia visibile nell'originale indipendente
semantica pointer (o reference) semantics value semantics
costo O(1)O(1) proporzionale ai dati
rischio doppia delete (due distruttori sullo stesso buffer) e dangling pointer nessuno (maggior costo)
quando serve tipi che non possiedono i dati (viste) o con proprietà condivisa gestita (contatori) tipi che possiedono i dati: vector, string

La copia di default (membro a membro) è una shallow copy per ogni puntatore: per questo una classe che possiede memoria dinamica deve definire costruttore e assegnamento di copia, oppure vietarli. Un tipo con una deep copy ha value semantics ed è un po' più lento.

Due classi

Entrambe hanno un int con la dimensione e un puntatore a un buffer. L'unica differenza è il costruttore di copia. La classe shallow non libera nulla nel distruttore: con una shallow copy due oggetti avrebbero lo stesso buffer e lo libererebbero due volte.

shallow_deep.cppcpp
#include <iostream>
using namespace std;

// ---------- classe con SHALLOW copy: il costruttore di copia copia solo il puntatore ----------
class ShallowArray {
public:
    explicit ShallowArray(int n) : n_{n}, a_{new int[n]()} {}
    ShallowArray(const ShallowArray& o)          // costruttore di copia SHALLOW
        : n_{o.n_}, a_{o.a_}                     // stesso indirizzo: i due oggetti condividono il buffer
    {}
    // niente distruttore che liberi: con una shallow copy due oggetti libererebbero lo stesso buffer
    int& operator[](int i) { return a_[i]; }
    int* data() const { return a_; }
    void libera() { delete[] a_; a_ = nullptr; } // da chiamare UNA volta, a mano, da un solo proprietario
private:
    int n_;
    int* a_;
};

// ---------- classe con DEEP copy: il costruttore di copia duplica i dati ----------
class DeepArray {
public:
    explicit DeepArray(int n) : n_{n}, a_{new int[n]()} {}
    DeepArray(const DeepArray& o)                // costruttore di copia DEEP
        : n_{o.n_}, a_{new int[o.n_]}            // NUOVO buffer
    {
        for (int i = 0; i < n_; ++i) a_[i] = o.a_[i];    // si copiano i valori
    }
    ~DeepArray() { delete[] a_; }                // qui ogni oggetto ha il suo buffer: la delete e' corretta
    int& operator[](int i) { return a_[i]; }
    int* data() const { return a_; }
private:
    int n_;
    int* a_;
};

int main()
{
    ShallowArray s1(3);
    s1[0] = 1;
    ShallowArray s2 = s1;                        // shallow
    s2[0] = 99;
    cout << "shallow: stesso buffer? " << (s1.data() == s2.data()) << ", s1[0] = " << s1[0] << " (modificato tramite s2)\n";
    s1.libera();                                 // libero il buffer una sola volta; s2 ora e' pendente: non si usa piu'

    DeepArray d1(3);
    d1[0] = 1;
    DeepArray d2 = d1;                           // deep
    d2[0] = 99;
    cout << "deep:    stesso buffer? " << (d1.data() == d2.data()) << ", d1[0] = " << d1[0] << ", d2[0] = " << d2[0] << '\n';
    return 0;
}
shallow: stesso buffer? 1, s1[0] = 99 (modificato tramite s2)
deep:    stesso buffer? 0, d1[0] = 1, d2[0] = 99

ShallowArray: ShallowArray(const ShallowArray& o) : n_{o.n_}, a_{o.a_} {} copia l'indirizzo: dopo ShallowArray s2 = s1;, s1.data() == s2.data() è vero e s2[0] = 99 cambia anche s1[0]. È una classe che per essere corretta richiede un proprietario unico che chiami libera() una volta (qui a mano), oppure un conteggio dei proprietari (shared_ptr, C++ - smart pointerGli smart pointer (classi template di ) sono oggetti che detengono un puntatore e lo deallocano automaticamente all'uscita dallo scope (RAII), eliminando memory leak e dangling pointer con un overhead minimo. unique_ptr ha un solo proprietario: non si copia (costruttore e assegnamento di copia cancellati) ma si sposta (std::move, ritorno da funzione), ha reset(), release(), get(), * e ->; due unique_ptr costruiti con lo stesso puntatore grezzo sono un errore (doppia delete) che il compilatore non vede, e si evita con make_unique. shared_ptr ha più proprietari: si copia, tiene un contatore di riferimenti (use_count) e libera l'oggetto quando l'ultimo shared_ptr viene distrutto, a un costo maggiore; weak_ptr osserva senza possedere, per rompere i cicli. auto_ptr è deprecato (rimosso in C++17).C++ - smart pointer →). Se avesse ~ShallowArray() { delete[] a_; }, la fine di main farebbe delete[] due volte sullo stesso buffer: comportamento indefinito.

DeepArray: DeepArray(const DeepArray& o) : n_{o.n_}, a_{new int[o.n_]} alloca un nuovo buffer, poi il ciclo copia i valori: d2[0] = 99 non tocca d1[0] (i buffer sono distinti, d1.data() != d2.data()); ogni oggetto libera il proprio, quindi il distruttore delete[] a_ è corretto. Con la deep copy anche l'assegnamento di copia deve essere definito (alloca, copia, libera il vecchio): altrimenti d2 = d1; farebbe di nuovo una shallow copy.

Versione ripasso

  • Shallow copy = copia dell'indirizzo (due oggetti, stesso buffer: modifiche visibili in entrambi, pericolo di doppia delete); deep copy = copia dei dati in un nuovo buffer (indipendenti, più lenta). Pointer semantics contro value semantics. La copia di default è shallow per i puntatori ⇒\Rightarrow con memoria dinamica si definiscono costruttore e assegnamento di copia (o si vietano).
cpp
class ShallowArray {                                   // copia l'indirizzo
public: ShallowArray(const ShallowArray& o) : n_{o.n_}, a_{o.a_} {}  /* niente delete[] nel distruttore */
private: int n_; int* a_; };

class DeepArray {                                      // copia i dati
public: DeepArray(const DeepArray& o) : n_{o.n_}, a_{new int[o.n_]}
        { for (int i = 0; i < n_; ++i) a_[i] = o.a_[i]; }
        ~DeepArray() { delete[] a_; }
private: int n_; int* a_; };
  • Prova: shallow: s1.data() == s2.data() vero, s2[0] = 99 →\to s1[0] = 99; deep: buffer diversi, d1[0] = 1, d2[0] = 99.
  • Errori tipici: distruttore con delete[] in una classe con copia shallow (doppia delete); deep copy senza assegnamento di copia; copiare n_ ma non allocare il buffer; credere che la copia di default copi anche i dati puntati.

Esercizi su questo argomento

Teoria collegata