Salta al contenuto
Note per Studenti C++ - smart pointer

C++ - smart pointer

In questa pagina 4

Un puntatore grezzo (T*) non dice chi deve fare la delete e quando: dimenticarla causa memory leak, farla due volte una doppia deallocazione, usare il puntatore dopo la delete un dangling pointer (C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →). Con le eccezioni la delete può anche essere saltata (C++ - eccezioni e gestione delle risorse (RAII)Un errore che non si può gestire dove avviene si segnala con throw e si gestisce con try/catch più in alto; la libreria standard ha la gerarchia std::exception (logic_error e runtime_error con le loro derivate) e le eccezioni proprie si derivano pubblicamente da una di esse; si cattura per riferimento. Le eccezioni mettono in crisi il codice che acquisisce risorse (memoria con new, file, lock, socket): se una chiamata lancia prima della delete, la risorsa si perde. La soluzione è RAII (Resource Acquisition Is Initialization): acquisire la risorsa nel costruttore di un oggetto locale e rilasciarla nel distruttore, perché all'uscita da uno scope, anche per eccezione (stack unwinding), i distruttori degli oggetti locali vengono chiamati. Garanzie di una funzione: basic (nessuna perdita, stato valido), strong (commit or rollback: se fallisce lo stato osservabile è quello di prima) e no-throw (non lancia).C++ - eccezioni e gestione delle risorse (RAII) →). Gli smart pointer applicano RAII ai puntatori: il puntatore è detenuto da un oggetto, e quando l'oggetto esce dallo scope la memoria viene liberata.

Definizione (smart pointer). Classe template (header <memory>) che si comporta come un puntatore (supporta * e ->), detiene un puntatore e ne gestisce automaticamente la deallocazione. Elimina memory leak e dangling pointer, con un overhead molto ridotto rispetto a un garbage collector.

I tipi della libreria standard differiscono per chi può possedere l'oggetto:

tipo proprietari copia sposta costo quando
unique_ptr<T> uno solo no (cancellata) sì praticamente uguale a un puntatore grezzo caso normale
shared_ptr<T> più di uno sì sì contatore dei riferimenti proprietà davvero condivisa
weak_ptr<T> nessuno (osserva uno shared_ptr) sì sì rompere cicli, osservare
auto_ptr<T> uno (semantica di copia pericolosa) deprecato, rimosso da C++17

unique_ptr

Uno unique_ptr:

  • dealloca l'oggetto puntato quando esce dallo scope;
  • permette il move, non la copia: costruttore e assegnamento di copia sono disabilitati (= delete), quindi in ogni istante c'è un solo proprietario e non c'è sostanziale overhead rispetto a un puntatore.

Operazioni:

operazione effetto
costruttore da puntatore unique_ptr<X> p{new X{1}};
*p, p->campo come per un puntatore
p.get() il puntatore grezzo, senza cedere il possesso
p.reset(q) distrugge l'oggetto attualmente posseduto (se c'è) e detiene q
p.reset() distrugge l'oggetto e diventa vuoto (nullptr)
p.release() cede il puntatore grezzo e diventa nullptr: la deallocazione ora è a carico del chiamante
make_unique<X>(args) crea l'oggetto con new e lo affida a uno unique_ptr, senza che il puntatore grezzo sia mai visibile
std::move(p) passa la proprietà a un altro unique_ptr; p diventa vuoto

Un programma con tutte le operazioni, il cui effetto si segue dagli X(...) e ~X(...) stampati:

unique/unique.cppcpp
#include <iostream>
#include <memory>
#include <utility>
#include <vector>
using namespace std;

struct X {
    int v;
    explicit X(int vv) : v{vv} { cout << "  X(" << v << ")\n"; }
    ~X() { cout << "  ~X(" << v << ")\n"; }
};

unique_ptr<int> AllocateAndReturn()
{
    unique_ptr<int> ptr_to_return(new int(42));
    return ptr_to_return;                        // niente copia: si usa il move constructor
}

unique_ptr<vector<int>> make_vec()
{
    unique_ptr<vector<int>> p{new vector<int>};  // acquisizione nel costruttore
    p->push_back(1);                             // -> come un puntatore
    p->push_back(2);
    return p;                                    // la proprieta' passa al chiamante
}

int main()
{
    cout << "1) uscita dallo scope\n";
    {
        unique_ptr<X> p{new X{1}};
        cout << "  *p = " << p->v << '\n';
    }                                            // ~X(1) automatico

    cout << "2) reset\n";
    unique_ptr<X> q{new X{2}};
    q.reset(new X{3});                           // distrugge X(2), poi tiene X(3)
    q.reset();                                   // senza argomenti: distrugge X(3)
    cout << "  q vuoto: " << (q == nullptr) << '\n';

    cout << "3) release\n";
    unique_ptr<X> r{new X{4}};
    X* grezzo = r.release();                     // r non possiede piu' nulla, nessuna delete
    cout << "  r vuoto: " << (r == nullptr) << ", grezzo->v = " << grezzo->v << '\n';
    delete grezzo;                               // ora la delete e' a carico nostro

    cout << "4) move\n";
    unique_ptr<X> a = make_unique<X>(5);         // make_unique: nessun puntatore grezzo in vista
    unique_ptr<X> b = std::move(a);                   // spostamento: a diventa vuoto
    cout << "  a vuoto: " << (a == nullptr) << ", b->v = " << b->v << '\n';
    // unique_ptr<X> c = b;                      // errore di compilazione: copia cancellata

    cout << "5) ritorno da funzione\n";
    unique_ptr<int> n = AllocateAndReturn();
    cout << "  *n = " << *n << '\n';
    auto v = make_vec();
    cout << "  v->size() = " << v->size() << '\n';

    cout << "6) array\n";
    unique_ptr<int[]> arr = make_unique<int[]>(5);   // usa delete[] da solo; valori inizializzati a 0
    arr[2] = 9;
    cout << "  arr[0] = " << arr[0] << ", arr[2] = " << arr[2] << '\n';

    cout << "fine main\n";
}

Uscita (eseguita in WebAssembly):

1) uscita dallo scope
  X(1)
  *p = 1
  ~X(1)
2) reset
  X(2)
  X(3)
  ~X(2)
  ~X(3)
  q vuoto: 1
3) release
  X(4)
  r vuoto: 1, grezzo->v = 4
  ~X(4)
4) move
  X(5)
  a vuoto: 1, b->v = 5
5) ritorno da funzione
  *n = 42
  v->size() = 2
6) array
  arr[0] = 0, arr[2] = 9
fine main
  ~X(5)

Si leggano le tracce. (1) ~X(1) compare alla chiusura delle graffe, senza delete. (2) q.reset(new X{3}) costruisce prima X(3) (l'argomento) e poi distrugge il vecchio X(2); reset() senza argomenti distrugge X(3) e lascia q vuoto. (3) Con release() non c'è nessuna distruzione automatica: ~X(4) compare solo perché il programma fa delete grezzo. Chi usa release() si riprende la responsabilità. (4) Dopo std::move(a) lo unique_ptr sorgente è vuoto; il copia-incolla c = b non compila (call to implicitly-deleted copy constructor of 'unique_ptr<int>'). (5) Una funzione può restituire uno unique_ptr: si usa il costruttore di spostamento, e la proprietà passa al chiamante senza che questi debba fare delete. ~X(5) alla fine di main è la distruzione di b. (6) Per gli array unique_ptr<int[]> usa delete[].

make_vec con unique_ptr

La funzione di C++ - eccezioni e gestione delle risorse (RAII)Un errore che non si può gestire dove avviene si segnala con throw e si gestisce con try/catch più in alto; la libreria standard ha la gerarchia std::exception (logic_error e runtime_error con le loro derivate) e le eccezioni proprie si derivano pubblicamente da una di esse; si cattura per riferimento. Le eccezioni mettono in crisi il codice che acquisisce risorse (memoria con new, file, lock, socket): se una chiamata lancia prima della delete, la risorsa si perde. La soluzione è RAII (Resource Acquisition Is Initialization): acquisire la risorsa nel costruttore di un oggetto locale e rilasciarla nel distruttore, perché all'uscita da uno scope, anche per eccezione (stack unwinding), i distruttori degli oggetti locali vengono chiamati. Garanzie di una funzione: basic (nessuna perdita, stato valido), strong (commit or rollback: se fallisce lo stato osservabile è quello di prima) e no-throw (non lancia).C++ - eccezioni e gestione delle risorse (RAII) → che costruisce un oggetto grande e lo restituisce, riscritta nelle due versioni del corso:

cpp
vector<int>* make_vec()                       // versione 1: release()
{
    unique_ptr<vector<int>> p { new vector<int> };
    // ... riempimento del vettore: può lanciare un'eccezione!
    return p.release();
}

unique_ptr<vector<int>> make_vec()            // versione 2: si ritorna lo unique_ptr
{
    unique_ptr<vector<int>> p { new vector<int> };
    // ... riempimento
    return p;
}

Nella versione 1, se il riempimento lancia, p viene distrutto e con lui il vector: non serve il catch. A fine utilizzo release() estrae il puntatore e azzera p, quindi la distruzione successiva di p non cancella nulla: si perde la deallocazione automatica e il chiamante deve fare delete. Nella versione 2 si evita anche questo, perché si ritorna lo unique_ptr (move) e la proprietà passa al chiamante.

La debolezza: lo stesso puntatore grezzo due volte

Due unique_ptr non possono essere copiati l'uno nell'altro, ma si possono costruire entrambi dallo stesso puntatore grezzo:

doppio/doppio.cppcpp
#include <memory>
using namespace std;
int main()
{
    int* p = new int{5};
    unique_ptr<int> sp{p};
    unique_ptr<int> sp2{p};          // compila! ma il distruttore farebbe due delete dello stesso blocco
    (void)sp; (void)sp2;
    // da non eseguire: doppia deallocazione
}

È un errore logico che il compilatore non può rilevare: all'uscita sia sp sia sp2 eliminano l'oggetto (doppia delete). Il punto debole è che esiste un puntatore grezzo accessibile. Si risolve con make_unique, che crea l'oggetto e restituisce direttamente lo unique_ptr, senza rendere accessibile il puntatore. Gli argomenti di make_unique<T>(...) sono quelli del costruttore di T; per esempio make_unique<vector<int>>(10) crea un vettore di 10 elementi.

shared_ptr

Quando più parti del programma devono davvero condividere un oggetto, shared_ptr rimuove i vincoli di unique_ptr: può essere copiato e condiviso, cioè molti shared_ptr possono detenere lo stesso puntatore. Lo fa con il conteggio dei riferimenti (reference counting): gli shared_ptr che detengono lo stesso oggetto "si conoscono" attraverso un contatore condiviso. L'oggetto viene deallocato quando l'ultimo shared_ptr che lo detiene viene distrutto. Il contatore consuma risorse (memoria in più, operazioni atomiche in presenza di thread).

shared/shared.cppcpp
#include <iostream>
#include <memory>
using namespace std;

struct X {
    int v;
    explicit X(int vv) : v{vv} { cout << "  X(" << v << ")\n"; }
    ~X() { cout << "  ~X(" << v << ")\n"; }
};

shared_ptr<int> AllocateAndReturn()
{
    shared_ptr<int> ptr_to_return(new int(42));
    return ptr_to_return;
}

int main()
{
    shared_ptr<int> moved_ptr = AllocateAndReturn();
    shared_ptr<int> copied_ptr = moved_ptr;      // la copia e' possibile
    cout << *moved_ptr << " " << *copied_ptr << '\n';
    cout << "use_count = " << moved_ptr.use_count() << '\n';

    cout << "contatore dei riferimenti\n";
    shared_ptr<X> p = make_shared<X>(7);
    cout << "  count = " << p.use_count() << '\n';
    {
        shared_ptr<X> q = p;                     // count 2
        shared_ptr<X> r = q;                     // count 3
        cout << "  count = " << p.use_count() << '\n';
    }                                            // q e r escono: count torna 1, X non e' distrutto
    cout << "  count = " << p.use_count() << '\n';
    p.reset();                                   // ultimo shared_ptr: qui viene distrutto X
    cout << "fine\n";
}

Uscita eseguita: 42 42, use_count = 2, contatore dei riferimenti, X(7), count = 1, count = 3, count = 1, ~X(7) (solo quando p.reset() toglie l'ultimo riferimento), fine. Con unique_ptr la riga copied_ptr = moved_ptr sarebbe un errore di compilazione. make_shared<T>(...) è la forma consigliata di creazione (un'allocazione sola per oggetto e contatore).

weak_ptr e i cicli

Se a possiede b con uno shared_ptr e b possiede a, i contatori non scendono mai a zero e i due oggetti non vengono mai distrutti (ciclo). weak_ptr osserva un oggetto gestito da shared_ptr senza incrementare il contatore; lock() restituisce uno shared_ptr valido (o vuoto se l'oggetto non esiste più).

weak/weak.cppcpp
#include <iostream>
#include <memory>
using namespace std;

struct Nodo {
    shared_ptr<Nodo> succ;                       // proprietario: tiene vivo il successivo
    weak_ptr<Nodo> prec;                         // NON proprietario: evita il ciclo di shared_ptr
    int v;
    explicit Nodo(int vv) : v{vv} {}
    ~Nodo() { cout << "~Nodo(" << v << ")\n"; }
};

int main()
{
    auto a = make_shared<Nodo>(1);
    auto b = make_shared<Nodo>(2);
    a->succ = b;
    b->prec = a;                                 // se fosse shared_ptr: a e b non verrebbero mai distrutti
    cout << "a.use_count = " << a.use_count() << ", b.use_count = " << b.use_count() << '\n';
    if (auto p = b->prec.lock()) cout << "il precedente di b e' " << p->v << '\n';
}

Uscita: a.use_count = 1, b.use_count = 2 (b è tenuto da b stesso e da a->succ), il precedente di b e' 1, poi ~Nodo(1) e ~Nodo(2): all'uscita da main si distrugge prima b (il suo contatore scende a 1), poi a (contatore 0), che distrugge Nodo(1) e con il suo succ anche Nodo(2). Con weak_ptr il ciclo non esiste.

Quale usare

Un unique_ptr minimo scritto a mano (costruttore, distruttore, ->, *, release()) è l'esercizio Esercizio - Un unique_ptr minimo e make_unique (laboratorio 10).

Domande d'esame

1. Cos'è uno smart pointer e quali problemi risolve? Descrivere unique_ptr e shared_ptr e cosa succede alla distruzione di ciascuno. Traccia. Oggetto template che detiene un puntatore e lo dealloca all'uscita dallo scope (RAII): niente leak né dangling. unique_ptr: un solo proprietario, copia disabilitata, move consentito; alla distruzione elimina l'oggetto. shared_ptr: più proprietari, copia consentita, reference counting; alla distruzione decrementa il contatore e elimina l'oggetto solo quando arriva a zero.

2. Perché unique_ptr<int> sp{p}; unique_ptr<int> sp2{p}; è un errore e come si evita? Traccia. Compila ma due oggetti credono di possedere lo stesso blocco: doppia delete all'uscita. Non rilevabile dal compilatore perché il puntatore grezzo è accessibile; si evita con make_unique, che non espone il puntatore.

3. Riscrivere vector<int>* make_vec() in modo che non perda memoria se il riempimento lancia, senza try/catch. Traccia. unique_ptr<vector<int>> p{new vector<int>} (o make_unique), riempimento, return p; (move). Se si lancia, ~unique_ptr elimina il vettore.

Versione ripasso

unique/unique.cppcpp
#include <iostream>
#include <memory>
#include <utility>
#include <vector>
using namespace std;

struct X {
    int v;
    explicit X(int vv) : v{vv} { cout << "  X(" << v << ")\n"; }
    ~X() { cout << "  ~X(" << v << ")\n"; }
};

unique_ptr<int> AllocateAndReturn()
{
    unique_ptr<int> ptr_to_return(new int(42));
    return ptr_to_return;                        // niente copia: si usa il move constructor
}

unique_ptr<vector<int>> make_vec()
{
    unique_ptr<vector<int>> p{new vector<int>};  // acquisizione nel costruttore
    p->push_back(1);                             // -> come un puntatore
    p->push_back(2);
    return p;                                    // la proprieta' passa al chiamante
}

int main()
{
    cout << "1) uscita dallo scope\n";
    {
        unique_ptr<X> p{new X{1}};
        cout << "  *p = " << p->v << '\n';
    }                                            // ~X(1) automatico

    cout << "2) reset\n";
    unique_ptr<X> q{new X{2}};
    q.reset(new X{3});                           // distrugge X(2), poi tiene X(3)
    q.reset();                                   // senza argomenti: distrugge X(3)
    cout << "  q vuoto: " << (q == nullptr) << '\n';

    cout << "3) release\n";
    unique_ptr<X> r{new X{4}};
    X* grezzo = r.release();                     // r non possiede piu' nulla, nessuna delete
    cout << "  r vuoto: " << (r == nullptr) << ", grezzo->v = " << grezzo->v << '\n';
    delete grezzo;                               // ora la delete e' a carico nostro

    cout << "4) move\n";
    unique_ptr<X> a = make_unique<X>(5);         // make_unique: nessun puntatore grezzo in vista
    unique_ptr<X> b = std::move(a);                   // spostamento: a diventa vuoto
    cout << "  a vuoto: " << (a == nullptr) << ", b->v = " << b->v << '\n';
    // unique_ptr<X> c = b;                      // errore di compilazione: copia cancellata

    cout << "5) ritorno da funzione\n";
    unique_ptr<int> n = AllocateAndReturn();
    cout << "  *n = " << *n << '\n';
    auto v = make_vec();
    cout << "  v->size() = " << v->size() << '\n';

    cout << "6) array\n";
    unique_ptr<int[]> arr = make_unique<int[]>(5);   // usa delete[] da solo; valori inizializzati a 0
    arr[2] = 9;
    cout << "  arr[0] = " << arr[0] << ", arr[2] = " << arr[2] << '\n';

    cout << "fine main\n";
}

Esercizi su questo argomento

Teoria collegata