Perché chiedere memoria a runtime
Finora ogni variabile che hai creato viveva sullo stack: la sua dimensione è nota in fase di compilazione e viene distrutta automaticamente quando il suo scope termina. È veloce e sicuro, ma non gestisce il caso in cui non sai quanta memoria ti serve finché il programma non è in esecuzione: un buffer dimensionato in base all'input dell'utente, una struttura che deve sopravvivere alla funzione che l'ha creata o un grafo la cui forma non è nota in anticipo.
Per questi casi il C++ ti permette di allocare dall'heap (chiamato anche free store) con new, e di restituire la memoria con delete. L'espressione new riserva un blocco, esegue il costruttore e restituisce un puntatore a quel blocco, basandosi direttamente sui puntatori che hai visto nella pagina precedente.
La variabile p in sé vive sullo stack: è solo un puntatore. L'int a cui punta vive sull'heap e resta vivo finché non fai delete, indipendentemente da quanti scope si aprono e si chiudono.
Stack e heap
Questa distinzione è l'intero motivo per cui esiste new, quindi vale la pena renderla concreta.
void demo() {
int a = 10; // sullo stack, sparisce quando demo() termina
int* b = new int(10); // 'b' sullo stack, l'int a cui punta sull'heap
} // 'a' distrutto; l'int sull'heap è un LEAK, non viene mai liberato
Le differenze principali:
- Stack: durata automatica, molto veloce, dimensione limitata (di solito pochi MB), liberato per te quando lo scope termina.
- Heap: durata manuale, leggermente più lento, grande, e liberato solo quando chiami
delete.
Lo scambio è flessibilità contro responsabilità: la memoria sull'heap vive esattamente quanto vuoi tu, ma diventi tu quello che deve ricordarsi di liberarla.
Allocare array con new[]
Quando ti serve un blocco la cui lunghezza viene decisa a runtime, usa la forma per array new T[n]. Restituisce un puntatore al primo elemento, e lo liberi con il corrispondente delete[].
La regola è rigida e facile da sbagliare: la memoria ottenuta con new si libera con delete, e quella ottenuta con new[] si libera con delete[]. Mescolarli, cioè fare delete arr su qualcosa allocato con new[], è comportamento indefinito, anche se sul tuo computer sembra funzionare.
I tre bug classici
La gestione manuale della memoria ha un piccolo insieme di errori che causano la maggior parte dei bug sull'heap. Impara a riconoscerli tutti e tre.
1. Memory leak: non chiami mai delete. Il blocco resta riservato per sempre. Innocuo una volta, fatale in un ciclo.
void leaky() {
int* p = new int(5);
// ... nessun delete ...
} // p sparisce; l'int sull'heap ora è irraggiungibile E non liberato
2. Puntatore pendente: usi la memoria dopo averla liberata. Il puntatore contiene ancora il vecchio indirizzo, ma quella memoria non è più tua.
3. Double free: fai delete due volte sullo stesso blocco. Questo corrompe la contabilità interna dell'heap e di solito provoca un crash.
int* p = new int(1);
delete p;
delete p; // double free, comportamento indefinito, spesso un crash
Impostare un puntatore a nullptr dopo averlo eliminato disinnesca sia l'uso di un puntatore pendente sia il double free: dereferenziare nullptr provoca subito un crash (facile da individuare), e delete nullptr è esplicitamente un'operazione sicura che non fa nulla.
Un ciclo realistico alloca, usa, libera
Mettendo tutto insieme, ecco la forma corretta della gestione manuale: alloca, usa, libera esattamente una volta e non toccare più il puntatore dopo.
Nota che per un tipo classe delete u fa due cose: prima esegue il distruttore dell'oggetto, poi rilascia la memoria grezza. Quest'ordine conta quando i tuoi oggetti possiedono a loro volta delle risorse.
Un'insidia sottile: se viene lanciata un'eccezione tra new e delete, il delete non viene mai eseguito e hai un leak. Avvolgere ogni allocazione in un try/catch per gestirlo è noioso e soggetto a errori, ed è esattamente il problema che risolve la prossima pagina.
Prossimo: Smart pointer
Hai visto il costo completo della gestione manuale della memoria: ogni new è una promessa di fare delete più tardi, e una sola liberazione mancata, doppia o anticipata è comportamento indefinito. Il C++ moderno non fa quasi mai questa promessa a mano. La prossima pagina introduce gli smart pointer, std::unique_ptr e std::shared_ptr, oggetti che possiedono un'allocazione sull'heap e chiamano delete per te automaticamente quando escono dallo scope, trasformando tutti e tre i bug classici in cose di cui si occupano il compilatore e RAII al posto tuo.
Domande frequenti
Qual è la differenza tra new e delete in C++?
new alloca memoria sull'heap a runtime e restituisce un puntatore a essa; delete libera la memoria allocata con new. Ogni new deve corrispondere esattamente a un delete, altrimenti hai un memory leak. Per gli array usa new[] con delete[].
Cosa succede se ti dimentichi di chiamare delete in C++?
Ottieni un memory leak: il blocco sull'heap resta riservato per tutta la vita del programma anche se nulla punta più a esso. Un singolo leak di solito è innocuo, ma i leak dentro un ciclo o in un servizio che gira a lungo crescono finché il programma esaurisce la memoria e va in crash.
Nel C++ moderno dovrei usare direttamente new e delete?
Raramente. Preferisci contenitori come std::vector o smart pointer (std::unique_ptr, std::shared_ptr) che liberano la memoria automaticamente. Vale la pena capire new/delete grezzi perché gli smart pointer li incapsulano, ma nel codice di tutti i giorni sono una fonte di memory leak e puntatori pendenti.