Distruttori in C++: gestire la fine del ciclo di vita di un oggetto
Quando si parla di C++ e della sua filosofia orientata agli oggetti, la conversazione tende spesso a gravitare intorno a costruttori, metodi, ereditarietà e polimorfismo. Eppure, esiste un aspetto che completa il quadro in modo cruciale: la “fine” del ciclo di vita di un oggetto. In C++, questo momento è governato da una funzione speciale chiamata “distruttore”. Spesso poco considerato a un primo sguardo, il distruttore rivela invece la strategia più profonda del linguaggio nella gestione di risorse, memoria e azioni da intraprendere prima che un oggetto scompaia. Questa lunga trattazione vuole guidare il lettore attraverso ogni dettaglio legato ai distruttori, spiegando perché siano uno strumento insostituibile per scrivere codice affidabile, efficiente e coerente con i principi su cui si basa C++.
L’intento è di trasmettere non solo i rudimenti sintattici, ma soprattutto il senso del distruttore come momento in cui la classe ripulisce ciò che aveva acquisito, chiude file, dealloca memoria, libera handle di sistema e compie tutte quelle operazioni indispensabili per evitare fughe di risorse. Ci muoveremo in un’ottica informale e discorsiva, con qualche esempio pratico che renda palese come i distruttori siano parte integrante di uno schema più ampio, noto come RAII (Resource Acquisition Is Initialization). Senza di essi, il linguaggio non avrebbe quella completezza che tanto lo distingue in ambiti mission-critical, embedded, gaming e molte altre aree di sviluppo.

Il ruolo del distruttore: un segnale di pulizia obbligatoria
Partiamo dalla definizione più immediata. In C++, il distruttore è una funzione membro speciale che porta lo stesso nome della classe, preceduto da un simbolo tilde (~
). Non ha tipo di ritorno (nemmeno void
) e non accetta parametri. Viene invocato in modo automatico dal linguaggio quando l’oggetto esce dallo scope, quando si invoca delete
su un puntatore che fa riferimento a quell’oggetto, o quando un’operazione di “smontaggio” dell’istanza è in corso.
Se definiamo una classe chiamata Esempio
, il suo distruttore apparirà così:
class Esempio {
public:
~Esempio() {
// Azioni di pulizia
}
};
Nel momento in cui un’istanza di Esempio
viene distrutta (perché finisce la sua validità nel blocco di codice in cui era stata creata, oppure perché è stata creata dinamicamente e poi eliminata con delete
), il corpo del distruttore viene eseguito. Una volta terminato, l’oggetto non esiste più. Questo meccanismo vale anche per oggetti contenuti in array, membri di altre classi, container standard e via dicendo.
L’utilità è evidente nel momento in cui la classe aveva acquisito una risorsa (un file, un blocco di memoria dinamica, una connessione di rete). Il distruttore garantisce che, prima di liberare la memoria che ospita i dati dell’oggetto, venga rilasciata qualsiasi risorsa gestita. Senza tale passaggio, le risorse rimarrebbero aperte o allocate, causando perdita di memoria o malfunzionamenti.
Distruttore di default vs. distruttore personalizzato
Se la classe non definisce esplicitamente un distruttore, il compilatore ne genera automaticamente uno di default, che di norma svolge un compito essenziale ma basico: non fa nulla se non rimuovere l’oggetto. Per molte strutture dati semplici, in cui non vengono gestite risorse dinamiche, ciò può bastare.
Il quadro cambia non appena la classe comincia a detenere responsabilità più complesse. Se la classe crea e possiede un buffer dinamico, se apre un file o se gestisce un semaforo di sistema, allora è indispensabile introdurre un distruttore personalizzato. In assenza di esso, infatti, il codice non saprebbe come liberare e chiudere quegli elementi. È in simili contesti che C++ mostra la sua filosofia: l’oggetto diventa un vero custode, che alloca le risorse nel costruttore e le libera nel distruttore, in perfetta coerenza con RAII. Di fatto, l’utente della classe non deve preoccuparsi di ricordarsi di chiudere i file o deallocare la memoria, perché tutto avviene in automatico.
Sintassi essenziale di un distruttore
Come accennato, il distruttore non possiede parametri né tipo di ritorno. La sua firma può assumere la forma:
~Classe() {
// Corpo del distruttore
}
All’interno delle parentesi graffe, è possibile scrivere le istruzioni che si desidera eseguire prima che l’oggetto cessi di esistere. Un esempio tipico è:
class FileHandle {
private:
FILE* filePtr;
public:
FileHandle(const char* nomeFile) {
filePtr = fopen(nomeFile, "r");
}
~FileHandle() {
if (filePtr) {
fclose(filePtr);
}
}
};
Qui il distruttore si occupa di chiudere il file se è ancora aperto. È un’operazione semplice, ma estremamente significativa, perché evita la preoccupazione di dover ricordare manualmente la chiusura del file ogni volta che non serve più. Quando l’oggetto FileHandle
viene distrutto (uscendo dallo scope o per chiamata di delete
), la fclose
viene invocata in automatico.
RAII: la logica dietro a costruttori e distruttori
Spesso, quando si parla di distruttori, compare il concetto di RAII (Resource Acquisition Is Initialization). È una sigla che potrebbe far pensare a un approccio complesso, ma in realtà descrive l’idea con cui C++ gestisce le risorse: l’acquisizione avviene nel costruttore, il rilascio avviene nel distruttore. Questo comporta che la durata di una risorsa coincide con la durata dell’oggetto che la amministra.
Se si alloca memoria dinamica in un costruttore o si apre una connessione di rete, nel distruttore si dealloca quella memoria o si chiude la connessione. In questo modo, la logica di gestione è blindata dentro le funzioni membro speciali e non si sparpaglia in giro per il codice con frammenti di delete
qua e là. RAII è considerato uno dei punti di forza del C++, perché riduce in modo drastico il rischio di “perdite di memoria” (memory leaks) o di risorse lasciate in uno stato indefinito.
Un esempio chiarisce bene il concetto: se creo una classe GestoreArrayDinamico
, nel costruttore posso allocare un array con new[]
, mentre nel distruttore lo libero con delete[]
. In mezzo, l’oggetto funziona da contorno sicuro per quell’array, che non può “sfuggire” a chi lo ha creato:
class GestoreArrayDinamico {
private:
int* dati;
size_t lunghezza;
public:
GestoreArrayDinamico(size_t n) : lunghezza(n) {
dati = new int[n];
}
~GestoreArrayDinamico() {
delete[] dati;
}
// Altri metodi
};
Qualunque funzione che utilizzi GestoreArrayDinamico
non dovrà preoccuparsi di ricordarsi di liberare la memoria, poiché questo compito è incapsulato nel distruttore.
Distruttori e oggetti di classe derivata
Quando si parla di ereditarietà, la questione dei distruttori può coinvolgere alcuni dettagli interessanti. In linea di massima, quando un oggetto di classe derivata viene distrutto, prima si invoca il suo distruttore, poi, in automatico, vengono distrutti gli oggetti base e così via, risalendo la gerarchia.
Se la classe base possiede un distruttore virtuale, allora la chiamata avviene correttamente sul distruttore più derivato. Questo è un meccanismo fondamentale in un contesto polimorfico: se si ha un puntatore alla classe base che in realtà punta a un oggetto di classe derivata, al momento della distruzione, è indispensabile che venga chiamato il distruttore della derivata. Per garantire ciò, occorre che il distruttore della base sia dichiarato come virtual
. Esempio:
class Base {
public:
virtual ~Base() {
// Distruttore virtuale
}
};
class Derivata : public Base {
public:
~Derivata() {
// Pulizia specifica di Derivata
}
};
int main() {
Base* ptr = new Derivata();
delete ptr; // Invoca ~Derivata e poi ~Base, grazie al distruttore virtuale
return 0;
}
Se ~Base
non fosse virtuale, la chiamata a delete ptr
sarebbe legata staticamente al tipo Base
, causando l’esecuzione del solo distruttore di Base
. Questa omissione, in un contesto dove la classe base viene utilizzata come interfaccia polimorfica, genera errori o fughe di risorse, perché la parte specifica di Derivata
non verrebbe mai smantellata a dovere.
Distruttori e sequenza di distruzione dei membri
Un altro dettaglio talvolta trascurato è la sequenza in cui i membri di una classe vengono distrutti. In C++, i membri vengono distrutti nell’ordine inverso rispetto a quello in cui sono dichiarati. Se, per esempio, nella classe abbiamo:
class Contenitore {
private:
std::string nome;
std::vector<int> dati;
public:
Contenitore() {
// ...
}
~Contenitore() {
// ...
}
};
al momento della distruzione, prima verrà distrutto dati
, poi nome
, perché l’ordine di dichiarazione nella classe è nome
poi dati
. Tuttavia, la regola del C++ recita che i membri sono inizializzati e poi distrutti nell’ordine in cui sono scritti all’interno della dichiarazione della classe, non in base a come appaiono nella lista di inizializzazione del costruttore. È quindi buona pratica dichiarare i membri nella stessa sequenza con cui si desidera realmente gestirli, così da evitare comportamenti inattesi in caso di dipendenze fra i membri stessi.
Distruttori e oggetti temporanei
In molte circostanze, in C++ vengono creati “oggetti temporanei” che vivono un battito di ciglia nell’espressione in cui compaiono. È comune soprattutto con operatori sovraccaricati, funzioni che restituiscono oggetti per valore, inizializzatori di funzione e via dicendo. Anche questi oggetti temporanei, benché invisibili all’occhio del programmatore, sono soggetti all’invocazione del distruttore quando la loro durata di vita termina. Ciò significa che se un oggetto temporaneo acquisisce risorse, le rilascerà immediatamente dopo che non viene più utilizzato, rispettando comunque la regola RAII.
Un esempio può emergere in situazioni di calcolo matematico:
class Vettore {
private:
double x, y;
public:
Vettore(double nx, double ny) : x(nx), y(ny) {}
~Vettore() {
// Distruttore
}
Vettore operator+(const Vettore& altro) const {
return Vettore(x + altro.x, y + altro.y);
}
};
int main() {
Vettore v1(1, 2);
Vettore v2(3, 4);
Vettore risultato = v1 + v2; // restituisce un temporaneo, poi copiato in risultato
return 0;
}
Nel frammento, v1 + v2
crea un oggetto temporaneo che, subito dopo la sua costruzione, viene usato per inizializzare risultato
. A quel punto, il temporaneo scompare e il suo distruttore viene chiamato. Questo processo si verifica in modo trasparente, e se la nostra classe avesse risorse critiche, C++ garantirebbe comunque il rilascio in automatico quando termina la durata di vita del temporaneo.
Distruttori e eccezioni
Che cosa accade se all’interno di un distruttore si genera un’eccezione non gestita? È una situazione delicata, perché il distruttore viene spesso invocato in contesti dove è già in atto un’operazione di pulizia che potrebbe aver seguito a un’eccezione precedente. Lanciare un’eccezione nel distruttore può quindi portare a un’eccezione “annidata” (detta anche double exception) e terminare in modo brusco il programma. Per queste ragioni, è una regola comunemente accettata che i distruttori non dovrebbero mai fallire, cioè non dovrebbero mai propagare eccezioni.
Se un’operazione rischia di generare un’eccezione nel distruttore, è opportuno catturarla internamente e magari registrare il problema in un log, ma senza farlo “uscire” all’esterno. Lo scopo del distruttore, dopotutto, è garantire che la pulizia venga eseguita anche in situazioni di errore, e se un’eccezione sfugge fuori, la stabilità del programma può essere a rischio.
Distruttori, polimorfismo e istruzione delete
Se in un progetto si usa l’ereditarietà per avere un polimorfismo di runtime (tramite puntatori o riferimenti alla classe base), ricordare di definire il distruttore virtuale nella base è essenziale. Altrimenti si hanno scenari potenzialmente catastrofici, con fughe di risorse e comportamenti indefiniti. Un distruttore virtuale si dichiara nel modo seguente:
class Base {
public:
virtual ~Base() {
// Pulizia base
}
};
class Derivata : public Base {
public:
~Derivata() {
// Pulizia derivata
}
};
In questo modo, se nel programma si fa:
Base* obj = new Derivata();
// ...
delete obj;
il chiamante scatterà la corretta sequenza di distruzione: prima ~Derivata()
, poi ~Base()
. Se invece ~Base()
non fosse virtuale, alla chiamata di delete
si richiamerebbe il distruttore di Base
, trascurando completamente la porzione di Derivata
. Qualora Derivata
gestisca risorse, andrebbero perse.
Se per qualche ragione non si prevede di utilizzare una classe base in modo polimorfico, si può omettere il distruttore virtuale. In caso di dubbi, tuttavia, è quasi sempre meglio inserirlo (se la classe è concepita per essere ereditata), in modo da mettersi al riparo da successivi sviluppi del codice che possano introdurre comportamenti polimorfici.
Distruttori e union
Le union in C++ rappresentano una peculiarità che spesso richiede particolari cautele. Una union
tradizionale, derivata dalla filosofia C, non può contenere oggetti di classe con costruttori e distruttori non banali. Tuttavia, nelle versioni più moderne di C++, esistono cosiddette “union discriminatorie” che consentono di avere tipi complessi al loro interno, ma la gestione dei distruttori diventa un terreno molto più complicato. In linea generale, è preferibile evitare di mescolare union e classi complesse con costruttori e distruttori, se non in casi molto specifici, perché si rischia di dover gestire a mano quando invocare manualmente i distruttori e quando no, perdendo così la semplicità e la sicurezza tipica del RAII.
Distruttore = default
e distruttore = delete
Così come per costruttori e operatori di copia, in C++ si può dichiarare un distruttore come = default
, segnalando esplicitamente al compilatore di generarne uno “di default” anche se la classe presenta altre funzioni speciali definite dall’utente. D’altro canto, si può marcare un distruttore con = delete
per impedire la sua invocazione. Dichiarare un distruttore = delete
è raro, ma potrebbe trovare applicazione in situazioni in cui la classe non deve essere distrutta in maniera convenzionale (ad esempio, perché l’oggetto è parte di una mappa di memoria condivisa e la sua vita è gestita con altre logiche). Bisogna usare questa possibilità con estrema cautela.
Distruttori e doppia distruzione
Un problema comune, specialmente tra chi si avvicina per la prima volta al linguaggio, è la doppia distruzione di un oggetto. Accade quando un oggetto viene creato su heap (new
) e poi eliminato (delete
) più di una volta. Il C++ non esegue controlli intrinseci su questo evento: la responsabilità è del programmatore. Effettuare due volte un delete
sullo stesso puntatore comporta un comportamento indefinito, che può manifestarsi con crash o corruzione di memoria.
Il distruttore, in sé, non fornisce meccanismi di protezione verso la doppia distruzione, anche se si potrebbero codificare delle difese manuali (per esempio, resettando i puntatori a nullptr
e controllandoli). In ogni caso, la regola generale è: “Chi allocherà l’oggetto è responsabile della sua singola e unica distruzione”. I moderni smart pointer (come std::unique_ptr
o std::shared_ptr
) aiutano a ridurre drasticamente simili errori, perché si incaricano di invocare il distruttore nel momento giusto, impedendo duplicazioni di responsabilità.
Piccola dimostrazione pratica: gestione di una connessione a un server
Per mostrare in che modo il distruttore si riveli importante nella pratica, immaginiamo una classe ConnessioneServer
. Il suo costruttore apre la connessione, mentre il distruttore la chiude. Ecco un frammento:
#include <iostream>
#include <string>
class ConnessioneServer {
private:
std::string endpoint;
bool aperta;
public:
ConnessioneServer(const std::string& ep)
: endpoint(ep), aperta(false)
{
// Simuliamo l'apertura
std::cout << "Apro connessione su " << endpoint << std::endl;
aperta = true;
}
~ConnessioneServer() {
if (aperta) {
std::cout << "Chiudo connessione su " << endpoint << std::endl;
aperta = false;
}
}
void inviaMessaggio(const std::string& msg) {
if (aperta) {
std::cout << "Invio messaggio a " << endpoint << ": " << msg << std::endl;
} else {
std::cout << "Connessione chiusa, impossibile inviare" << std::endl;
}
}
};
int main() {
{
ConnessioneServer conn("192.168.1.100");
conn.inviaMessaggio("Ciao dal main");
// Appena il blocco termina, il distruttore viene chiamato
}
std::cout << "Sono uscito dallo scope, la connessione è chiusa automaticamente" << std::endl;
return 0;
}
All’interno del blocco, conn
è attivo e gestisce la connessione. Non appena si esce dalle parentesi graffe, conn
esce dallo scope e scatta il ~ConnessioneServer()
, che stampa la conferma di chiusura. L’esecuzione del main prosegue, ma la risorsa è stata gestita in modo corretto e trasparente. Senza il distruttore, il rischio sarebbe quello di dover ricordarsi manualmente di chiamare una funzione “chiudiConnessione()”, con la possibilità che ci si dimentichi e si lasci la connessione aperta.
Distruttori in contesti reali: perché sono fondamentali
Dall’esempio della connessione a un server, si può intuire come i distruttori rappresentino un mattoncino essenziale quando si ragiona su progetti più ampi. Pensiamo a un motore di gioco 3D che gestisce texture, mesh e suoni. Ognuna di queste risorse può avere un impatto notevole sulla memoria. Se l’engine non dealloca correttamente gli asset non più necessari, in pochi minuti di gioco la memoria si esaurisce. Grazie ai distruttori, ogni classe può racchiudere la logica di rilascio delle proprie risorse. Il programmatore che usa la classe non deve tenere in mente una sequenza di pulizie: si affida al linguaggio, che chiama il distruttore in automatico alla fine della vita dell’oggetto.
Un altro ambito interessante è quello dei server ad alto carico, dove gestire in modo maniacale le risorse può fare la differenza tra un server stabile e uno soggetto a continui crash. Se ogni connessione, sessione utente o transazione viene incapsulata in un oggetto con distruttore, possiamo stare certi che non ci saranno accumuli di file descriptor o di socket non chiusi.
Lo stesso discorso vale per la programmazione embedded. In dispositivi con poca RAM, gestire correttamente la memoria può essere questione di efficienza e di stabilità del firmware. Invece di affidarsi a lunghe routine manuali di pulizia, si sfrutta la semantica RAII: l’oggetto, una volta usato, si autodistrugge (o viene distrutto dal sistema) e libera tutto ciò che aveva. L’importante è progettare la classe in modo che il suo distruttore faccia esattamente ciò che serve.
Distruttore e smontaggio di oggetti complessi
Quando una classe possiede al suo interno altri oggetti, la distruzione segue la sequenza inversa all’inizializzazione. Prima vengono distrutti i membri più recenti, poi quelli precedenti, e infine la classe contenitore stessa. Se esistono molte dipendenze tra i membri, bisogna assicurarsi che l’ordine di distruzione non crei problemi. Se, per esempio, un membro A dipende da un membro B, conviene dichiarare B prima di A, cosicché in fase di distruzione A venga smantellato prima di B.
Un caso classico riguarda un std::unique_ptr
che dipende da un std::mutex
di classe. Se dichiarati in un ordine non coerente, potremmo trovarci a dover rilasciare un lock dopo che il mutex è già stato distrutto. Per evitare simili scenari, il suggerimento è di dichiarare i membri in ordine logico, riflettendo le effettive dipendenze tra di essi.
Distruttori e performance
Da un punto di vista puramente prestazionale, la chiamata a un distruttore ha un costo che di solito è irrisorio rispetto all’operazione di pulizia che spesso vi è contenuta (chiusura di un file, deallocazione di memoria, invio di un messaggio di uscita, ecc.). Quasi mai è un fattore limitante. Tuttavia, è bene considerare che se un’applicazione crea e distrugge una miriade di piccoli oggetti in rapida successione, potrebbe generarsi un overhead degno di attenzione. Di solito, però, il design globale del software evita di creare e distruggere milioni di oggetti costosi in loop continuo.
In situazioni estreme, alcuni sviluppatori scelgono di riciclare le risorse, mantenendo pool di oggetti e riducendo la frequenza di chiamata ai distruttori. Questo non cambia l’utilità o la necessità dei distruttori, bensì li avvolge in strategie di caching e pooling per migliorare la performance. Resta il fatto che, nella maggior parte dei casi, fidarsi del RAII e della distruzione automatica rimane la scelta più robusta e manutenibile.
Un’ultima occhiata alla sintassi e ai possibili errori
Per chi inizia a scrivere classi con distruttori personalizzati, uno degli errori più frequenti è dimenticare di marcare il distruttore come virtual
in una gerarchia di classi polimorfica. Un altro errore, già sottolineato, è quello di lasciar uscire un’eccezione dal distruttore, creando situazioni poco gestibili. Bisogna poi stare attenti a non scrivere distruttori che fanno operazioni invalidanti, come richiamare delete
su un puntatore già eliminato altrove, o come invocare funzioni dipendenti da oggetti già parzialmente distrutti.
A livello stilistico, qualcuno preferisce definire i distruttori nel file header se il loro corpo è minuscolo, ma in progetti di grandi dimensioni si tende a metterli in un file .cpp
dedicato, lasciando l’header più pulito e permettendo ricompilazioni più veloci nel caso di modifiche. Sulla scelta influisce, inoltre, la presenza di costrutti inline.
La dimensione del distruttore, comunque, non dovrebbe mai essere eccessiva. Se si complica al punto da diventare un elenco di operazioni disparate, forse è il caso di domandarsi se la classe stia assorbendo troppe responsabilità. Spesso, un buon design orientato agli oggetti distribuisce la gestione delle risorse in più classi specializzate, ognuna con il suo distruttore snello e lineare.
Il distruttore in C++ può sembrare un argomento banale, ma racchiude in realtà la chiave di tutto l’approccio del linguaggio alla gestione automatica delle risorse. Senza di esso, l’idea di RAII perderebbe senso. Senza di esso, non si potrebbe facilmente garantire la chiusura di file, il rilascio di memoria, la conclusione di sessioni di rete in modo automatico e sicuro. È la funzione a cui spetta l’atto finale nella vita di ogni oggetto, dal più semplice al più elaborato.
Apprendere come e quando definirlo, come gestire la virtualità in gerarchie di classi, come relazionarlo ai costruttori e alle risorse incapsulate, significa compiere un balzo di qualità nella scrittura di codice robusto e manutenibile. È grazie al distruttore che possiamo dire: “Non devo preoccuparmi di ricordarmi di questa o quella pulizia, è la classe stessa a farsene carico”. Nel quotidiano, questa filosofia semplifica il lavoro di chi sviluppa, minimizzando la presenza di bug e semplificando il debugging quando qualcosa non va.
In definitiva, il distruttore costituisce il tassello conclusivo di un’esistenza ben progettata per l’oggetto. Non appare mai come una chiamata esplicita (a meno di contesti molto particolari), ma lavora dietro le quinte in modo deciso, assicurando che ogni risorsa venga restituita al sistema. Se nel costruttore si ha la “nascita”, nel distruttore si ha una “dipartita ordinata”, con tutto ciò che la metafora comporta. In un linguaggio votato alle alte prestazioni e al controllo fine come il C++, non si sarebbe potuto pensare a un meccanismo più naturale e coerente: appena l’oggetto cessa di essere, si saluta e libera tutto, lasciando il campo pulito, pronto per nuove istanze che ne prenderanno il posto. E in un mondo software dove l’ordine è prezioso, la semplicità di tale azione risulta persino rassicurante.