1. Idea principale: invarianti e crash anticipato
Se si guarda assert dal punto di vista di un principiante, sembra strano: «perché dovrei usare un comando che termina intenzionalmente il programma? Sto cercando di farlo funzionare». Ed è una buona intuizione — nello scenario utente cerchiamo davvero di comportarci con attenzione. Ma il programmatore ha un altro scenario: catturare i propri errori di sviluppo il prima possibile e il più vicino possibile al punto in cui abbiamo violato le nostre regole.
I sanitizer catturano molto bene classi di problemi, ma non possono leggere la vostra mente. Per esempio, un sanitizer può dire «out of bounds», ma non dirà «avevi promesso che l'id del task sarebbe sempre positivo, e invece è diventato -17». Qui assert funziona come un piccolo «sensore del buon senso»: nel codice fissate un'aspettativa che dovrebbe essere vera se la logica del programma è corretta.
Una metafora utile: il sanitizer è il metal detector all'ingresso dell'aeroporto, mentre assert è il check‑list del pilota prima del decollo. Il metal detector cattura molte cose pericolose, ma la check‑list verifica ciò che è importante proprio per quell'aereo in quel momento.
Invariante e errore dell'utente
La parola «invariante» sembra inventata per far disperare gli studenti. In realtà è un'idea molto semplice: un invariante è una condizione che “in condizioni normali” è sempre vera in un punto definito del programma. Per esempio: «l'indice è sempre dentro i limiti del vettore», «il divisore non è zero», «la lunghezza della stringa non è negativa» (sì, sembra buffo, ma queste cose a volte le dimentichiamo).
Punto chiave: l'invariante è di solito il nostro impegno interno, non un contratto con l'utente. L'utente può inserire cose sbagliate: stringhe vuote, lettere al posto di un numero, id negativi — e questo non è un “bug del programma”, è realtà. Queste situazioni le gestiamo con if, messaggi di errore e ritorni “non riuscito”.
Se invece noi all'interno del programma calcoliamo un indice e otteniamo inaspettatamente i == v.size(), oppure generiamo un id e diventa 0, — questo non è più un «errore dell'utente». Significa che la nostra logica ha fallito, e proseguire spesso è pericoloso (si può finire nel UB). Sono proprio questi i casi che copre assert.
2. Come funziona assert in C++
Sintassi di base: come attivarlo e com'è il crash
assert in C++ è un macro, incluso tramite l'header <cassert>. Macro è una parola importante: non è una funzione, ma una costruzione espansa dal preprocessore. Per questo assert ha una caratteristica particolare: con certe impostazioni di build può trasformarsi in «nulla», cioè la condizione potrebbe neppure essere valutata (ne parleremo più avanti).
Esempio minimo:
#include <cassert>
int main() {
int x = 10;
assert(x > 0); // ok, la condizione è vera
}
Se la condizione è falsa, il programma terminerà in modo anomalo. Di solito vedrete un messaggio tipo “assertion failed” e le coordinate (dipende dalla piattaforma e dalla build). Importante: assert non è pensato per una «bella UX», ma per lo sviluppatore che vuole capire subito: “siamo arrivati in uno stato impossibile”.
Un fatto storico negli standard: anche intorno a assert ci sono miglioramenti e discussioni su come rendere la diagnostica più amichevole. Per esempio, nei documenti del WG21 ci sono punti dedicati a modifiche per rendere assert() più “user friendly” in C e C++.
Dove mettere assert
Quasi tutti gli assert utili nel codice reale stanno in due posti:
- prima di un'operazione potenzialmente pericolosa, che con dati sbagliati può portare a UB o a risultati insensati;
- dopo una modifica di stato, quando vogliamo assicurarci che l'oggetto/contenitore sia rimasto in uno stato coerente.
Perché è importante? Perché assert dovrebbe catturare il problema il più vicino possibile alla causa. Se lo si mette troppo tardi, il crash avverrà in un punto inatteso, mentre l'errore reale può essere molte linee prima. Se lo si mette troppo presto e troppo in generale, si attiverà «a volte per buone ragioni, a volte per coincidenza».
Esempio “prima della riga pericolosa” — divisione:
#include <cassert>
int Divide(int a, int b) {
assert(b != 0);
return a / b;
}
Esempio “prima della riga pericolosa” — indicizzazione:
#include <cassert>
#include <vector>
int GetAt(const std::vector<int>& v, std::size_t i) {
assert(i < v.size());
return v[i]; // se la condizione è violata, è meglio crashare PRIMA di questa riga
}
Esempio “dopo una modifica di stato” — abbiamo aggiunto un elemento e vogliamo essere sicuri che il contenitore non sia diventato vuoto:
#include <cassert>
#include <vector>
void AddOne(std::vector<int>& v, int x) {
v.push_back(x);
assert(!v.empty());
}
NDEBUG: perché assert può «scomparire»
La più importante insidia è questa: assert spesso funziona in modo che in Debug è attivo, mentre in Release può essere disabilitato. La disabilitazione è solitamente collegata al macro NDEBUG: se è definito, assert(...) diventa una costruzione vuota.
Conclusione pratica: non si deve basare la logica del programma su assert. assert non è “se succede, esegui un'azione importante”, ma «se succede, verifica la condizione». E basta.
Esempio di codice cattivo — assert con effetto collaterale:
#include <cassert>
int main() {
int x = 0;
assert(++x == 1); // male: modifichiamo x dentro assert
// in Release questo potrebbe diventare “nulla”,
// e allora x rimarrebbe 0
}
Versione buona: prima eseguire l'azione, poi controllare:
#include <cassert>
int main() {
int x = 0;
++x;
assert(x == 1); // bene: la verifica non modifica lo stato
}
Ricordate una regola semplice: l'espressione dentro assert(...) deve essere il più possibile “pulita”, senza modificare variabili e senza chiamate a funzioni che mutano qualcosa.
Come rendere il crash più comprensibile: messaggio in assert
assert ha una proprietà comoda: si può rendere l'espressione un po' più «esplicativa», così che al crash sia chiaro quale contratto è stato violato. Idealmente chi vede l'assert fallito dovrebbe capire in 10 secondi cosa è andato storto, senza dover aprire l'intero progetto.
Il trucco classico (senza effetti collaterali) è aggiungere una stringa letterale usando &&. Se la condizione è falsa, il risultato è comunque falso, e l'assert scatterà:
#include <cassert>
#include <vector>
int GetAt(const std::vector<int>& v, std::size_t i) {
assert(i < v.size() && "index must be within vector bounds");
return v[i];
}
Perché funziona? Perché "text" è un puntatore alla stringa letterale, che è “truthy” in senso logico, e normalmente l'espressione si riduce al controllo i < v.size(). Il testo aiuta voi (o il vostro team lead alle 3 di notte) a capire rapidamente l'invariante violato.
Importante: è un suggerimento per lo sviluppatore. Per l'utente non mostriamo questi messaggi (e in generale assert non è per l'utente).
3. Esempio: aggiungiamo assert in TaskKeeper
Per evitare che assert resti una «bestia accademica», integriamolo in un'applicazione console didattica. Supponiamo di avere un semplice task manager: conserviamo una lista di task (std::vector), ogni task ha id, title e un flag done. L'app sa aggiungere un task, marcarlo come completato e stampare la lista.
Modello
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Generazione id
Qui l'invariante è semplice: id è sempre positivo, e nextId è sempre maggiore di zero.
#include <cassert>
int GenerateId(int& nextId) {
assert(nextId > 0 && "nextId must stay positive");
int id = nextId;
++nextId;
assert(id > 0);
return id;
}
Notate lo stile: non «risolviamo» il caso in cui nextId <= 0. Lo consideriamo logicamente impossibile con codice corretto. Quindi, se succede, è meglio crashare e indagare.
Aggiungere un task
Qui ci sono due categorie di controlli:
- “di utente”: il titolo può essere vuoto, ed è una situazione normale — semplicemente non aggiungiamo il task;
- “interno”: se abbiamo aggiunto il task, il suo id deve essere valido, e il vettore dopo l'aggiunta non deve improvvisamente diminuire.
#include <cassert>
#include <string>
#include <vector>
bool AddTask(std::vector<Task>& tasks, int& nextId, const std::string& title) {
if (title.empty()) return false; // errore di input/utente
const std::size_t oldSize = tasks.size();
Task t;
t.id = GenerateId(nextId);
t.title = title;
tasks.push_back(t);
assert(tasks.size() == oldSize + 1);
assert(tasks.back().id > 0);
return true;
}
Ricerca per id
Qui è comodo restituire l'indice. Conosciamo già std::optional da temi precedenti, ed è un buon caso: “trovato/non trovato” è un esito normale, non un crash.
Anche qui si può mettere un assert per il contratto interno: se restituiamo un indice, questo è sicuramente entro i limiti di tasks.size().
#include <cassert>
#include <optional>
#include <vector>
std::optional<std::size_t> FindTaskIndexById(const std::vector<Task>& tasks, int id) {
if (id <= 0) return std::nullopt; // l'utente potrebbe aver inserito dati sbagliati
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) {
assert(i < tasks.size());
return i;
}
}
return std::nullopt;
}
Marcare come completato
Trappola tipica: troviamo l'indice e poi accediamo a tasks[index]. Se l'indice è sbagliato — candidate per UB. Mettiamo un assert direttamente prima dell'accesso, anche se “logicamente l'indice era già verificato”. È come una seconda cintura di sicurezza: quasi mai scatta, ma quando scatta siete contenti che ci sia.
#include <cassert>
#include <optional>
#include <vector>
bool MarkDone(std::vector<Task>& tasks, int id) {
const auto idx = FindTaskIndexById(tasks, id);
if (!idx) return false;
assert(*idx < tasks.size());
tasks[*idx].done = true;
return true;
}
Main minimale
Senza complicare l'input — qui ci alleniamo specificamente con assert.
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks;
int nextId = 1;
AddTask(tasks, nextId, "Read about assert");
AddTask(tasks, nextId, "Fix UB before it fixes you");
MarkDone(tasks, 1);
for (const auto& t : tasks) {
std::cout << t.id << ". " << t.title
<< (t.done ? " [done]" : " [todo]") << '\n';
// 1. Read about assert [done]
// 2. Fix UB before it fixes you [todo]
}
}
Lo scopo di questo frammento non è creare un task manager perfetto, ma mostrare come assert fissi i nostri impegni interni: “id positivo”, “indice nei limiti”, “dopo push_back la dimensione è aumentata di 1”.
4. assert nel debug e integrazione con i sanitizer
Quando assert scatta, spesso sembra che «il programma sia semplicemente morto». In realtà è una morte molto informativa (a differenza dell'UB, che può uccidere silenziosamente e in punti casuali). Se avete attivato la diagnostica (Debug e/o sanitizer), di solito avrete coordinate e la possibilità di vedere lo stack trace.
È utile tenere presente uno schema semplice di come «copriamo» gli errori a strati:
flowchart TD
A[Errore di logica: invariante violato] --> B[assert scatta subito]
A --> C[se non c'è assert: si prosegue]
C --> D[può succedere UB]
D --> E[sanitizer cattura parte dell'UB]
D --> F[senza sanitizer: sintomi strani]
Regola pratica: se avete un punto dove può succedere UB (indicizzazione, divisione, shift), mettere assert prima dell'operazione spesso trasforma un «problema strano tra 5 minuti» in un «crash comprensibile qui».
5. Errori tipici nell'uso di assert
Errore n.1: usare assert per validare l'input utente.
Quando l'utente fornisce un id errato o una stringa vuota, non è uno “stato impossibile”, è un ramo normale del programma. Se mettete assert(id > 0) all'ingresso di un comando, il programma crollerà per qualsiasi input non valido. Non è protezione, è modo per litigare con l'utente. In questi casi servono normali if e messaggi/ritorni appropriati.
Errore n.2: fare affidamento su assert come parte essenziale della logica.
Poiché assert può essere disabilitato tramite NDEBUG, non bisogna mettere azioni importanti dentro l'assert. Qualsiasi effetto collaterale nella condizione (++x, scrittura in un contenitore, cambio di flag) trasforma la build Release in una versione con logica differente. Poi sarà difficile spiegare perché “in debug funziona”.
Errore n.3: mettere assert dopo l'operazione pericolosa.
A volte si vede il pattern “prima leggiamo v[i], poi verifichiamo i < v.size()”. È troppo tardi: se l'indice è sbagliato, l'UB può essere avvenuto alla riga di lettura e la verifica sotto non salva. assert deve stare prima dell'operazione potenzialmente pericolosa, altrimenti è solo una targhetta decorativa “attento, qui c'è stato un problema”.
Errore n.4: scrivere assert troppo generici e senza senso.
assert(true) o assert(x == x) non esprimono nessuna idea reale. Un buon assert deve esprimere un contratto concreto: “indice nei limiti”, “dimensione aumentata”, “id positivo”, “puntatore non null”. Se non sapete spiegare a parole perché quell'assert è lì, probabilmente non vi sarà d'aiuto.
Errore n.5: trasformare il codice in un “campo minato di assert”.
assert è potente, ma se lo inserite ogni due righe “per sicurezza” il codice diventa illeggibile e nervoso. Meglio porli dove c'è un contratto chiaro: ai confini delle funzioni, prima delle operazioni pericolose, dopo cambi di stato importanti. Così ogni assert sarà come una buona segnaletica stradale, non come graffiti su ogni muro.
GO TO FULL VERSION