Asserzioni in Linguaggio C

Un'asserzione, in generale, è una dichiarazione che afferma che una certa condizione è vera in un determinato punto del programma. Non si tratta di una normale istruzione di controllo del flusso, ma piuttosto di un mezzo per verificare che il programma rispetti certe ipotesi durante l'esecuzione.

Si tratta di uno strumento potente per la verifica del codice, in quanto consente agli sviluppatori di identificare rapidamente errori logici o condizioni inattese durante l'esecuzione del programma. Le asserzioni sono spesso utilizzate durante la fase di sviluppo e debug per garantire che il codice funzioni come previsto.

In linguaggio C, le asserzioni sono implementate tramite la macro assert, definita nell'header <assert.h>. In questa lezione vediamo come funzionano.

Concetti Chiave
  • Le asserzioni sono dichiarazioni che verificano che una certa condizione sia vera durante l'esecuzione del programma.
  • In linguaggio C, le asserzioni sono implementate tramite la macro assert, definita nell'header <assert.h>.
  • Se un'asserzione fallisce, il programma stampa un messaggio di errore e termina l'esecuzione.
  • Le asserzioni sono utili per il debug e la verifica del codice, ma possono essere disattivate in fase di produzione definendo la macro NDEBUG.

Definizione di Asserzione

Quando si realizzano dei programmi o comunque del software in generale, si sviluppano degli algoritmi che si basano su alcune ipotesi. Ad esempio, in un ciclo che itera su un array, si può ipotizzare che l'indice non superi mai la lunghezza dell'array stesso.

Si badi bene che tali assunzioni non riguardano la verifica dei dati in ingresso. Ad esempio, è normale controllare se il divisore di una divisione sia diverso da zero prima di eseguire l'operazione. Le asserzioni, invece, sono utilizzate per dimostrare che il codice sia corretto.

Vediamo un esempio pratico. Supponiamo di avere una funzione che ordina un array di numeri interi utilizzando l'algoritmo di ordinamento a bolle (bubble sort). Si può ipotizzare che, dopo l'esecuzione della funzione, l'array sia effettivamente ordinato. Se così non fosse, non si tratterebbe di un errore di input, ma di un errore logico nell'implementazione dell'algoritmo.

#include <stdio.h>

bool is_sorted(int arr[], int size) {
    for (int i = 1; i < size; i++) {
        if (arr[i - 1] > arr[i]) {
            return false;
        }
    }
    return true;
}

void bubble_sort(int arr[], int size) {
    for (int i = 0; i < size - 1; i++) {
        for (int j = 0; j < size - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

int main() {
    int data[] = {5, 2, 9, 1, 5, 6};
    int size = sizeof(data) / sizeof(data[0]);

    bubble_sort(data, size);

    if (is_sorted(data, size)) {
        printf("L'array è ordinato correttamente.\n");
    } else {
        printf("Errore: l'array non è ordinato!\n");
    }

    return 0;
}

La funzione is_sorted verifica se l'array è ordinato. Se, dopo aver chiamato bubble_sort, l'array non risulta ordinato, significa che c'è un errore nell'implementazione dell'algoritmo.

Il linguaggio C fornisce un modo semplice per esprimere queste asserzioni tramite la macro assert.

Utilizzo della Macro assert

Nel file di intestazione <assert.h>, è definita la macro assert, che consente di verificare una condizione specifica durante l'esecuzione del programma. La sintassi è la seguente:

#include <assert.h>

void assert(scalar espressione);

Anche se assert sembra una funzione, in realtà è una macro.

Ogni volta che viene invocata, la macro valuta l'espressione passata come argomento. Se l'espressione risulta vera, ossia ha un valore diverso da zero, il programma continua l'esecuzione normalmente come se non fosse accaduto nulla.

Se l'espressione risulta falsa (cioè ha valore zero), la macro assert stampa un messaggio di errore sullo standard error (stderr) e termina brutalmente il programma chiamando la funzione abort().

Riprendiamo l'esempio precedente e utilizziamo la macro assert per verificare che l'array sia ordinato dopo l'esecuzione della funzione di ordinamento:

#include <stdio.h>
#include <assert.h>

bool is_sorted(int arr[], int size) {
    for (int i = 1; i < size; i++) {
        if (arr[i - 1] > arr[i]) {
            return false;
        }
    }
    return true;
}

void bubble_sort(int arr[], int size) {
    for (int i = 0; i < size - 1; i++) {
        for (int j = 0; j < size - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

int main() {
    int data[] = {5, 2, 9, 1, 5, 6};
    int size = sizeof(data) / sizeof(data[0]);

    bubble_sort(data, size);

    assert(is_sorted(data, size)); // Verifica che l'array sia ordinato

    printf("L'array è ordinato correttamente.\n");
    return 0;
}

Adesso, aggiungiamo (volutamente) un errore nell'algoritmo di ordinamento per vedere come funziona l'asserzione:

void bubble_sort(int arr[], int size) {
    for (int i = 0; i < size - 1; i++) {
        for (int j = 0; j < size - i - 1; j++) {
            // Errore intenzionale: confronto errato
            if (arr[j] < arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

Adesso, provando a compilare ed eseguire il programma con l'errore, ciò che accade è che l'asserzione fallisce, e il programma stampa un messaggio di errore simile a questo:

Assertion failed: is_sorted(data, size), file example.c, line 20
Aborted (core dumped)

Alcune note importanti

Esistono delle leggere differenze nella struttura e nel comportamento della macro assert a seconda della versione dello standard C utilizzato (C89, C99, C11, ecc.), ma il concetto di base rimane lo stesso.

In particolare, nello standard C89 era richiesto che l'argomento di assert avesso il tipo int, quindi fosse un intero. Lo standard C99 rilassa questo requisito e richiede solo che l'argomento sia una espressione scalare, ossia un'espressione che produce un singolo valore (come int, float, char, ecc.).

Inoltre, lo standard C99 richiede che, in caso di fallimento dell'asserzione, venga stampato un messaggio di errore che includa il nome del file e il numero di linea in cui si è verificato il fallimento, migliorando così la capacità di debug. Ovviamente, la forma precisa del messaggio può variare a seconda dell'implementazione del compilatore.

Se usiamo gcc, tipicamente la forma del messaggio di errore sarà simile a quella mostrata nell'esempio precedente:

Assertion failed: <espressione>, file <nome_file>, line <numero_linea>

Disattivare le asserzioni

Le asserzioni sono un potente strumento di debug che ci consente di verificare la correttezza del codice durante lo sviluppo.

Tuttavia, esse hanno due svantaggi principali:

  1. Rallentano l'esecuzione del programma, poiché ogni asserzione richiede una valutazione della condizione.
  2. Possono interrompere bruscamente l'esecuzione del programma in caso di fallimento dell'asserzione. Queste interruzioni non sono gestite e potrebbero provocare perdite di dati o stati incoerenti.

Per questi motivi, quando si rilascia un programma in produzione, ossia quando si distribuisce il software agli utenti finali, non è una buona pratica mantenere attive le asserzioni.

Per disattivare le asserzioni in un programma C, non è necessario modificare il codice sorgente. Invece, è sufficiente definire la macro NDEBUG (che sta per "No Debug") prima di includere l'header <assert.h>.

La definizione di NDEBUG può essere fatta in due modi:

  1. Aggiungendo una direttiva #define all'inizio del file sorgente, prima dell'inclusione di <assert.h>:

    #define NDEBUG
    #include <assert.h>
    

    In questo modo, tutte le asserzioni nel file sorgente verranno disattivate.

  2. Passando l'opzione -DNDEBUG al compilatore durante la compilazione del programma. Ad esempio, se si utilizza gcc, il comando di compilazione potrebbe essere:

    gcc -DNDEBUG -o mio_programma mio_programma.c
    

In entrambi i casi, quando NDEBUG è definita, tutte le chiamate alla macro assert vengono ignorate dal compilatore. Di conseguenza, il programma non eseguirà alcuna verifica delle asserzioni durante l'esecuzione, migliorando le prestazioni e evitando interruzioni indesiderate.

Nota

Mai inserire codice con effetti collaterali all'interno di un'asserzione

Bisogna sempre evitare di usare, all'interno di un'asserzione, codice che produce effetti collaterali.

Infatti, prendiamo l'esempio che segue:

assert((p = malloc(n)) != NULL);

In questo caso, se NDEBUG è definita, l'asserzione viene ignorata e la chiamata a malloc non viene eseguita. Di conseguenza, la variabile p non viene inizializzata, il che può portare a comportamenti imprevisti nel programma.