C++11 ha introdotto std::unique_ptr e std::shared_ptr per sostituire il non sicuro std::auto_ptr. Entrambi supportano deleters personalizzati per gestire risorse non relative alla memoria come maniglie di file o connessioni a database. Tuttavia, i loro approcci architettonici differiscono fondamentalmente a causa dei loro modelli di proprietà e delle esigenze di prestazioni.
std::unique_ptr implementa la proprietà esclusiva e memorizza il suo deleter come parte del suo tipo (il secondo parametro template). Se il deleter è con stato, occupa spazio all'interno dell'oggetto unique_ptr stesso insieme al puntatore gestito. std::shared_ptr implementa la proprietà condivisa tramite un blocco di controllo allocato nell'heap, dove il deleter è cancellato di tipo e memorizzato separatamente dall'oggetto shared_ptr.
Questa differenza architettonica porta a caratteristiche di dimensione distinte. Un std::unique_ptr con un deleter senza stato occupa esattamente lo stesso spazio di un puntatore grezzo grazie all'Ottimizzazione della base vuota. Al contrario, std::shared_ptr mantiene una dimensione costante (di solito due puntatori) indipendentemente dalla dimensione o complessità del deleter, perché il deleter risiede nel blocco di controllo allocato separatamente.
#include <memory> #include <cstdio> #include <iostream> struct FileDeleter { void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; struct StatefulDeleter { int flags = 0xDEAD; void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; int main() { // unique_ptr con deleter senza stato: dimensione == dimensione puntatore (8 byte su 64-bit) std::unique_ptr<FILE, FileDeleter> up(nullptr); // shared_ptr: dimensione costante (16 byte) indipendentemente dal deleter std::shared_ptr<FILE> sp(nullptr, FileDeleter{}); std::cout << "Unico (senza stato): " << sizeof(up) << " byte\n"; std::cout << "Condiviso (qualunque deleter): " << sizeof(sp) << " byte\n"; // unique_ptr con deleter con stato: dimensione maggiore (16 byte: puntatore + int + padding) std::unique_ptr<FILE, StatefulDeleter> up2(nullptr, StatefulDeleter{}); std::shared_ptr<FILE> sp2(nullptr, StatefulDeleter{}); std::cout << "Unico (con stato): " << sizeof(up2) << " byte\n"; std::cout << "Condiviso (con stato): " << sizeof(sp2) << " byte\n"; }
Un team di sviluppo aveva bisogno di gestire maniglie di connessione a database legacy (void*) restituite da una API C. Queste maniglie richiedevano una pulizia specifica tramite db_disconnect() piuttosto che delete. L'applicazione creava migliaia di maniglie al secondo in cicli stretti, rendendo critica la dimensione della memoria e le prestazioni di allocazione.
Il primo approccio considerato era una classe wrapper RAII personalizzata ConnectionGuard che memorizzava la maniglia e chiamava db_disconnect() nel suo distruttore. I pro includevano un controllo completo sull'interfaccia e la possibilità di aggiungere metodi specifici della connessione. I contro includevano un codice boilerplate significativo per ogni tipo di risorsa, la reinvenzione della semantica dei puntatori e l'incompatibilità con gli algoritmi della libreria standard progettati per puntatori intelligenti.
La seconda soluzione ha utilizzato std::shared_ptr<void> con un deleter lambda che catturava la funzione di disconnessione. I pro includevano disponibilità immediata usando componenti standard e la capacità a prova di futuro di condividere la proprietà se necessario. I contro includevano l'allocazione obbligatoria nell'heap per il blocco di controllo, il sovraccarico del conteggio di riferimento atomico inadatto per la proprietà unica ad alta frequenza e una dimensione dell'oggetto fissa di 16 byte indipendentemente dalla natura leggera della maniglia.
Il terzo approccio ha impiegato std::unique_ptr<void, decltype(&db_disconnect)> con un deleter puntatore a funzione, o preferibilmente un functor senza stato. I pro includevano zero sovraccarico quando si utilizzano functor senza stato grazie all'Ottimizzazione della base vuota (corrispondente alla dimensione del puntatore grezzo di 8 byte), nessuna allocazione nell'heap e l'espressione perfetta della semantica di proprietà esclusiva. I contro includevano la verbosità della firma di tipo e l'incapacità di cambiare i deleters a tempo di esecuzione.
Il team ha selezionato la terza soluzione con un deleter functor senza stato. Questa scelta ha eliminato completamente le allocazioni nell'heap, ridotto le dimensioni del wrapper a 8 byte e rimosso il sovraccarico delle operazioni atomiche mantenendo la pulizia automatica.
Il risultato è stata una riduzione del 40% nell'uso della memoria e significativi miglioramenti in latenza nel sistema di pooling delle connessioni, ottenendo sicurezza contro le eccezioni senza compromettere le prestazioni.
Perché std::unique_ptr richiede un tipo completo al momento della distruzione quando utilizza il deleter predefinito, mentre std::shared_ptr no?
Risposta: std::unique_ptr con il deleter predefinito chiama delete sul puntatore gestito. Lo standard C++ richiede che delete su un puntatore a T abbia T definito come tipo completo per invocare il distruttore e calcolare la dimensione per la deallocazione. Se il distruttore di unique_ptr è istanziato dove T è solo dichiarato in anticipo, la compilazione fallisce. std::shared_ptr cattura il deleter (che sa come distruggere T) al momento della costruzione nel blocco di controllo. Poiché il deleter è cancellato di tipo e memorizzato separatamente, shared_ptr può essere distrutto successivamente dove T è incompleto. Questa distinzione è cruciale per l'idioma Pimpl (Puntatore all'Implementazione): shared_ptr consente di nascondere i dettagli di implementazione nei file sorgente mentre unique_ptr richiede tipi completi o deleter personalizzati espliciti definiti dove l'implementazione è visibile.
Perché std::make_unique non supporta deleters personalizzati e qual è l'alternativa raccomandata?
Risposta: std::make_unique (introdotto in C++14) fornisce un'allocazione sicura da eccezioni ma restituisce solo std::unique_ptr<T> o std::unique_ptr<T[]>, che utilizzano std::default_delete. La funzione non può dedurre il tipo del deleter dagli argomenti perché il tipo del deleter deve far parte della firma template di unique_ptr, e le funzioni factory non possono dedurre implicitamente i tipi di deleter personalizzati senza specifici parametri template. L'alternativa raccomandata è la costruzione diretta: std::unique_ptr<T, CustomDeleter>(new T(args), CustomDeleter{...}). Questo approccio specifica esplicitamente il tipo del deleter nel template, mentre consente la logica di pulizia delle risorse personalizzata, anche se richiede una gestione manuale delle eccezioni o un ordine di costruzione attento per mantenere le garanzie di sicurezza contro le eccezioni.
Come influisce l'Ottimizzazione della base vuota sulla disposizione della memoria di std::unique_ptr quando si utilizzano deleters senza stato, e perché questo non è disponibile per std::shared_ptr?
Risposta: std::unique_ptr eredita dalla sua classe deleter quando il deleter è di tipo classe. Se il deleter non contiene membri dati (senza stato), C++ applica l'Ottimizzazione della base vuota (EBO), consentendo al sottoggetto vuoto della base di occupare zero byte. Di conseguenza, sizeof(std::unique_ptr<T, StatelessDeleter>) è uguale a sizeof(T*), raggiungendo un'astrazione senza sovraccarico. std::shared_ptr non può utilizzare l'EBO perché deve supportare la cancellazione di tipo: qualsiasi shared_ptr dello stesso T deve avere la stessa dimensione indipendentemente dal deleter. Pertanto, shared_ptr memorizza il deleter nel blocco di controllo allocato nell'heap piuttosto che all'interno dell'oggetto shared_ptr stesso. Questo design consente il polimorfismo a tempo di esecuzione dei deleters ma costringe a un'allocazione nell'heap e impedisce l'ottimizzazione dello spazio stack di cui gode unique_ptr.