Cosa significa davvero "undefined behavior"
La pagina precedente ha mostrato come try/catch gestisce gli errori che il tuo programma definisce e lancia di proposito. L'undefined behavior è l'opposto: è l'insieme delle operazioni a cui lo standard C++ si rifiuta di dare qualsiasi significato. Non c'è nessuna eccezione da catturare, nessun codice di errore, nessuna garanzia di crash. Il compilatore è libero di presumere che l'UB non accada mai e di fare ciò che vuole quando accade.
È proprio questa libertà a rendere l'UB così pericoloso. La stessa riga con il bug potrebbe stampare la risposta "giusta" sul tuo portatile, restituire spazzatura su un server ed essere eliminata del tutto dall'ottimizzatore con -O2. L'UB non è "un comportamento che non documentiamo": è "un comportamento su cui il linguaggio non promette nulla". Il tuo compito è non scriverlo mai.
int arr[3] = {1, 2, 3};
int x = arr[5]; // undefined behavior: lettura oltre la fine dell'array
Qui non c'è nessun errore di compilazione, e in molte esecuzioni ti restituirà tranquillamente un intero a caso. Quell'apparente successo è la trappola.
Leggere o scrivere fuori dai limiti
La forma più comune di UB è toccare memoria che non ti appartiene. Gli array predefiniti e std::vector::operator[] non fanno alcun controllo sui limiti: un indice oltre la fine (o negativo) è UB immediato, sia in lettura sia in scrittura.
Il bug da tenere d'occhio è <= dove intendevi <: quando i == v.size() accedi a una posizione oltre l'ultimo elemento, ed è UB. Preferisci un ciclo for basato su intervallo (visto in precedenza) quando non ti serve l'indice, perché non può andare oltre la fine. Quando invece usi gli indici a mano e vuoi una rete di sicurezza, v.at(i) lancia std::out_of_range invece di corrompere la memoria in silenzio:
Usa at() mentre dai la caccia a un bug; torna a [] nei cicli critici per le prestazioni quando hai dimostrato che gli indici sono validi.
Puntatori pendenti e use-after-free
Un puntatore o un riferimento che sopravvive all'oggetto a cui punta è pendente (dangling). Usarlo è UB: la memoria potrebbe essere stata riutilizzata, liberata o non essere mai esistita. È la trappola che gli smart pointer (dal capitolo precedente) ti aiutano a evitare, ma i puntatori grezzi ti ci fanno ancora cadere.
La versione più insidiosa è restituire l'indirizzo di una variabile locale. La variabile locale muore quando la funzione ritorna, quindi il chiamante resta con in mano un puntatore al nulla:
int* makeNumber() {
int n = 42;
return &n; // restituisce l'indirizzo di una locale: dopo il return non esiste più
}
// Dereferenziare il risultato è undefined behavior.
Succede lo stesso dopo un delete o quando un vector rialloca e invalida gli iteratori o i puntatori ai suoi elementi:
int* p = new int(5);
delete p;
cout << *p; // use-after-free: undefined behavior
vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4); // può riallocare: ora 'first' è pendente
cout << *first; // undefined behavior
Le difese sono quelle che conosci già: tieni in vita gli oggetti finché un puntatore ne ha bisogno, preferisci riferimenti e smart pointer ai puntatori grezzi proprietari, e recupera di nuovo puntatori e iteratori dopo ogni operazione che può ridimensionare un contenitore.
Variabili non inizializzate e overflow con segno
Leggere una variabile prima di averle dato un valore è UB per i tipi predefiniti: non esiste uno 0 di default. La variabile contiene i bit che si trovavano già in quella memoria, e l'ottimizzatore può presumere che tu non la legga mai senza averla inizializzata.
Se sum fosse stata dichiarata semplicemente come int sum;, ogni sum += i leggerebbe prima un valore indeterminato: UB, e un bug notoriamente difficile perché spesso sembra funzionare. Fai dell'inizializzazione un'abitudine: int x = 0; oppure int x{};.
Un altro colpevole silenzioso è l'overflow degli interi con segno. Spingere un int con segno oltre il suo massimo è UB (i tipi unsigned ricominciano da capo in modo prevedibile, quelli con segno no):
int big = 2147483647; // INT_MAX su un int a 32 bit
int oops = big + 1; // overflow con segno: undefined behavior
Non contare sul fatto che "diventi un numero negativo": il compilatore può presumere che l'overflow non possa accadere e ottimizzare di conseguenza. Se ti serve un ricominciare da capo ben definito, usa un tipo unsigned o controlla i limiti prima di sommare.
Scovare l'UB con sanitizer e avvisi
Non puoi arrivare alla certezza sull'UB a forza di test, perché un'esecuzione riuscita non garantisce nulla. Quello che funziona è rendere l'UB rumoroso a runtime con i sanitizer del compilatore (disponibili in GCC e Clang).
// AddressSanitizer: fuori dai limiti, use-after-free, memory leak
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app
// UndefinedBehaviorSanitizer: overflow con segno, deref di null, cast errati
g++ -fsanitize=undefined -g main.cpp -o app && ./app
Esegui i tuoi test esistenti con questi flag e la lettura fuori dai limiti, lo use-after-free o l'overflow con segno che "funzionavano benissimo" diventano un report preciso con file e riga. Abbinali a -Wall -Wextra così il compilatore segnala anche il codice sospetto (come una probabile lettura non inizializzata) prima ancora di eseguirlo.
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 main.cpp:7 in main
Tratta ogni report di un sanitizer come un bug da correggere per forza, non come un avviso da ignorare: ti sta dicendo che lo standard non promette nulla su quella riga.
Riepilogo
L'undefined behavior è la parte del C++ in cui spariscono i guardrail: accessi fuori dai limiti, puntatori pendenti, use-after-free, letture non inizializzate e overflow con segno producono tutti codice senza un significato definito, e "ha funzionato" non è mai la prova che sia corretto. Per restare al sicuro scrivi in modo difensivo (inizializza ogni variabile, rispetta i limiti dei contenitori, lascia che gli smart pointer possiedano la memoria heap) e poi verifica con -fsanitize=address, -fsanitize=undefined e -Wall -Wextra, così l'UB silenzioso diventa un report rumoroso e correggibile.
Con questo si chiude il capitolo Errori e debugging. Tra eccezioni, try/catch e una sana paura dell'UB, ora hai gli strumenti per scrivere C++ che fallisce in modo rumoroso e voluto, invece che in silenzio e per caso.
Domande frequenti
Cos'è l'undefined behavior in C++?
L'undefined behavior (UB) è qualsiasi operazione a cui lo standard C++ lascia esplicitamente un risultato non definito, ad esempio leggere oltre la fine di un array o dereferenziare un puntatore pendente. Il compilatore può fare qualsiasi cosa: crashare, restituire valori spazzatura, eliminare il codice con l'ottimizzazione, oppure sembrare funzionare oggi e rompersi dopo una nuova compilazione. È un bug nel tuo programma, non una caratteristica del linguaggio.
Perché il mio programma C++ funziona anche se contiene undefined behavior?
"Ha funzionato" non dimostra nulla sull'UB. Lo standard non dà garanzie in nessun senso, quindi un bug di UB può produrre il risultato che ti aspettavi sulla tua macchina con il tuo compilatore oggi, e poi crashare con un altro livello di ottimizzazione, un'altra piattaforma o un'altra versione del compilatore. Non considerare mai un'esecuzione riuscita come prova che l'UB sia innocuo: usa un sanitizer per scovarlo davvero.
Come si scova l'undefined behavior in C++?
Compila con i sanitizer: -fsanitize=address (AddressSanitizer) trova letture e scritture fuori dai limiti e use-after-free, mentre -fsanitize=undefined (UndefinedBehaviorSanitizer) segnala overflow con segno, dereferenziazioni di null e cast errati. Attiva gli avvisi (-Wall -Wextra) ed esegui i test con questi flag: trasformano l'UB silenzioso in un report chiaro a runtime.