1. Introduzione
Quando si scopre per la prima volta shared_ptr, la mente tira subito la conclusione felice: “Oh! Ora non devo più pensare al lifetime degli oggetti!”. È un po' come comprare un frigorifero grande e credere che non serva più controllare le scadenze degli alimenti. Bisogna comunque pensare — solo che i problemi diventano più costosi e interessanti.
Il motivo è che shared_ptr risolve un problema: «quando distruggere l'oggetto, se ci sono più proprietari». Ma non rende automaticamente il design corretto. Inoltre, se si comincia a “spalmare” shared_ptr ovunque per sicurezza, si ottiene una nuova classe di errori: cicli di ownership, estensioni di vita inattese, dipendenze imprevedibili e API in cui è difficile capire chi è responsabile di cosa.
Perciò serve una convenzione a livello di progetto: di default usiamo unique_ptr, e passiamo a shared_ptr solo quando la ownership condivisa ha davvero senso. E il senso non deve essere “mi sento più tranquillo”, ma «per i requisiti del programma ci sono realmente più proprietari indipendenti».
Perché «passare a shared_ptr per non pensare» è un patto pericoloso
Immaginiamo una situazione tipica: restituisci un Task* verso l'esterno, l'utente seleziona il task e poi lo storage lo elimina — quel puntatore diventa dangling. A un principiante può venire l'idea: «Oh! Facciamo shared_ptr, così l'oggetto vive finché qualcuno lo riferisce».
A volte risolve davvero il problema, ma il costo può superare il problema stesso. Perché hai cambiato l'architettura: ora i task vivono “finché qualcuno li tiene”. Questo può portare a effetti inattesi: hai rimosso il task dallo storage, ma continua a vivere perché da qualche parte nell'interfaccia c'era ancora una copia di shared_ptr. E l'utente poi si chiede: «Perché esiste ancora?»
Quindi shared_ptr non è una «riparazione del dangling pointer». È il passaggio a un altro modello di vita degli oggetti. Talvolta necessario, talvolta scelto per errore.
2. Modello rapido dei ruoli: proprietario, coinvolto e osservatore
Prima di scegliere il puntatore è utile farsi una domanda: che ruolo ha questo riferimento sull'oggetto? Bastano tre parole: proprietario, coinvolto, osservatore.
Il proprietario è responsabile del lifetime. Finché lui esiste — l'oggetto è garantito vivo. Quando il proprietario muore — l'oggetto dovrebbe essere distrutto (o la ownership trasferita a un altro proprietario). Questo è il ruolo naturale per unique_ptr.
Il coinvolto (co-owner) è anch'egli responsabile del lifetime, ma ce ne sono diversi. L'oggetto vive finché almeno un coinvolto esiste. Questo è shared_ptr.
L'osservatore ha accesso “se va bene”: può osservare l'oggetto se è ancora vivo, ma non ha il diritto di mantenerlo in vita. Questo è weak_ptr (o a volte un pointeur grezzo/riferimento come accesso non proprietario, ma qui ci concentriamo sull'accoppiata shared_ptr/weak_ptr).
È importante non solo conoscere questo modello, ma saperlo «leggere a occhi» nei tipi del codice: il tipo di un campo o di un parametro dovrebbe suggerire chi possiede cosa.
3. Regola №1: iniziare con unique_ptr
Se un oggetto ha un proprietario ovvio, std::unique_ptr<T> è quasi sempre la scelta migliore. Si guadagna subito tutto: leggibilità del codice, prevedibilità del lifetime, assenza di cicli di ownership, minori overhead e debug più semplice.
In pratica unique_ptr è il “puntatore-proprietario” che dichiara esplicitamente: «l'oggetto mi appartiene, e solo a me». Se bisogna trasferire la proprietà — lo si fa esplicitamente con std::move. Questo è ottimo: nel codice si vede il punto in cui la responsabilità si sposta.
Rafforziamo il concetto con un piccolo frammento della nostra applicazione. Supponiamo di scrivere un semplice "task manager" da console e di voler conservare i task in memoria. Nel modello più semplice lo storage possiede i task.
#include <memory>
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
};
struct TaskStorage {
std::vector<std::unique_ptr<Task>> tasks;
};
Qui si legge facilmente: TaskStorage è il proprietario di tutti i task. Nessun altro ha il diritto di possedere i task «di default». Quindi il lifetime di un task è chiaro: finché è dentro tasks, esiste.
Come restituire un oggetto all'esterno senza cedere la proprietà
Quando si usa unique_ptr, sorge spesso la domanda: «Come restituire il task all'esterno?». Un principiante a volte vuole ritornare unique_ptr da findTask() — ma così lo storage perderebbe la proprietà. Di solito non è quello che si desidera per una ricerca.
La risposta corretta spesso è: lo storage possiede, e all'esterno si fornisce un accesso non proprietario. Per esempio un puntatore grezzo Task* (che può essere null), oppure un riferimento Task& (se è garantito) o const Task* / const Task& per sola lettura.
Esempio: cerchiamo il task e ritorniamo un puntatore non proprietario. Notare: non è «restituire la proprietà grezza», è «dare la possibilità di guardare».
#include <memory>
#include <vector>
Task* find_task(std::vector<std::unique_ptr<Task>>& tasks, int id) {
for (auto& p : tasks) {
if (p->id == id) return p.get(); // puntatore non proprietario
}
return nullptr;
}
Abitudine importante: .get() è letteralmente “dammi l'indirizzo raw, ma tieniti la proprietà”. È un'operazione normale se sai cosa fai.
4. Quando shared_ptr è davvero giustificato
std::shared_ptr ha senso quando, per la logica del programma, esistono più componenti indipendenti, ognuno dei quali può dire: «ho bisogno che l'oggetto rimanga vivo mentre lavoro».
Parola chiave — indipendenti. Non «ho pigrizia di pensare», non «ho paura», ma proprio «per i requisiti di sistema l'oggetto deve sopravvivere ai movimenti tra parti diverse senza un singolo centro di ownership».
Esempio pratico (senza framework complessi): c'è un oggetto "Impostazioni applicazione" usato da moduli diversi. Se quei moduli vivono in maniera indipendente e possono sopravvivere l'uno all'altro, a volte è comodo avere ownership condivisa delle impostazioni. Ma anche qui spesso è sufficiente passare un const& al proprietario unico — semplicemente il proprietario deve essere chiaramente definito.
Un esempio più onesto per shared_ptr: abbiamo il risultato di un calcolo in cache che può essere fornito a più parti del programma e ognuna vuole garantire che il risultato non sia distrutto finché lo usa.
#include <memory>
#include <string>
struct Report {
std::string text;
};
std::shared_ptr<Report> make_report() {
auto r = std::make_shared<Report>();
r->text = "Weekly report";
return r; // restituiamo una quota di ownership all'esterno
}
Restituire shared_ptr per valore è un contratto leggibile: «creo l'oggetto e lo do in ownership condivisa».
Perché shared_ptr è più costoso di quanto sembri
L'overhead di shared_ptr non è solo performance. Sì, c'è il control block, operazioni atomiche sul contatore (nella maggior parte delle implementazioni), allocazioni extra e istruzioni in più. Ma ancora più grave è il costo per il modo di pensare al programma.
Con unique_ptr quasi sempre puoi rispondere: «chi possiede l'oggetto?» guardando il campo. Con shared_ptr la risposta spesso è: «beh… qualcuno». E allora inizia la caccia: cercare tutte le copie, capire perché l'oggetto è ancora vivo e perché il distruttore non è stato chiamato.
Particolare rilevante: use_count() sembra una zattera di salvataggio (“guardo quanti proprietari ci sono!”), ma nel codice reale è una base fragile per la logica. use_count() ha garanzie deboli e non si può costruire su di essa la correttezza in scenari multithread o sottili; è piuttosto uno strumento diagnostico.
Perciò shared_ptr è uno strumento potente, ma è meglio tirarlo fuori non dalla “tasca di tutti i giorni”, bensì dalla cassetta degli attrezzi quando sei sicuro che serve.
5. weak_ptr: “voglio riferirmi ma non possedere”
Se hai già shared_ptr, quasi subito nasce la domanda: «Come riferirmi a un oggetto da un altro oggetto senza creare un ciclo di ownership?». La risposta è weak_ptr.
Dal punto di vista delle regole di scelta, weak_ptr non è un “terzo” tra unique/shared, ma un complemento di shared_ptr. Compare quando vuoi costruire navigazione (per esempio il “back” in una struttura doppiamente collegata, o il puntatore al genitore in un nodo) senza rendere quella relazione proprietaria.
Un buon pattern: «conservo un weak_ptr, e quando lo uso faccio una sola volta lock() e lavoro tramite il shared_ptr ottenuto».
#include <memory>
struct Task;
struct Link {
std::weak_ptr<Task> selected;
};
struct Task {
int id{};
};
Qui Link non mantiene vivo il Task. Tiene solo in memoria: «se quel task esiste ancora — posso ottenerne l'accesso».
6. Promemoria di scelta: regola, tabella e mini-schema
Se un oggetto ha un proprietario ovvio (contenitore, manager, “storage”, oggetto genitore) — usiamo unique_ptr. Se qualcuno deve solo osservare — forniamo T*, T&, const T*, const T& (a seconda del contratto).
Se per design servono più proprietari indipendenti, e non c'è un centro di ownership semplice da usare come proprietario, — allora shared_ptr.
Se serve «riferirsi ma non possedere» nel mondo di shared_ptr — usiamo weak_ptr.
Per ricordarlo più facilmente, tenete una breve tabella di scelta.
| Situazione nel codice | Che cosa si vuole garantire | Scelta tipica |
|---|---|---|
| «L'oggetto appartiene al contenitore/storage» | Un proprietario chiaro e unico | |
| «Occorre trasferire la proprietà» | Il nuovo proprietario si prende la responsabilità | |
| «Più parti del programma devono mantenere l'oggetto vivo» | L'oggetto vive finché serve a qualcuno | |
| «Serve ricordare un riferimento ma non prolungare la vita» | Accesso solo se l'oggetto è ancora vivo | |
Mini-schema decisionale
Per non meditare troppo ogni volta che vedi smart pointer, è utile avere in testa un algoritmo decisionale breve:
flowchart TD
A["Serve conservare/trasmettere un riferimento all'oggetto"] --> B{"Chi gestisce il lifetime?"}
B -->|Un proprietario evidente| C["unique_ptr dal proprietario"]
C --> D["All'esterno: T*/T&/const T& (osservazione)"]
B -->|Più proprietari indipendenti| E["shared_ptr dai co-owner"]
E --> F{"Ci sono riferimenti inversi/cicli?"}
F -->|Sì| G["Spezzare: weak_ptr da un lato"]
F -->|No| H["shared_ptr dove serve la proprietà"]
7. Quando è meglio usare un ID invece di un puntatore
C'è un altro punto molto pratico: a volte si tende allo shared_ptr non perché serve veramente la ownership condivisa, ma perché si vuole conservare un «riferimento a vita lunga» a un oggetto che può scomparire.
In questi casi spesso è più corretto conservare non il puntatore ma un identificatore (per esempio un int id) e cercare l'oggetto ogni volta che serve. Sì, è un po' più codice, ma non si ha l'estensione nascosta della vita dell'oggetto e non ci si chiede «perché è ancora vivo».
Idea minima per la nostra app Task: l'interfaccia conserva selected_id, non shared_ptr<Task>.
#include <optional>
struct UiState {
std::optional<int> selected_task_id;
};
Poi, per visualizzare o modificare, si fa find_task(..., *selected_task_id) e si gestisce onestamente il nullptr. A volte è molto più semplice che cambiare il modello di ownership dell'intera applicazione.
8. Errori tipici
Errore №1: usare shared_ptr «di default» perché fa stare più tranquilli.
È una trappola umana: mettere un "casco protettivo" su tutto. Ma shared_ptr non è un casco, è più simile a un'armatura pesante: ci si può muovere, ma ci si stanca in fretta e a volte non si passa nemmeno dalle porte. Se un oggetto ha un proprietario unico, unique_ptr rende il lifetime più semplice e onesto.
Errore №2: tentare di riparare dangling pointer passando a shared_ptr senza cambiare il modello.
A volte il problema non è che servono co-owner, ma che si tiene il riferimento sbagliato (per esempio un riferimento a un elemento di un contenitore poi rimosso). Spesso è meglio conservare un id e cercare nuovamente l'oggetto, piuttosto che creare un oggetto “sempre vivo” tramite ownership condivisa.
Errore №3: conservare shared_ptr in entrambe le direzioni e creare un ciclo di ownership.
Non appena compaiono relazioni bidirezionali (“parent–child”, “prev–next”, “a riferisce b e b riferisce a”), il rischio di ciclo diventa quasi automatico. La cura non è “chiamare reset da qualche parte”, ma cambiare il ruolo della relazione: una delle due parti deve essere weak_ptr.
Errore №4: costruire la logica del programma su use_count().
use_count() può essere utile per debug e apprendimento, ma per la correttezza del programma è una base fragile: il numero di proprietari può cambiare e le garanzie sono limitate. Consideratelo diagnostico, non condizione logica «se i proprietari sono 1 — allora...».
Errore №5: confondere «osservazione» e «proprietà» nelle signature delle funzioni.
Se una funzione prende std::shared_ptr<T> per valore, diventa co-proprietaria — anche se voleva solo stampare l'oggetto. Se la proprietà non è necessaria, passate const std::shared_ptr<T>& (quando è importante il fatto che sia shared) o meglio ancora const T& (quando serve solo accesso ai dati). Più onesta è la signature, meno sorprese sul lifetime.
GO TO FULL VERSION