<< Home

Gli assert

Indice

Introduzione

Gli assert, per qualche motivo, sono uno degli oggetti della programmazione che vedo fraintesi di più. Mi sono trovato più volte a spiegare e rispiegare cosa sonom, per questo motivo ho deciso di scrivere qui una spiegazione esaustiva da poter linkare nel futuro!.

Il contesto in cui parlerò degli assert è la programmazione C, ma sono un concetto comune anche ad altri linguaggi.

Cos'è?

L'assert è una macro definita in assert.h che prende come argomento un'espressione. Quando le chiamate ad assert vengono eseguite, se l'espressione risulta essere falsa, allora viene abortita l'esecuzione del programma. Se l'espressione risulta essere vera allora non hanno effetto ed il programma continua la sua esecuzione.

Se però è definito il simbolo NDEBUG quando compiliamo il programma, allora il loro comportamento cambia. In tal caso tutti gli assert vengono disabilitati e non hanno effetto, indipendentemente dal risultato dell'espressione fornitagli.

Potremmo immaginare che assert sarà implementata più o meno così:

1#ifndef NDEBUG
2# define assert(exp) { if(!(exp)) abort(); }
3#else
4# define assert(exp) (exp)
5#endif

A cosa serve?

Assert serve a comunicare ai lettori del codice sorgente che una certa condizione è sempre vera. Per questo motivo, non è da intendersi come codice ma più come un commento, perchè servono a documentare e non ad implementare funzionalità. Ogni assert è un messaggio ai i futuri lettori di una porzione di codice che ricorda come un fatto per necessità del programma sia sempre vero, in modo tale da permettergli di seguire meglio la logica del codice in questione.

La cosa che però rende conveniente un assert rispetto ad un commento ordinario, è che permette di controllare quando il programma rompe le regole che ci siamo imposti di rispettare (anche note come invarianti). Questo dovrebbe accadere solo in fase di sviluppo, quindi solo in tal caso gli assert dovrebbero essere abilitati. In un programma corretto, le espressioni fornite agli assert sono sempre vere.

Quindi, nel codice aggiungiamo gli assert per documentare alcuni fatti della logica del programma e, per assicurarci che in fase di sviluppo queste assunzioni di base sono rispettate, diciamo agli assert di abortire il programma nel caso non lo siano. Quando non siamo più in fase di sviluppo assumiamo che le invarianti imposte dagli assert siano sempre vere, rendendo il controllo degli assert superfluo perchè le loro espressioni saranno sempre verificate. A questo punto li disabilitiamo definendo NDEBUG.

Esempi di uso scorretto

Usare gli assert come to-do

L'uso scorretto più comune che vedo è quello di usare l'assert nei casi in cui nella logica del programma potrebbe avvenire un errore, ma il programmatore non vuole scrivere il codice che lo gestisce. In tal caso il programmatore ignorante mette un assert sulla condizione di errore. Un esempio di questo caso potrebbe essere l'apertura di un file:

1#include <stdio.h>
2
3int main()
4{
5 FILE *fp = fopen("file.txt", "r");
6 assert(fp != NULL);
7
8 // .. fai cose con il file ..
9
10 fclose(fp);
11 return 0;
12}

In questo caso l'uso è scorretto perchè è possibile che il file non esista, anche se il programma è scritto correttamente e senza bug. L'esistenza del file non è qualcosa sul quale il programma ha il controllo, quindi è realistico il caso in cui questo non esista ed fopen ritorni NULL. Quando poi avremo concluso lo sviluppo del nostro programma e faremo una compilazione non di debug definendo NDEBUG, questo controllo diventerà una nop (no operation) ed il nostro programma resterà scoperto a questa condizione di errore.

È da notare che decidere di abortire quando la fopen fallisce è una decisione perfettamente ragionevole, il problema sta nel farlo usando una chiamata ad assert.

È naturale "impigrirsi" e non voler gestire subito una condizione di errore. Del resto il codice di un programma può essere rappresentato come un albero, dove la separazione di rami corrisponde ad un if-else. Gestire tutti gli errori subito corrisponde a percorrere questo albero depth-first, quando magari una persona è più portata ad un approccio breadth-first (cioè trattare prima i casi generali e poi gestire i casi nel dettaglio). Il problema, di nuovo, è l'uso degli assert per fare questa cosa.

Usare gli assert come abort

Il secondo caso di uso scorretto è quello in cui una condizione di errore si verifica, ma il programma non può recuperare da questa crisi. Per questo motivo l'unica opzione è abortire l'esecuzione. Un esempio di questo caso è l'allocazione della memoria. I programmi che scriviamo comunemente tendono a non essere progettati a recuperare dal fallimento di un'allocazione, quindi se, ad esempio, malloc ci restituisce un bel NULL, non possiamo far altro che abortire. In questo caso potrebbe sembrare una buona idea usare assert dato che manda il programma in abort quando si verifica la falsità di un'affermazione:

1#include <stdlib.h>
2
3int main()
4{
5 void *p = malloc(1024);
6 assert(p != NULL);
7
8 // .. //
9
10 free(p);
11 return 0;
12}

ma questo è scorretto, perchè non avere memoria è una condizione perfettamente plausibile anche per un programma corretto, quindi quando andremo a fare una compilazione non di debug definendo NDEBUG, questi controlli svaniranno, lasciano il programma scoperto a queste condizioni di errore.

In questo caso la cosa corretta da fare era semplicemente usare abort, così:

1#include <stdlib.h>
2
3int main()
4{
5 void *p = malloc(1024);
6 if(p == NULL)
7 abort();
8
9 // .. //
10
11 free(p);
12 return 0;
13}