Comprendere ed evitare il comportamento indefinito
Scopri cos'è il comportamento indefinito, perché esiste, come i compilatori lo sfruttano per ottimizzare e come rilevarlo con i sanitizer e gli avvisi del compilatore.
Che cos'è il comportamento indefinito e perché esiste?
Il comportamento indefinito (UB) significa che lo standard C++ non pone alcun requisito sul comportamento del programma. Il programma potrebbe andare in crash, produrre risultati errati, sembrare funzionare correttamente o formattare il tuo disco rigido — sono tutti esiti "validi".
L'UB esiste perché abilita ottimizzazioni aggressive. Quando il compilatore sa che certe situazioni "non possono accadere" (perché sarebbero UB), può generare codice più veloce omettendo i controlli per quelle situazioni. Per esempio:
- L'overflow degli interi con segno è UB, il che permette al compilatore di assumere che x + 1 > x sia sempre vero, abilitando ottimizzazioni sui cicli.
- Dereferenziare un puntatore null è UB, il che permette al compilatore di eliminare i controlli di nullità dopo una dereferenziazione.
- Accedere a memoria fuori dai limiti è UB, il che permette al compilatore di omettere i controlli sui limiti, rendendo l'accesso agli array veloce quanto l'accesso diretto alla memoria.
Il patto di fondo: il C++ si fida che il programmatore eviti l'UB in cambio delle massime prestazioni. Quando quella fiducia viene tradita, ogni garanzia decade.
Fonti comuni di comportamento indefinito
Ecco le fonti di UB più frequenti nel codice C++ del mondo reale. Ogni programmatore C++ le incontra.
#include <vector>
#include <iostream>
#include <cstdint>
// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
int x = INT32_MAX;
int y = x + 1; // UB! Signed overflow is undefined
// unsigned overflow is well-defined (wraps around)
unsigned u = UINT32_MAX;
unsigned v = u + 1; // OK: v == 0 (well-defined wrap)
}
// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
int val = *ptr; // UB if ptr is null
if (ptr == nullptr) { // Compiler may remove this check!
// The compiler reasons: "ptr was already dereferenced above,
// which would be UB if null. Since UB cannot happen in a
// correct program, ptr must be non-null. This check is dead code."
std::cout << "null!\n";
}
}
// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
int arr[5] = {1, 2, 3, 4, 5};
int bad = arr[10]; // UB! No bounds checking on raw arrays
// std::vector::operator[] is also UB if out of bounds
// Use .at() for bounds-checked access (throws std::out_of_range)
}
// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
int local = 42;
return local; // UB! Reference to destroyed local
}
// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++; // UB if concurrent with Thread 2
// Thread 2: shared++; // UB! Data race on non-atomic variableIl problema del "viaggio nel tempo"
Uno degli aspetti più controintuitivi dell'UB è che può influenzare anche il codice precedente al punto in cui si verifica. I compilatori ottimizzano assumendo che in un programma corretto l'UB non avvenga mai, e queste ottimizzazioni possono riordinare o eliminare codice che compare prima dell'UB.
Considera:
bool checked = false;
void process(int* p) {
int value = *p; // UB se p è null
checked = true; // Questa riga è PRIMA dell'uso di 'value'
if (p == nullptr) { // Compilatore: "p è stato dereferenziato, quindi non è null"
abort(); // Compilatore: "Questo è codice morto, lo rimuovo"
}
use(value);
}Il compilatore può impostare checked = true prima della dereferenziazione (riordinando per la pipeline) e poi rimuovere del tutto il controllo di nullità. Il risultato: checked viene impostato a true anche quando p è null, e la dereferenziazione del puntatore null avviene senza l'abort. L'UB ha "viaggiato indietro nel tempo" corrompendo il flag checked.
Questo non è un bug del compilatore — è una conseguenza diretta delle libertà di ottimizzazione che l'UB concede.
Rilevare l'UB con i sanitizer
I sanitizer sono strumenti forniti dal compilatore che strumentano il tuo codice per rilevare l'UB a runtime. Aggiungono controlli a runtime che intercettano l'UB con messaggi di errore chiari. Esegui sempre la tua suite di test con i sanitizer abilitati.
# AddressSanitizer (ASan) — detects:
# - Buffer overflow (stack, heap, global)
# - Use-after-free, use-after-return
# - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program
# UndefinedBehaviorSanitizer (UBSan) — detects:
# - Signed integer overflow
# - Null pointer dereference
# - Out-of-bounds array access
# - Misaligned memory access
# - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program
# ThreadSanitizer (TSan) — detects:
# - Data races between threads
# - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program
# MemorySanitizer (MSan) — detects:
# - Reads of uninitialized memory
# (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program
# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
-Wall -Wextra -Werror program.cpp -o program
# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)Gli avvisi del compilatore come rete di sicurezza
I compilatori moderni sanno rilevare a tempo di compilazione molte potenziali situazioni di UB. Tratta gli avvisi come errori (-Werror) nella CI, così nessun avviso viene ignorato.
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
-Wall # Enable most warnings \
-Wextra # Enable additional warnings \
-Wpedantic # Strict ISO C++ compliance \
-Werror # Treat warnings as errors \
-Wconversion # Warn on implicit narrowing conversions \
-Wshadow # Warn when a variable shadows another \
-Wnon-virtual-dtor # Warn on classes with virtual functions but non-virtual dtor \
-Wold-style-cast # Warn on C-style casts \
-Wcast-align # Warn on potentially misaligned casts \
-Wunused # Warn on unused variables/functions \
-Woverloaded-virtual # Warn when a derived class hides a base virtual function \
-Wnull-dereference # Warn on potential null dereferences (GCC) \
-Wdouble-promotion # Warn when float is implicitly promoted to double \
-Wformat=2 # Warn on printf/scanf format string issues \
program.cpp -o program
# For Clang, add:
# -Weverything # Enable ALL warnings (then selectively disable)
# -Wno-c++98-compat # Disable C++98 compatibility warningsSegui queste linee guida per ridurre al minimo l'UB nel tuo codice.
- Abilita tutti gli avvisi e trattali come errori:
-Wall -Wextra -Wpedantic -Werror - Esegui i sanitizer nella CI: combina ASan + UBSan a ogni esecuzione dei test; usa TSan per il codice multithread
- Usa
.at()invece di[]per un accesso ai contenitori con controllo dei limiti durante lo sviluppo - Usa gli smart pointer (
unique_ptr,shared_ptr) invece dinew/deletegrezzi, per eliminare gli use-after-free - Usa
std::atomicper le variabili condivise tra thread — non usare mai tipi semplici per dati condivisi tra thread - Preferisci i tipi senza segno per la manipolazione dei bit, ma i tipi con segno per l'aritmetica (l'aritmetica senza segno va in wrap-around, il che può causare bug sottili nei limiti dei cicli)
L'UB più pericoloso è il codice che sembra funzionare perfettamente nei test ma fallisce in produzione. Ecco perché.
- L'UB può "funzionare" per anni e poi rompersi quando aggiorni il compilatore, cambi livello di ottimizzazione o esegui il codice su una CPU diversa
- Overflow con segno nei cicli:
for (int i = 0; i < n; i++)— senèINT_MAX,i++va in overflow. Il compilatore può assumere che il ciclo termini e ottimizzare di conseguenza - Lettura di variabili non inizializzate: il valore non è "casuale" — il compilatore può eliminare del tutto la variabile o propagare qualunque bit si trovi nel registro
- Violazioni dello strict aliasing: fare il cast di
int*afloat*e dereferenziarlo è UB (tranne che attraversochar*ostd::byte*). Usastd::bit_cast(C++20) per il type punning std::string_viewche sopravvive alla stringa: restituisce una view pendente che sembra funzionare finché la memoria non viene riutilizzata
- Il comportamento indefinito esiste per abilitare ottimizzazioni aggressive del compilatore — il C++ baratta la sicurezza con le prestazioni
- L'UB può "viaggiare nel tempo" — il compilatore può riordinare o eliminare codice che precede il punto dell'UB
- Le fonti di UB più comuni sono l'overflow con segno, la dereferenziazione di puntatori null, gli accessi fuori dai limiti, gli use-after-free e le race condition sui dati
- I sanitizer (ASan, UBSan, TSan, MSan) rilevano l'UB a runtime — eseguili in ogni pipeline di CI
- Gli avvisi del compilatore (
-Wall -Wextra -Werror) intercettano a tempo di compilazione molte potenziali situazioni di UB - L'UB che "funziona" oggi può rompersi silenziosamente con un nuovo compilatore, un diverso livello di ottimizzazione o un'altra piattaforma
Quiz — Metti alla prova le tue conoscenze
(15 XP)1. Perché l'overflow degli interi con segno è comportamento indefinito in C++?
2. Che cosa rileva AddressSanitizer (ASan)?
3. Qual è il problema del 'viaggio nel tempo' legato al comportamento indefinito?