Asserzioni Statiche in Linguaggio C
Nella lezione precedente abbiamo studiato il concetto di asserzione, ossia di una condizione che un programma deve sempre rispettare durante la sua esecuzione. Un'asserzione è completamente differente rispetto ad un errore che, invece, rappresenta una condizione anomala che il programma può incontrare durante la sua esecuzione.
Abbiamo visto che il linguaggio C mette a disposizione la macro assert() per definire delle asserzioni che vengono verificate durante l'esecuzione del programma. Tuttavia, tali asserzioni hanno due difetti principali:
- Rallentano l'esecuzione del programma;
- In caso di errore, l'asserzione viene rilevata solo durante l'esecuzione, interrompendo il programma in modo brusco.
A partire dallo standard C11, il linguaggio C mette a disposizione un meccanismo per definire delle asserzioni che vengono verificate durante la fase di compilazione del programma. Queste asserzioni sono chiamate asserzioni statiche. Ovviamente, non tutte le condizioni possono essere verificate in fase di compilazione, ma solo quelle che dipendono da valori noti in tale fase.
Vediamo, in questa lezione, come definire delle asserzioni statiche e come esse funzionano.
- Le asserzioni statiche permettono di verificare delle condizioni durante la fase di compilazione del programma.
- Le asserzioni statiche sono definite utilizzando la macro
static_assert(), definita nell'header<assert.h>. - Le asserzioni statiche non rallentano l'esecuzione del programma e permettono di rilevare errori a tempo di compilazione.
- Le asserzioni statiche possono verificare solo condizioni che dipendono da valori noti a tempo di compilazione.
Definire asserzioni statiche
Un'asserzione statica in linguaggio C viene definita utilizzando la macro static_assert(), definita nell'header <assert.h>.
La sintassi della macro static_assert() è la seguente:
static_assert(condizione, messaggio);
Essa funziona in modo simile alla macro assert(). Infatti, se il valore della condizione, che deve essere scalare, è falsa (cioè 0), allora il compilatore genera un errore di compilazione, mostrando il messaggio fornito come secondo argomento e non genererà il file eseguibile. Se, invece, la condizione è vera (cioè diversa da 0), allora il programma viene compilato normalmente.
Un'asserzione statica può essere adoperata per validare delle assunzioni o ipotesi fatte dal programmatore a tempo di compilazione. Quindi, ipotesi riguardanti non l'aspetto dinamico del programma, ma il suo aspetto statico, come ad esempio la dimensione di un tipo di dato o la presenza di una macro.
Esempi di asserzioni statiche
Partiamo da un primo esempio.
Supponiamo di star realizzando un programma che lavora con numeri interi e il programmatore vuole essere sicuro che il tipo int sia rappresentato con almeno 4 byte. Per fare ciò, può utilizzare la macro static_assert() come segue:
#include <assert.h>
/* ... */
static_assert(sizeof(int) >= 4,
"Il tipo int deve essere di almeno 4 byte");
/* ... */
In questo esempio, l'asserzione statica verifica che la dimensione del tipo int sia almeno 4 byte. Da notare che l'operatore sizeof può essere utilizzato in fase di compilazione, poiché restituisce un valore noto a tale fase.
A differenza di un'asserzione dinamica realizzata con la macro assert(), un'asserzione statica è una dichiarazione a tutti gli effetti, motivo per cui può apparire a livello di file (cioè fuori da qualsiasi funzione) o a livello di blocco (cioè all'interno di una funzione o di un blocco di codice delimitato da parentesi graffe {}).
Tornando all'esempio precedente, ad esempio, avremmo potuto realizzare un file sorgente come il seguente:
#include <assert.h>
static_assert(sizeof(int) >= 4, "Il tipo int deve essere di almeno 4 byte");
int main() {
/* ... */
return 0;
}
La macro static_assert() appare al di fuori della funzione main(), ma ciò è perfettamente valido.
Vediamo un altro esempio di applicazione. Supponiamo di voler realizzare una funzione, chiamata pulisci_stdin(), che svuota il buffer di input standard (stdin). Essa deve leggere tutti i caratteri presenti nel buffer, usando la funzione getchar(), fino a quando non incontra un carattere di nuova linea ('\n') o la fine del file (EOF).
Il problema è che la funzione getchar() restituisce un valore di tipo int, questo perché deve essere in grado di rappresentare tutti i caratteri possibili, più il valore speciale EOF. Tuttavia, lo standard del linguaggio C permette al tipo int di avere lo stesso intervallo di valori del tipo char, il che potrebbe causare problemi nell'interpretazione del valore EOF. Quindi, in alcune implementazioni del linguaggio C, il tipo int potrebbe non essere in grado di rappresentare il valore EOF correttamente.
Vogliamo aggiungere, quindi, un'asserzione statica che verifichi che il tipo int sia in grado di rappresentare il valore EOF. Per fare ciò, possiamo utilizzare la macro static_assert() come segue:
#include <assert.h>
#include <stdio.h>
#include <limits.h>
void pulisci_stdin() {
int c;
do {
c = getchar();
static_assert(UCHAR_MAX < INT_MAX,
"Il tipo int deve essere in grado di"
"rappresentare EOF");
} while (c != '\n' && c != EOF);
}
In questo esempio, l'asserzione statica verifica che il valore massimo rappresentabile dal tipo unsigned char (definito dalla macro UCHAR_MAX nell'header <limits.h>) sia minore del valore massimo rappresentabile dal tipo int (definito dalla macro INT_MAX). Studieremo l'header <limits.h> in una lezione successiva.
Da notare che l'asserzione statica è stata inserita all'interno della funzione pulisci_stdin(), ma ciò è perfettamente valido. Abbiamo agito in questo modo così che l'asserzione si trova nei pressi del codice che dipende dalla verità dell'assunzione. In questo modo, se l'asserzione viene violata, possiamo direttamente modificare il codice corrispondente.
Piazzamento delle asserzioni statiche
Sebbene le asserzioni statiche possano essere piazzate ovunque, conviene sempre metterle nei pressi del codice che dipende dalla verità dell'assunzione. In questo modo, se l'asserzione viene violata, è più facile individuare il codice che necessita di essere modificato.
Vediamo un ultimo esempio.
Supponiamo di avere una porzione di programma che copia una stringa di prefisso ad un'altra stringa statica. Possiamo adoperare static_assert per verificare, a tempo di compilazione, che la stringa di destinazione sia sufficientemente grande da contenere la stringa di prefisso più il carattere di terminazione nullo ('\0').
#include <assert.h>
static const char prefisso[] = "Messaggio: ";
#define DIM_DESTINAZIONE 50
char destinazione[DIM_DESTINAZIONE];
/* ... */
static_assert(sizeof(destinazione) > sizeof(prefisso),
"La stringa di destinazione deve essere"
"sufficientemente grande per contenere"
"il prefisso");
strcpy(destinazione, prefisso);
In questo modo, se per errore o distrazione il programmatore definisce DIM_DESTINAZIONE con un valore troppo piccolo, il compilatore genererà un errore di compilazione, evitando potenziali problemi di overflow del buffer a tempo di esecuzione.
Vantaggi e Limiti delle Asserzioni Statiche
Le asserzioni statiche, rispetto alle asserzioni normali o dinamiche, offrono diversi vantaggi:
- In primo luogo, esse non rallentano l'esecuzione del programma, poiché vengono verificate durante la fase di compilazione.
- In secondo luogo, esse permettono di rilevare errori a tempo di compilazione, evitando che il programma venga eseguito in uno stato non valido.
Inoltre, non c'è bisogno di adoperare direttive di precompilazione per abilitare o disabilitare le asserzioni statiche, come avviene per le asserzioni dinamiche con la macro NDEBUG.
Tuttavia, le asserzioni statiche presentano un limite importante: esse possono verificare solo condizioni che dipendono da valori noti a tempo di compilazione. Pertanto, non è possibile utilizzare le asserzioni statiche per verificare condizioni che dipendono da dati dinamici, come ad esempio l'input dell'utente o lo stato di una variabile durante l'esecuzione del programma.
Ad esempio, se volessimo verificare che una certa variabile x sia positiva durante l'esecuzione del programma, non potremmo utilizzare un'asserzione statica, poiché il valore di x non è noto a tempo di compilazione.
Infatti, il seguente codice produrrebbe un errore di compilazione:
#include <assert.h>
int x;
/* ... */
static_assert(x > 0, "La variabile x deve essere positiva");
Se proviamo a compilare questo codice, il compilatore genererà un errore, poiché x è una variabile il cui valore non è noto a tempo di compilazione.
Viceversa, se avessimo dichiarato la variabile x come const, allora il compilatore potrebbe conoscere il suo valore a tempo di compilazione, permettendoci di utilizzare un'asserzione statica:
#include <assert.h>
const int x = 10;
/* ... */
static_assert(x > 0, "La variabile x deve essere positiva");
In generale, l'espressione passata come primo argomento alla macro static_assert() deve essere valutabile a tempo di compilazione, altrimenti il compilatore genererà un errore. Non deve dipendere, cioè, da variabili o dati che non sono fino a che il programma viene eseguito.