Assert in C

Ultimamente sto programmando un po’ di più in C per un progetto universitario di cui parlerò in futuro.
Una funzionalità che sto sfruttando per assicurarmi che siano sempre verificate le precondizioni all’inizio di una funzione sono le assert (o, se preferite, le asserzioni).

Che cosa sono

Le asserzioni sono dei semplici predicati che indicano una condizione che deve essere verificata in un certo punto del programma. Quindi qualsiasi test che ritorna un valore booleano può essere utilizzato come argomento per una asserzione.

Esempio

Ad esempio, se volessi che una funzione accetti solo numeri positivi potrei scrivere:

/* @file assert.c */
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int func(int x)
{
    assert(x > 0);
    return x;
}

int main(void)
{
    int i = 0;
    printf("Numero: %d\n", func(i));
    return 0;
}    

Eseguendo questo programma otterrei:

[justb@dellill C]$ gcc -Wall assert.c -o assert.out && ./assert.out 
assert.out: assert.c:8: func: Assertion `x > 0' failed.
Annullato

L’esecuzione è stata annullata, ottenendo informazioni utili su cosa è andato storto, ma soprattutto dove è andato storto.

In produzione

Chiaramente la bruta interruzione di un programma non è sempre un comportamento accettabile. Secondo me l’uso delle asserzioni è molto utile durante lo sviluppo di un’applicazione, ma non può sostituire una robusta opera di error checking.
Inoltre, per applicazioni di una certa dimensione, l’aggiunta di molte asserzioni può causare un calo delle prestazioni, dovuto ai molti controlli aggiuntivi.

Così, per ottenere il meglio dei due approcci, è possibile indicare al preprocessore di ignorare le asserzioni nella build di produzione, passando il flag NDEBUG:

[justb@dellill C]$ gcc -Wall -DNDEBUG assert.c -o assert.out && ./assert.out 
Numero: 0

In questo modo non vi sarà nessun overhead nel programma finale dovuto alle varie assert.

Diario Progetto LASD – 07/11/11

Oggi ho ripreso a lavorare al progetto per il corso di Laboratorio di Algoritmi e strutture dati.

Tra le cose che ho fatto:

  • Aggiunta possibilità di estrazione dalla testa della lista, nella libreria lista
  • Creata l’interfaccia per la gestione di un insieme di vertici
  • Iniziata la prima bozza per la versione dell’insieme di vertici basato su array

Per quanto riguarda l’ultimo punto ho creato una struct così formata:

struct jvset_tag
{
    J_VERTEX **Vertices;   /**< Array contenente i vertici */
    int NumActiveVertices; /**< Numero di vertici inseriti nell'insieme */
    int NextFreeIndex;     /**< Indice della prossima locazione libera */
    int Size;              /**< Numero totale di vertici */
    J_LIST *FreeList;      /**< Lista delle locazioni libere */
};

Ho scritto anche le funzioni per la gestione dell'inizializzazione dell'insieme e per la relativa deallocazione.

Il prossimo passo è scrivere le funzioni di aggiunta e rimozione dei vertici.

Sto pensando però che forse dovrei tenere traccia anche delle locazioni dell'array occupate, e non solo di quelle libere, per permettere di effettuare la visita solo sui vertici effettivamente inseriti e non su tutta la dimensione dell'array.

Error handling in C: una panoramica personale

Il C non fornisce un meccanismo standard per la gestione degli errori interno al linguaggio (come le eccezioni) quindi è compito del programmatore decidere come procedere.

Le prime volte che ho programmato in C, pensavo di risolvere la questione facilmente, semplicemente emulando le funzioni della libreria standard. Purtroppo mi sono dovuto scontrare con la dura realtà: le interfacce della libreria standard sono parecchio discordanti su come segnalare una situazione di errore.

Ad esempio atoi restituisce 0 se non riesce a convertire la stringa passata in ingresso, il che è strano, perché 0 è un elemento del dominio della funzione. Infatti il programma:

//Esempio di output di atoi
//

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char uno[] = "uno";
    const char due[] = "2";
    const char zero[] = "0";

    int num1 = atoi(uno);
    int num2 = atoi(due);
    int num0 = atoi(zero);

    printf("atoi(\"uno\") vale %d\n", num1);
    printf("atoi(\"2\") vale %d\n", num2);
    printf("atoi(\"0\") vale %d\n", num0);

    return 0;
}

produrrà il seguente output:

[justb@dellill]$ ./atoi.out 

atoi("uno") vale 0
atoi("2") vale 2
atoi("0") vale 0

Oppure le funzioni della famiglia *alloc, che restituiscono NULL in caso di errore ed un puntatore all’area di memoria allocata in caso di successo, oppure ancora printf che restituisce il numero di caratteri stampati in caso di successo ed un numero negativo in caso contrario.
Insomma sembra che ogni funzione voglia dire la sua sull’argomento.

Tabula rasa: una libreria da zero

Sviluppare una libreria da zero porta quindi alcuni vantaggi.
Uno di questi è la possibilità di creare delle interfacce coerenti che consentano di presumere la posizione dei parametri e i valori di ritorno delle funzioni, tanto per fare un esempio, oppure di gestire coerentemente le situazioni d’errore.

Facendo alcune ricerche ho notato che esistono due orientamenti principali per la gestione degli errori in C:

  • Restituire lo stato dell’operazione, passando eventuali parametri di output via puntatore
  • Inserire in ogni metodo della libreria un parametro di output contenente lo stato dell’operazione

Restituire lo stato dell’operazione

In questo modello, si creano interfacce che restituiscono sempre un booleano (oppure un intero), indicando l’esito dell’operazione, ad esempio:

//Definizione di myFun
bool myFun(int InParam, int *OutParam);
//Implementazione di myFun
bool myFun( int InParam, int *OutParam )
{
    //Se non ci sono problemi il valore di ritorno sarà true
    bool status = true;

    if( condizioniFavorevoli ) {
        *OutParam = InParam * 2;
    } else {
        //ERRORE! Computazione fallita!
        status = false;
    }

    return status;
}                    

La variabile condizioniFavorevoli rappresenta una possibile situazione di errore in cui potrebbe incorrere myFun, mentre il parametro di uscita OutParam è passato tramite puntatore.
Un possibile utilizzo di questa funzione potrebbe essere il seguente:

/* Test per myFun
 * Compilare con -DFALSE per simulare una condizione di errore
 */
#include <stdio.h>
#include <stdbool.h> //C99
#include <stdlib.h>

#ifndef FALSE
bool condizioniFavorevoli = true;
#else
bool condizioniFavorevoli = false;
#endif

//Definizione di myFun
...

int main(void)
{
    bool status;
    int result;

    status = myFun( 3, &result );
    if( !status ) {
        fprintf(stderr, "Errore in myFun\n");
        exit(1);
    }

    printf("Il risultato di myFun: %d\n", result);

    return 0;
}

//Implementazione di myFun
...

Un possibile miglioramento sul tema, come consigliato in questa risposta su StackOverflow è quello di creare un tipo che contenga tutti i possibili errori utilizzati nella libreria, ed utilizzarlo al posto del semplice booleano, rendendo il codice più espressivo. Ad esempio:

/*
 * errors.h
 *
 * */

#ifndef STATUS_ERROR
#define STATUS_ERROR

typedef enum 
{
    SUCCESS,
    E_DIVIDE_ZERO,
    E_PASS_ONE
} STATUS;

#endif

#include "errors.h"

STATUS print_divide_ten(int input)
{
    STATUS ReturnStatus;

    ReturnStatus = SUCCESS;

    if( input == 0 ) {
        ReturnStatus = E_DIVIDE_ZERO;
    } else if( input == 1 ) {
        ReturnStatus = E_PASS_ONE;
    } else {
        printf("Faccio la divisione %d\n", 10 / input);
    }

    return ReturnStatus;
}

L’utilizzo è praticamente lo stesso del caso precedente, solo che in questo caso è possibile decidere come comportarsi a seconda del valore di ritorno.
Aggiungendo una funzione che trasponga il codice di errore in un messaggio significativo per l’utente si ha a disposizione una buona infrastruttura per la gestione degli errori:

void explain_error_code( STATUS code )
{
    switch(code)
    {
        case SUCCESS:
            printf("Tutto ok\n");
            break;
        case E_DIVIDE_ZERO:
            printf("È stata tentata una divisione per zero\n");
            break;
        case E_PASS_ONE:
            printf("Il valore uno non è accettato");
            break;
        default:
            printf("Status code sconosciuto\n");
            break;
    }
}

Stato passato tramite puntatore

L’altro modello di gestione degli errori consiste nell’utilizzare il valore di ritorno della funzione per un eventuale output, mentre lo stato è passato tramite un parametro della funzione.

La funzione di esempio si trasformerebbe nel modo seguente:

int myFun2( int InParam, bool *Status )
{
    *Status = true;

    if( condizioniFavorevoli ) {
        return InParam * 2;
    } else {
        *Status = false;
    }

    return -1;
}

Mentre un possibile esempio di utilizzo sarebbe:

int main(void)
{
    bool status;
    int result;

    result = myFun2( 3, &status );
    if( !status ) {
        fprintf(stderr, "Errore in myFun\n");
        exit(1);
    }

    printf("Il risultato di myFun: %d\n", result);

    return 0;
}

Chiaramente anche in questo caso è possibile utilizzare una struct contenente le diverse tipologie di stati di ritorno da utilizzare nel programma chiamante.

Considerazioni

Ho letto commenti a favore e contro dell’uno e dell’altro metodo di gestione degli errori: c’è chi ritiene che l’output gestito tramite valore di ritorno sia più “naturale”, e chi invece sostiene che un eventuale situazione d’errore passi inosservata utilizzando un parametro di output.

Personalmente, ritengo che le due notazioni siano equivalenti in quanto, soprattutto in linguaggi come il C, il controllo degli errori debba essere ai limiti del maniacale.
Piuttosto credo sia meglio concentrarsi sulla coerenza delle interfacce: ovvero scegliere un metodo di gestione degli errori per la libreria in sviluppo, e mantenere sempre lo stesso stile. In questo modo si facilita il lavoro all’utilizzatore finale della libreria, che può contare su un comportamento quantomeno prevedibile degli strumenti che ha a disposizione.

Bonus: implementare le eccezioni in C

Facendo ricerche su questo argomento ho appreso che è possibile simulare la gestione delle eccezioni in C utilizzando la coppia di funzioni setjmp/longjmp.

Una prima implementazione è possibile trovarla sul sito di Francesco Nidito, mentre per una trattazione più approfondita è possibile consultare C Interfaces and Implementations: Techniques for Creating Reusable Software.

Per un altro esempio d’uso delle funzioni setjmp/longjmp, potete consultare questo post dell’amico Gian Paolo “JP” Ghilardi

Preprocessor directives in C

Il C è come il vino buono, più passa tempo, più si apprezza.
Oggi ho imparato (solo oggi, purtroppo) ad usare le direttive al preprocessore.
Ad esempio mi era utile definire se il codice dovesse essere compilato per debug oppure per produzione, e ho risolto in questo modo:

/*
 * compile_directives.c
 *
 **/
#include 

#ifdef DEV_ENV
#define DEBUG 1
#else
#define DEBUG 0
#endif

int main(){
  if(DEBUG) {
    printf("Development environment\n");
  } else {
    printf("Production environment\n");
  }
  return 0;
}   

Compilando senza alcun flag aggiuntivo si ottiene:

[justb@dellill]$ gcc -Wall compile_directives.c -o compile_directives.out
[justb@dellill]$ ./compile_directives.out 
Production environment

Mentre aggiungendo il flag -D DEV_ENV dato che, come recita man gcc:

-D name
Predefine name as a macro, with definition 1.

[justb@dellill]$ gcc -Wall -DDEV_ENV compile_directives.c -o compile_directives.out
[justb@dellill]$ ./compile_directives.out 
Development environment

Pretty neat, uh?