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:
#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
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:
#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).
#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ù).
#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
- Il default è nessun puntatore proprietario: oggetti locali,
vector,string. - Se serve un oggetto nel free store con un solo proprietario (tipico: una gerarchia polimorfa, C++ - ereditarietà, funzioni virtuali e layout degli oggettiL'ereditarietà pubblica (class D : public B) esprime la relazione "is a": D eredita da B i membri public e protected e li estende; la composizione esprime invece "has a". Una classe base di una gerarchia disabilita copia e assegnamento (=delete) perché copiando una derivata in una base si ha lo slicing. Una funzione virtuale può essere ridefinita nelle derivate (override: stesso nome, tipi e constness) e la funzione chiamata si decide a run-time in base al tipo dell'oggetto, non del puntatore o del riferimento; override esplicito con la keyword override; una funzione virtuale pura (= 0) obbliga le derivate a ridefinirla e rende astratta la classe; il distruttore di una classe base polimorfa è virtuale. Il meccanismo è realizzato con una vtbl per classe (tabella di puntatori alle funzioni) e un vptr in ogni oggetto; i dati della derivata seguono quelli della base.C++ - ereditarietà, funzioni virtuali e layout degli oggetti →):
unique_ptr, creato conmake_unique; per cederlo,std::moveo ritorno da funzione. - Se la proprietà è condivisa davvero:
shared_ptrconmake_shared; per i riferimenti non proprietari che possono cadere nel ciclo:weak_ptr. - Un puntatore grezzo resta lecito per osservare (non possedere) un oggetto:
p.get()o un riferimento. Non si fa maideletesu un puntatore ottenuto conget(). - Non si inizializzano due smart pointer con lo stesso puntatore grezzo; non si mescola
new[]conunique_ptr<int>(serveunique_ptr<int[]>).
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
Smart pointer = classe template di
<memory>che detiene un puntatore e lo dealloca all'uscita dallo scope (RAII): niente leak né dangling, overhead minimo. Supporta*e->. Tipi:unique_ptr,shared_ptr,weak_ptr;auto_ptrdeprecato (rimosso in C++17). Vedi 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) →.unique_ptr: un solo proprietario; copia cancellata, move consentito; nessun overhead sostanziale.reset(q)distrugge il vecchio e tieneq;reset()distrugge e svuota;release()cede il puntatore grezzo e azzera lounique_ptr(ladeletetorna a te);get()osserva;std::move(p)passa la proprietà.Ritorno da funzione:
unique_ptr<vector<int>> make_vec()→return p;usa il move: nessunadeletenel chiamante e nessuntry/catch(se il riempimento lancia, il vettore è distrutto).Debolezza:
unique_ptr<int> a{p}; unique_ptr<int> b{p};compila ma fa doppiadelete. Rimedio:make_unique<T>(args)(non espone il puntatore grezzo). Per gli arrayunique_ptr<int[]>(delete[]).shared_ptr: più proprietari, copia consentita, reference counting (use_count()); l'oggetto è distrutto quando l'ultimoshared_ptrmuore; costa di più.make_shared<T>(args).weak_ptr: osserva senza possedere,lock()dà unoshared_ptr; rompe i cicli (a->succshared,b->precweak).Scelta: default nessun puntatore proprietario; un proprietario →
unique_ptr; proprietà condivisa →shared_ptr; puntatore grezzo solo per osservare.Errori tipici: due smart pointer dallo stesso puntatore,
deletesuget(), dimenticare che doporelease()serve ladelete, copiare unounique_ptr, ciclo dishared_ptr.Esercizio: Esercizio - Un unique_ptr minimo e make_unique (laboratorio 10); lezione sulle gerarchie: C++ - ereditarietà, funzioni virtuali e layout degli oggettiL'ereditarietà pubblica (class D : public B) esprime la relazione "is a": D eredita da B i membri public e protected e li estende; la composizione esprime invece "has a". Una classe base di una gerarchia disabilita copia e assegnamento (=delete) perché copiando una derivata in una base si ha lo slicing. Una funzione virtuale può essere ridefinita nelle derivate (override: stesso nome, tipi e constness) e la funzione chiamata si decide a run-time in base al tipo dell'oggetto, non del puntatore o del riferimento; override esplicito con la keyword override; una funzione virtuale pura (= 0) obbliga le derivate a ridefinirla e rende astratta la classe; il distruttore di una classe base polimorfa è virtuale. Il meccanismo è realizzato con una vtbl per classe (tabella di puntatori alle funzioni) e un vptr in ogni oggetto; i dati della derivata seguono quelli della base.C++ - ereditarietà, funzioni virtuali e layout degli oggetti →.
Codice (unique/unique.cpp):
#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
- Esercizio - Allocazione dinamica e layout della memoria (primo appello 2022-23, teoria)
- Esercizio - Copiare da array a array dinamico senza delete (terzo appello 2022-23, programmazione)
- Esercizio - Gerarchia MyPointer, MyUniquePointer e MySharedPointer (terzo appello 2021-22, programmazione)
- Esercizio - Quiz e domande su funzioni virtuali, passaggio degli argomenti, dangling pointer e doppia deallocazione (quarto appello 2023-24, teoria)
- Esercizio - Quiz e domande su polimorfismo, allocazione dinamica e garanzie sulle eccezioni (secondo appello 2023-24, teoria)
- Esercizio - Quiz e domande su smart pointer, function object e aritmetica dei puntatori (terzo appello 2023-24, teoria)
- Esercizio - Quiz su constexpr, ordine di valutazione, dynamic_cast e lambda (primo appello 2022-23, quiz)
- Esercizio - RAII e garanzie sulle eccezioni (quarto appello 2021-22, teoria)
- Esercizio - RAII e garanzie sulle eccezioni (terzo appello 2022-23, teoria)
- Esercizio - Shallow copy e deep copy (quarto appello 2021-22, teoria)
- Esercizio - Smart pointer, unique_ptr e shared_ptr (primo appello 2021-22, teoria)
- Esercizio - Spostare i valori di una list in un array dinamico senza delete (quarto appello 2021-22, programmazione)
- Esercizio - Un unique_ptr minimo e make_unique (laboratorio 10)