Compilazione Condizionale in linguaggio C

La potenza del preprocessore del linguaggio C non sta solo nella possibilità di definire macro o includere file. Attraverso di esso è possibile effettuare la cosiddetta compilazione condizionale.

Attraverso la compilazione condizionale è possibile includere o escludere parti di codice dal programma. L'esclusione avviene a tempo di compilazione, e non a tempo di esecuzione. Questo significa che le parti di codice escluse non saranno presenti nel programma compilato.

In questa lezione vedremo quali sono le direttive del preprocessore che permettono di effettuare la compilazione condizionale e ne studieremo gli usi più comuni.

Compilazione Condizionale

La compilazione condizionale è una tecnica che permette di includere o escludere intere porzioni di codice dal programma in base a determinate condizioni.

Si tratta di una tecnica applicata a tempo di compilazione. Infatti, mentre nel caso di istruzioni condizionali, come if o switch, il controllo su quali istruzioni eseguire viene effettuato durante l'esecuzione del programma stesso, nel caso della compilazione condizionale, le istruzioni escluse saranno completamente assenti dal programma compilato.

Pertanto, le condizioni che determinano quali parti di codice includere o escludere devono essere note al compilatore, e non possono dipendere da valori calcolati a tempo di esecuzione.

Il preprocessore del C fornisce delle direttive per la compilazione condizionale, che permettono di includere o escludere parti di codice in base al valore di una macro.

Vediamo in questa lezione quali sono le direttive del preprocessore che permettono di effettuare la compilazione condizionale.

Definizione

Compilazione Condizionale

La compilazione condizionale è una tecnica che permette di includere o escludere parti di codice dal programma in base al risultato di un test effettuato dal preprocessore.

Direttive #if e #endif

Un primo utilizzo della compilazione condizionale riguarda la possibilità di compilare un programma in due modalità differenti: debug e release.

Tipicamente, quando un programma è in fase di sviluppo, si tende ad inserire nel programma stesso istruzioni e funzionalità aggiuntive che permettono di effettuare controlli e debug del codice. Una delle funzionalità aggiuntive tipiche è la possibilità di stampare a video messaggi di debug, per verificare il corretto funzionamento del programma. In tal caso si dice che il programma è compilato in modalità debug.

Quando, poi, il programma è pronto per essere distribuito, si desidera che il programma sia il più possibile leggero e veloce, e che non contenga parti di codice aggiuntive che non siano strettamente necessarie al funzionamento del programma. In tal caso si dice che il programma è compilato in modalità release.

Per gestire queste due modalità di compilazione, si procede, tipicamente, in questo modo.

Per prima cosa, si definisce una macro che indichi la modalità di compilazione. Ad esempio, si può definire una macro DEBUG che, se pari ad 1, indica che il programma è in modalità debug, mentre se pari a 0, indica che il programma è in modalità release.

#define DEBUG 1

Non è importante il nome della macro, ma è importante che il valore della macro sia definito in modo che possa essere utilizzato come condizione per la compilazione condizionale.

Successivamente, ogniqualvolta vogliamo inserire del codice di diagnostica nel nostro programma, possiamo utilizzare la direttiva #if per includere il codice solo se la macro DEBUG è definita e pari a 1.

Ad esempio:

#if DEBUG
    printf("Debug: il valore di x è %d\n", x);
#endif

In fase di precompilazione, il preprocessore verifica la condizione che segue la direttiva #if. In tal caso, la direttiva #if del preprocessore funziona esattamente come l'istruzione if del C, nel senso che essa considera come vero qualunque valore diverso da 0.

Se la condizione risulta vera, il preprocessore include nel programma le istruzioni che seguono la direttiva #if fino alla direttiva #endif che chiude il blocco. Viceversa, se la condizione risulta falsa, il preprocessore esclude dal programma le istruzioni comprese tra #if e #endif.

All'atto pratico, se la condizione della direttiva #if è falsa, il preprocessore cancella le righe di codice comprese. Pertanto, il compilatore, quando riceverà in ingresso il codice sorgente modificato, non troverà traccia delle righe di codice comprese tra #if e #endif. Il codice non verrà, quindi, incluso nel programma finale.

Nell'esempio di sopra, se la macro DEBUG è definita e pari a 1, il messaggio di debug verrà stampato a video. Se, invece, la macro DEBUG non è definita o è pari a 0, il messaggio di debug non verrà incluso nel programma finale.

In questo modo, possiamo lasciare le stampe di debug e disattivarle modificando semplicemente il valore della macro DEBUG.

Ricapitolando:

Definizione

Direttiva #if e #endif

La direttiva #if permette di includere o escludere parti di codice in base al valore di una macro. Se la condizione è vera, il codice compreso tra #if e #endif verrà incluso nel programma finale.

La sintassi è la seguente:

#if condizione
    // codice da includere
#endif

Facciamo due osservazioni. La prima è che la condizione della direttiva #if può essere espressione costante. Con questo intendiamo che l'espressione può essere anche complessa ma deve essere calcolabile a tempo di compilazione.

La conseguenza è che non possiamo utilizzare variabili o funzioni che vengono valutate a tempo di esecuzione. Ad esempio, non possiamo scrivere:

/* ERRORE: il valore di x non è noto a tempo di compilazione */
int x;

/* ... */

#if x > 0
    // codice da includere
#endif

Possiamo, però, combinare con espressioni complesse i valori di macro diverse. Ad esempio:

#define DEBUG 1
#define VERBOSE 0
#define VERSION 5

#if DEBUG && VERBOSE
    // codice da includere
#endif

#if !VERBOSE && DEBUG
    // codice da includere
#endif

#if VERSION < 5
    // codice da includere
#endif

Nel primo caso, il codice verrà incluso solo se entrambe le macro DEBUG e VERBOSE sono definite e pari a 1. Nel secondo caso, il codice verrà incluso solo se la macro DEBUG è definita e pari a 1, mentre la macro VERBOSE è definita e pari a 0. Nel terzo caso, il codice verrà incluso solo se la macro VERSION è definita e minore di 5.

Definizione

Condizioni delle Direttive #if

Le condizioni delle direttive #if possono essere espressioni costanti, cioè espressioni che possono essere valutate a tempo di compilazione. Le espressioni possono essere composte da valori di macro, operatori e costanti numeriche.

Il vincolo è che le espressioni devono essere valutabili a tempo di compilazione.

La seconda osservazione riguarda la definizione delle macro presenti nelle condizioni. In particolare, non è necessario che le macro siano definite. Se una macro non è definita, il preprocessore la considera come se fosse definita e pari a 0.

Prendiamo l'esempio che segue:

#if DEBUG
    printf("Debug: il valore di x è %d\n", x);
#endif

Se la macro DEBUG non è definita, il preprocessore considera la macro DEBUG come se fosse definita e pari a 0. Pertanto, il codice verrà escluso dal programma finale.

Analogamente, possiamo scrivere:

#if !DEBUG
    /* Codice da inserire in modalità release */
#endif

In tal caso, se la macro DEBUG non è definita, il codice verrà incluso nel programma finale.

Definizione

Macro non Definite

Se una macro non è definita, il preprocessore la considera come se fosse definita e pari a 0. Pertanto, possiamo utilizzare le macro non definite nelle condizioni delle direttive #if.

Operatore defined

Oltre agli operatori di conversione stringa, #, e di concatenazione, ##, esiste un terzo operatore specifico del preprocessore: l'operatore defined.

Questo operatore, quando applicato ad un identificatore, restituisce 1 se l'identificatore corrisponde ad una macro definita, e 0 altrimenti.

Questo operatore viene usato, spesso, in congiunzione con la direttiva #if per verificare se una macro è definita o meno.

Usando questo operatore possiamo, ad esempio, modificare il codice che segue:

#if DEBUG
    printf("Debug: il valore di x è %d\n", x);
#endif

in:

#if defined(DEBUG)
    printf("Debug: il valore di x è %d\n", x);
#endif

Esiste, tuttavia, una fondamentale differenza. L'operatore defined restituisce 1 se la macro è definita, indipendentemente dal suo valore basta, cioè, che sia definita.

Per cui, se scriviamo:

#define DEBUG 0

oppure scriviamo:

#define DEBUG 1

in entrambi i casi la condizione #if defined(DEBUG) risulterà vera.

Quindi, quando si usa defined non è necessario passare un valore alla macro. Basta scrivere semplicemente:

#define DEBUG
Definizione

Operatore defined

L'operatore defined restituisce 1 se l'identificatore passato come argomento è definito, e 0 altrimenti.

La sintassi è:

defined(identificatore)

Direttive #ifdef e #ifndef

Le direttive #ifdef e #ifndef sono delle abbreviazioni delle direttive #if defined e #if !defined. Il loro scopo è proprio quello di verificare se una macro è definita o meno.

Possiamo, ad esempio, riscrivere il codice seguente:

#if defined(DEBUG)
    printf("Debug: il valore di x è %d\n", x);
#endif

in questo modo:

#ifdef DEBUG
    printf("Debug: il valore di x è %d\n", x);
#endif

Analogamente, possiamo riscrivere il codice seguente:

#if !defined(DEBUG)
    /* Codice da inserire in modalità release */
#endif

in questo modo:

#ifndef DEBUG
    /* Codice da inserire in modalità release */
#endif
Definizione

Direttive #ifdef e #ifndef

La direttiva #ifdef verifica se una macro è definita, mentre la direttiva #ifndef verifica se una macro non è definita.

La sintassi è:

#ifdef identificatore
#ifndef identificatore

Direttiva #elif e #else

Per convenienza, il precompilatore mette a disposizione anche le direttive #elif e #else che possono essere utilizzate in congiunzione con le direttive #if, #ifdef e #ifndef.

La sintassi da utilizzare è la seguente:

#if condizione1
    // codice da includere se condizione1 è vera
#elif condizione2
    // codice da includere se condizione1 è falsa e condizione2 è vera
#else
    // codice da includere se nessuna delle condizioni precedenti è vera
#endif

Un possibile esempio è:

#ifdef DEBUG
    printf("Debug: il valore di x è %d\n", x);
#else
    /* Codice da inserire in modalità release */
#endif

In un blocco di codice condizionale, possiamo avere più direttive #elif e una sola direttiva #else. La direttiva #else è facoltativa.

Definizione

Direttive #elif e #else

Le direttive #elif e #else possono essere utilizzate in congiunzione con le direttive #if, #ifdef e #ifndef.

La sintassi è:

#elif condizione
#else

Un blocco di codice condizionale può contenere più direttive #elif e una sola direttiva #else.

Blocchi di Codice Condizionale annidati

Il preprocessore permette di innestare blocchi di codice condizionale. Questo significa che possiamo inserire un blocco di codice condizionale all'interno di un altro blocco di codice condizionale. Proprio come avviene con le istruzioni if e else del C.

Ad esempio, possiamo scrivere:

#if condizione1
    // codice da includere se condizione1 è vera
    #if condizione2
        // codice da includere se condizione1 e condizione2 sono vere
    #endif
#else
    // codice da includere se condizione1 è falsa
#endif

In questo caso, il codice compreso tra #if condizione2 e #endif verrà incluso solo se la condizione condizione1 è vera e la condizione condizione2 è vera.

Consiglio

Suggerimento per la scrittura del codice condizionale annidato

Quando si usa il codice condizionale annidato, è consigliabile non solo indentare correttamente il codice, ma anche utilizzare commenti per indicare a quale blocco di codice appartiene ciascuna direttiva #if, #elif, #else e #endif.

Ad esempio:

1
2
3
4
5
6
7
8
#if condizione1
    // codice da includere se condizione1 è vera
    #if condizione2
        // codice da includere se condizione1 e condizione2 sono vere
    #endif /* condizione2 */
#else
    // codice da includere se condizione1 è falsa
#endif /* condizione1 */

In questo esempio abbiamo identato il codice e abbiamo inserito commenti alle righe 5 e 8 per indicare a quale blocco di codice appartengono le direttive #endif.

Utilizzi della Compilazione Condizionale

Abbiamo visto che uno degli utilizzi tipici della compilazione condizionale è la diagnostica o debugging del codice. In tal caso, possiamo inserire stampe a video o controlli che ci permettono di verificare il corretto funzionamento del programma. Quando, poi, il programma è pronto per essere distribuito, possiamo disattivare le stampe di debug semplicemente modificando il valore di una macro.

Gli utilizzi della compilazione condizionale non si limitano a questo. Vediamo, in questa sezione, alcuni degli usi più comuni.

Portabilità del codice

Spesso sorge la necessità di dover scrivere un programma per più sistemi operativi, ad esempio Linux, Windows e MacOS. Ogni sistema operativo ha delle specifiche particolari che richiedono l'utilizzo di funzioni o librerie diverse.

Si potrebbe pensare di scrivere tre versioni differenti del programma, una per ogni sistema. Tuttavia, questa soluzione non è ottimale, in quanto richiederebbe di mantenere tre versioni del codice, con il rischio di introdurre errori o bug.

Un'alternativa è quella di utilizzare la compilazione condizionale per includere nel programma le parti di codice specifiche per ciascun sistema operativo.

I compilatori di questi sistemi operativi, infatti, definiscono delle macro predefinite che indicano il sistema operativo per cui il programma è compilato. Ad esempio, il compilatore di Linux definisce la macro __linux__, il compilatore di Windows definisce la macro _WIN32, e così via.

Possiamo, pertanto, scrivere il codice in questo modo:

#ifdef __linux__
    // Codice specifico per Linux
#elif _WIN32
    // Codice specifico per Windows
#elif __APPLE__
    // Codice specifico per MacOS
#endif

Quando si scrive il codice in questo modo si parla di codice portabile, cioè di codice che può essere compilato ed eseguito su più sistemi operativi senza dover apportare modifiche al codice sorgente.

Versioni del Software

Un altro utilizzo della compilazione condizionale è quello di gestire le diverse versioni di un software. Ad esempio, si potrebbe voler distribuire una versione lite di un software che contiene solo le funzionalità base, e una versione pro che contiene funzionalità aggiuntive.

In tal caso, si può definire una macro che indica la versione del software, e utilizzare la compilazione condizionale per includere nel programma le parti di codice specifiche per ciascuna versione.

Ad esempio:

#define VERSION_LITE 1
#define VERSION_PRO 2

#define VERSION VERSION_LITE

#if VERSION == VERSION_LITE
    // Codice specifico per la versione lite
#elif VERSION == VERSION_PRO
    // Codice specifico per la versione pro
#endif

In questo modo, sarà necessario scrivere soltanto una versione del codice, e utilizzare la compilazione condizionale per includere nel programma le parti di codice specifiche per ciascuna versione.

Compilazione con compilatori diversi

Con la compilazione condizionale possiamo gestire anche le differenze tra compilatori diversi. Ogni compilatore definisce delle macro predefinite che indicano il compilatore stesso. Ad esempio, il compilatore GCC definisce la macro __GNUC__, il compilatore Clang definisce la macro __clang__, e così via.

Le differenze tra compilatori possono riguardare, ad esempio, l'implementazione di alcune funzionalità del linguaggio, o la gestione di alcune estensioni del linguaggio.

Inoltre, un compilatore potrebbe supportare uno standard del linguaggio diverso da un altro compilatore. Ad esempio, un compilatore vecchio potrebbe supportare solo lo standard C89, mentre un compilatore più recente potrebbe supportare lo standard C99 o C11.

In tal caso, possiamo scrivere il codice in questo modo:

#ifdef __GNUC__
    // Codice specifico per il compilatore GCC
#elif __clang__
    // Codice specifico per il compilatore Clang
#endif

#ifdef __STDC_VERSION__
    #if __STDC_VERSION__ >= 199901L
        // Codice specifico per compilatori che supportano lo standard C99
    #endif
#endif

In questo modo, possiamo scrivere il codice in modo da tener conto delle differenze tra compilatori diversi.

Definizioni di default delle macro

Infine, un altro utilizzo della compilazione condizionale è quello di controllare se una specifica macro sia stata definita e, in caso contrario, definirla con un valore di default.

Ad esempio, supponiamo di voler controllare se è stata definita la macro BUFFER_SIZE che indica la dimensione del buffer da utilizzare nel programma. Se la macro non è stata definita, possiamo definirla con un valore di default:

#ifndef BUFFER_SIZE
    #define BUFFER_SIZE 1024
#endif

In questo modo, se la macro BUFFER_SIZE non è stata definita, il preprocessore la definirà con un valore di default pari a 1024.

In Sintesi

In questa lezione abbiamo visto come utilizzare la compilazione condizionale per includere o escludere parti di codice dal programma in base al valore di una macro.

Abbiamo studiato nuove direttive del preprocessore:

  • #if e #endif per includere o escludere parti di codice in base al risultato di una espressione costante;
  • #ifdef e #ifndef per verificare se una macro è definita o meno;
  • #elif e #else per gestire più condizioni;
  • l'operatore defined per verificare se una macro è definita.

Abbiamo visto che la compilazione condizionale è una tecnica molto potente che può essere utilizzata per diversi scopi, come la diagnostica del codice, la portabilità del software, la gestione delle versioni del software e delle differenze tra compilatori.

Inoltre, abbiamo visto come utilizzare la compilazione condizionale per definire delle macro con valori di default, se non sono state definite dall'utente.

Nella prossima lezione concludiamo il capitolo sul preprocessore del C, studiando le rimanenti direttive che il preprocessore mette a disposizione.