1. Introduzione
Quando un programmatore vede per la prima volta new e delete, nella sua testa nasce un pensiero pericoloso: «Oh, ora posso creare oggetti quando e dove voglio! Io sono il padrone della memoria!». Questa sensazione passa in fretta quando il programma inizia a crollare non dove c'è l'errore, ma "da qualche parte vicino a std::cout" (classico). La ragione è semplice: la gestione manuale della memoria è un contratto che è molto facile violare. Oggi esamineremo i tre modi più comuni per violare quel contratto.
Per avere un riferimento, fissiamo il "golden invariant" della memoria manuale (ciò che deve essere vero sempre):
Ogni risorsa ottenuta tramite `new` / `new[]` deve essere liberata esattamente una volta
con il corrispondente `delete` / `delete[]` e solo dopo che non viene più usata.
Se l'invariante viene violato, otteniamo uno dei tre protagonisti di questa lezione.
Mappa del territorio: tre errori e come si differenziano
Prima di tuffarci negli esempi, è utile fare per un minuto una "tabella del buon senso" per non confondere i concetti. Questi bug vengono spesso confusi perché l'esito è simile: "tutto si è rotto". Ma si rompono in modi diversi.
| Bug | Cosa è stato violato | Sintomo tipico | Perché è insidioso |
|---|---|---|---|
|
risorsa non liberata | il programma "aumenta" l'uso di memoria, a volte crolla dopo molto tempo | può non manifestarsi nei test piccoli |
|
risorsa liberata due volte | terminazione anomala, crash dell'allocator, "heap corruption" | l'errore spesso è lontano dal punto del crash |
|
risorsa utilizzata dopo la sua liberazione | "dati spazzatura", crash rari, valori strani | a volte "funziona", finché la memoria non viene riutilizzata |
Andremo in quest'ordine: prima leak (i più "silenziosi"), poi double-free (i più rumorosi), poi use-after-free (i più misteriosi).
2. Leak: la memoria è "persa" perché l'avete "smarrita"
Una perdita di memoria è la situazione in cui avete allocato memoria ma non l'avete liberata. Importante: la memoria non deve necessariamente "perdersi per sempre" dal punto di vista del sistema operativo — quando il processo termina, il SO riprende tutto. Ma finché il programma è in esecuzione, una leak significa aumento del consumo di memoria e, talvolta, degrado delle prestazioni a causa delle allocazioni e della frammentazione.
Il punto più fastidioso è che una leak spesso appare "innocua": il programma stampa i numeri corretti, i test passano, e il problema si manifesta solo in esecuzioni lunghe o sotto carico. Proprio dove meno vi aspettate sorprese.
Il leak più semplice: dimenticare il delete
Iniziamo con un esempio "didattico". È banale, ma è proprio così che la mente del principiante si abitua: alloco — dimentico.
#include <iostream>
int main() {
int* p = new int{10};
std::cout << *p << '\n'; // 10
// delete p; // dimenticato => leak
}
Il problema qui non è che il programma necessariamente crashi. Il problema è che avete violato il contratto: la risorsa vive più a lungo del necessario e non la controllate più.
Leak tramite return anticipato: "sei uscito dalla funzione e hai lasciato la porta aperta"
Questo è più vicino alla vita reale: la logica si dirama e in una delle branch uscite dalla funzione senza liberare la risorsa.
#include <string>
int* parse_positive_or_null(const std::string& s) {
int* p = new int{0};
if (s.empty()) {
return nullptr; // leak: p non è stato liberato
}
*p = 42; // supponiamo di aver parsato
return p;
}
È importante sentire: il problema non è il return in sé. Il problema è che il delete è "sparso" sulle varie branch (e in una di esse manca del tutto). Più branch ci sono, maggiore la probabilità di sbagliare. Ed è una delle ragioni per cui la memoria manuale scala male con la complessità del codice.
Leak per sovrascrittura del puntatore: "hai perso l'ultimo indirizzo"
Errore molto comune: avete allocato memoria, poi avete assegnato al puntatore un nuovo indirizzo — e il primo indirizzo è perso, non c'è più modo di liberarlo.
int main() {
int* p = new int{1};
p = new int{2}; // leak: l'indirizzo di {1} è perso
delete p; // liberiamo solo {2}
p = nullptr;
}
Succede spesso nel codice che "riusa la variabile" pensando che sia più efficiente. Si risparmiano solo righe di codice, non memoria né nervi.
Leak in un ciclo: "un po' alla volta, ma ogni volta"
Le leak diventano veramente pericolose quando entrano in sezioni ripetute del codice. Allora si ottiene l'effetto "goccia a goccia — e allagamento".
#include <iostream>
int main() {
for (int i = 0; i < 3; ++i) {
int* p = new int{i};
std::cout << *p << ' '; // 0 1 2
// delete p; // dimenticato => leak ad ogni iterazione
}
std::cout << '\n';
}
A tre iterazioni nessuno se ne accorge. A tre milioni se ne accorge anche il vostro portatile, che comincerà a "rumoreggiare" con le ventole e a fingere il decollo.
4. Double-free: risorsa liberata due volte (e questo è già UB)
double-free suona come "ho pulito la stanza due volte, che c'è di male?". Sulla memoria funziona al contrario: il secondo delete prova a liberare una risorsa già liberata. Da lì in poi inizia Undefined Behavior: il programma può crashare immediatamente, oppure più tardi, oppure "solo" corrompere le strutture interne dell'allocator e causare un crash altrove.
La causa più comune di double-free nei principianti è la mancata comprensione della differenza tra "copiare il puntatore" e "copiare l'oggetto". Un puntatore è solo un indirizzo. Se copiate l'indirizzo avete due nomi per la stessa risorsa. Se entrambi i nomi si considerano proprietari, entrambi chiameranno delete.
Due puntatori allo stesso new: "due proprietari di un frigorifero"
int main() {
int* p = new int{5};
int* q = p; // q e p puntano allo stesso oggetto
delete p;
// delete q; // UB: double-free (non eseguito)
q = nullptr;
}
Idea chiave: copiare T* copia l'indirizzo, non crea un secondo oggetto.
Double-free dovuto a una funzione "utile" che decide di cancellare
A volte il codice si rompe a causa di un contratto di funzione poco chiaro. Supponiamo che passiate un puntatore a una funzione "fai qualcosa" e questa decida che, dato che ha ricevuto il puntatore, sia sua responsabilità cancellarlo. Voi invece pensavate di cancellarlo voi. Risultato: due delete.
#include <iostream>
void print_and_delete(int* p) {
std::cout << *p << '\n'; // supponiamo di stampare
delete p; // e per caso cancelliamo
}
int main() {
int* p = new int{7};
print_and_delete(p);
// delete p; // UB: double-free (non eseguito)
p = nullptr;
}
Questo esempio è importante non perché "non si deve fare" (anche se non si deve), ma perché mostra che la proprietà deve essere univoca. Dal tipo int* non è chiaro chi sia il proprietario. Questa è una delle questioni fondamentali delle API "raw".
Double-free per "liberato e non azzerato": secondo delete su un vecchio indirizzo
A volte il secondo delete non avviene perché qualcuno ha copiato il puntatore, ma perché il codice ha "riutilizzato la variabile" e ha dimenticato che lì era già stato fatto il delete.
int main() {
int* p = new int{1};
delete p;
// ... molto codice ...
// delete p; // UB: secondo delete dello stesso indirizzo (non eseguito)
p = nullptr;
}
Sì, azzerare con p = nullptr; non risolve l'architettura di proprietà. Ma spesso aiuta a trasformare un "UB silenzioso" in un errore più evidente: se qualcuno poi fa delete su p, allora delete nullptr è sicuro e non rompe nulla.
5. Use-after-free: l'oggetto è già morto, ma voi continuate a parlargli
Use-after-free è quando liberate memoria ma continuate a usare il puntatore che ci punta. Tecnicamente dopo delete il puntatore diventa dangling (penzolante): conserva ancora l'indirizzo, ma a quell'indirizzo non c'è più un oggetto "vivo" con un tempo di vita valido.
L'insidia è che spesso si pensa: "l'indirizzo è lo stesso. Quindi i dati sono lì". Ma il linguaggio C++ non garantisce nulla: dopo delete quel blocco di memoria può essere immediatamente riutilizzato per altri scopi, oppure può rimanere "comunque là" per un po'. Perciò use-after-free talvolta "funziona" (peggio che crashare subito).
Use-after-free diretto: dereferenziazione dopo delete
#include <iostream>
int main() {
int* p = new int{99};
delete p;
// std::cout << *p << '\n'; // UB: use-after-free (non eseguito)
p = nullptr;
}
Se siete fortunati il programma crasha. Se non lo siete, ottenete un numero qualsiasi e pensate che sia tutto ok. Poi quel "funziona" diventerà un bug in produzione.
Use-after-free tramite "indirizzo salvato": il puntatore è andato a spasso
Versione più realistica: avete salvato l'indirizzo da qualche parte, poi avete liberato e poi usate la "copia salvata".
#include <iostream>
int main() {
int* p = new int{10};
int* alias = p; // seconda "etichetta" per lo stesso indirizzo
delete p;
// std::cout << *alias << '\n'; // UB: use-after-free (non eseguito)
alias = nullptr;
}
Nota psicologica: potete aver azzerato onestamente p = nullptr;, ma alias resterà dangling. Perciò "azzerare il puntatore" non è una difesa universale se l'indirizzo è stato copiato.
Use-after-free che sembra "random": la memoria è stata riutilizzata da un nuovo new
Effetto istruttivo: avete cancellato l'oggetto e poi da qualche parte fate un altro new. L'allocator può restituire lo stesso indirizzo, e allora il vostro vecchio dangling-pointer improvvisamente "punta" a un nuovo oggetto. Leggete attraverso di esso e ottenete i dati di un altro oggetto. È praticamente un caso da romanzo giallo.
#include <iostream>
int main() {
int* p = new int{1};
delete p;
int* q = new int{777}; // a volte può ricevere lo stesso indirizzo di p
// std::cout << *p << '\n'; // UB: use-after-free (non eseguito)
delete q;
}
Perché è spaventoso: se il programma crashasse sarebbe facile trovare l'errore. Ma quando "funziona, ma sbagliato", catturate bug fantasma.
Perché questi bug si manifestano in modo strano: modello semplificato dell'allocator
Vorremmo credere che un errore rompa il programma subito e in modo chiaro. Ma la realtà è che il memory manager (allocator) è un sistema complesso che riutilizza blocchi liberati, mantiene liste di zone libere, unisce e divide blocchi. Quindi delete non significa "azzerare la memoria", ma "restituire il blocco alla gestione".
È utile avere in testa uno schema semplice del ciclo di vita:
flowchart LR
A[new allocato un blocco] --> B[l'oggetto vive]
B --> C[delete ha terminato il lifetime]
C --> D[il blocco può essere riutilizzato]
D --> E[un nuovo new può restituire lo stesso indirizzo]
Da questo schema derivano due effetti sgradevoli. Primo, use-after-free a volte produce dati "plausibili" perché il blocco non è ancora stato riutilizzato. Secondo, double-free può non crashare al secondo delete ma "corrompere l'interno" e far crashare più tardi su un diverso new o delete.
6. Mini-case: TaskBoard e note legacy su char*
Per non studiare gli errori nel vuoto, immaginiamo di avere una semplice applicazione console TaskBoard (nome convenzionale), dove i task vivono in std::vector e i titoli in std::string. Tutto bene... finché qualcuno non decide di aggiungere alla task una "nota in stile C", perché "è più veloce" o "voglio esercitarmi con new[]".
Creiamo una piccola funzione che alloca una C-string per la nota e ritorna char*. Non è una raccomandazione, è un esempio didattico per mostrare i rischi.
Allochiamo memoria per una C-string
#include <cstring>
#include <string>
char* make_note_cstr(const std::string& note) {
char* p = new char[note.size() + 1];
std::memcpy(p, note.c_str(), note.size() + 1); // includendo '\0'
return p; // ATTENZIONE: chi farà delete[]?
}
Ed è qui che inizia il classico problema di design: dal tipo di ritorno char* non si capisce chi è il proprietario e quando liberare. Di conseguenza, tutti e tre i nostri bug diventano scenari "naturali".
Leak: si ottiene la nota e ci si dimentica di liberarla
#include <iostream>
#include <string>
int main() {
char* note = make_note_cstr("Call mom");
std::cout << note << '\n'; // Call mom
// delete[] note; // dimenticato => leak
}
Un delete[] dimenticato in un punto può sembrare piccolo. Ma se le note vengono create frequentemente (per esempio, ad ogni aggiornamento del task), la leak diventa sistemica.
Double-free: due moduli pensano di essere "proprietari della nota"
#include <string>
void store_note_somewhere(char* note) {
// ... abbiamo salvato ...
delete[] note; // "pensiamo che sia nostra responsabilità"
}
int main() {
char* note = make_note_cstr("Buy milk");
store_note_somewhere(note);
// delete[] note; // UB: double-free (non eseguito)
}
Questa situazione emerge spesso nello sviluppo di team, quando una persona scrive la funzione, un'altra la chiama, e il contratto di ownership non è documentato (o è documentato in un commento che nessuno legge — realistico).
Use-after-free: la nota è stata cancellata ma il puntatore è ancora nel task
Immaginate che in un task sia memorizzato char* note;, e durante l'aggiornamento della nota fate delete[] note, ma dimenticate di aggiornare il campo o dimenticate che qualcun altro ci fa riferimento.
#include <iostream>
int main() {
char* note = make_note_cstr("Old note");
delete[] note;
// std::cout << note << '\n'; // UB: use-after-free (non eseguito)
note = nullptr;
}
Qui la morale è la stessa: se gestite la memoria manualmente, dovete assicurarvi manualmente la correttezza dei tempi di vita in tutti i punti in cui l'indirizzo è "uscito".
7. Errori tipici
Errore n.1: "Restituiamo semplicemente un T* dalla funzione, poi si vedrà".
Restituire un puntatore raw a un oggetto dinamico quasi sempre crea una falla nel contratto di ownership. Il codice compila, funziona sui casi semplici, e poi compare improvvisamente una leak (nessuno ha cancellato) o un double-free (hanno cancellato due volte), perché la responsabilità non è assegnata in modo univoco.
Errore n.2: la copia del puntatore è scambiata per copia dell'oggetto.
Questa è forse la causa più frequente di double-free e use-after-free nei principianti. int* q = p; non crea un secondo int, crea solo una seconda etichetta per lo stesso indirizzo. Se non vi preoccupate esplicitamente "ora abbiamo due nomi per la stessa memoria", la mente tende a immaginare una copia dell'oggetto che non esiste.
Errore n.3: il delete è "sparso" tra branch e return anticipati.
Quando in una funzione compaiono diversi return e più branch if, e il delete è "da qualche parte", la probabilità di una leak diventa quasi statistica. Di solito si nota scorrendo la funzione: vi chiedete "qui abbiamo veramente liberato?". Se dovete pensarci — è già pericoloso.
Errore n.4: dopo delete si continua ad accedere al puntatore o lo si passa oltre.
Use-after-free nasce spesso non per malafede, ma per dimenticanza: si è cancellato l'oggetto ma l'indirizzo è rimasto nella variabile, nel campo della struttura non è stato aggiornato, o un "alias temporaneo" è stato salvato altrove. Dopo delete il puntatore va considerato invalido. Azzerare con p = nullptr; aiuta, ma non risolve il problema delle copie dell'indirizzo.
Errore n.5: confusione tra new[] e delete (o viceversa), considerandoli "quasi la stessa cosa".
Anche se l'argomento è già noto, è direttamente collegato ai crash di oggi: la forma sbagliata di deallocazione è anche essa UB e spesso si manifesta come "heap corrotto", quindi sintomi simili a double-free o use-after-free. Se avete allocato un array, liberatelo con delete[], senza compromessi.
GO TO FULL VERSION