Le préprocesseur

Le préprocesseur est un programme qui réalise des traitements sur le code source avant que ce dernier ne soit réellement compilé. Globalement, il a trois grands rôles :

  • réaliser des inclusions (la fameuse directive #include) ;
  • définir des macros qui sont des substituts à des morceaux de code. Après le passage du préprocesseur, tous les appels à ces macros seront remplacés par le code associé ;
  • permettre la compilation conditionnelle, c’est-à-dire de moduler le contenu d’un fichier source suivant certaines conditions.

Les inclusions

Nous avons vu dès le début du cours comment inclure des fichiers d’en-tête avec la directive #include, sans toutefois véritablement expliquer son rôle. Son but est très simple : inclure le contenu d’un fichier dans un autre. Ainsi, si nous nous retrouvons par exemple avec deux fichiers comme ceux-ci avant la compilation.

fichier.h
#ifndef FICHIER_H
#define FICHIER_H

extern int glob_var;

extern void f1(int);
extern long f2(double, char);

#endif
fichier.c
#include "fichier.h"

void f1(int arg)
{
    /* du code */
}

long f2(double arg, char c)
{
    /* du code */
}

Après le passage du préprocesseur et avant la compilation à proprement parler, le code obtenu sera le suivant :

fichier.c
extern int glob_var;

extern void f1(int arg);
extern long f2(double arg, char c);

void f1(int arg)
{
    /* du code */
}

long f2(double arg, char c)
{
    /* du code */
}

Nous pouvons voir que les déclarations contenues dans le fichier « fichier.h » ont été incluses dans le fichier « fichier.c » et que toutes les directives du préprocesseur (les lignes commençant par le symbole #) ont disparu.

Vous pouvez utiliser l’option -E lors de la compilation pour requérir uniquement l’utilisation du préprocesseur. Ainsi, vous pouvez voir ce que donne votre code après son passage.

$ zcc -E fichier.c

Les macroconstantes

Comme nous l’avons dit dans l’introduction, le préprocesseur permet la définition de macros, c’est-à-dire de substituts à des morceaux de code. Une macro est constituée des éléments suivants.

  • La directive #define.
  • Le nom de la macro qui, par convention, est souvent écrit en majuscules. Notez que vous pouvez choisir le nom que vous voulez, à condition de respecter les mêmes règles que pour les noms de variable ou de fonction.
  • Une liste optionnelle de paramètres.
  • La définition, c’est-à-dire le code par lequel la macro sera remplacée.

Dans le cas particulier où la macro n’a pas de paramètre, on parle de macroconstante (ou constante de préprocesseur). Leur substitution donne toujours le même résultat (d’où l’adjectif « constante »).

Substitutions de constantes

Par exemple, pour définir une macroconstante TAILLE avec pour valeur 100, nous utiliserons le code suivant.

#define TAILLE 100

L’exemple qui suit utilise cette macroconstante afin de ne pas multiplier l’usage de constantes entières. À chaque fois que la macroconstante TAILLE est utilisée, le préprocesseur remplacera celle-ci par sa définition, à savoir 100.

#include <stdio.h>
#define TAILLE 100

int main(void)
{
    int variable = 5;

    /* On multiplie par TAILLE */
    variable *= TAILLE;
    printf("Variable vaut : %d\n", variable);

    /* On additionne TAILLE */
    variable += TAILLE;
    printf("Variable vaut : %d\n", variable);
    return 0;
}

Ce code sera remplacé, après le passage du préprocesseur, par celui-ci.

int main(void)
{
    int variable = 5;

    variable *= 100;
    printf("Variable vaut : %d\n", variable);
    variable += 100;
    printf("Variable vaut : %d\n", variable);
    return 0;
}

Nous n’avons pas inclus le contenu de <stdio.h> car celui-ci est trop long et trop compliqué pour le moment. Néanmoins, l’exemple permet d’illustrer le principe des macros et surtout de leur avantage : il suffit de changer la définition de la macroconstante TAILLE pour que le reste du code s’adapte.

D’accord, mais je peux aussi très bien utiliser une variable constante, non ?

Dans certains cas (comme dans l’exemple ci-dessus), utiliser une variable constante donnera le même résultat. Toutefois, une variable nécessitera le plus souvent de réserver de la mémoire et, si celle-ci doit être partagée par différents fichiers, de jouer avec les déclarations et les définitions.

Notez qu’il vous est possible de définir une macroconstante lors de la compilation à l’aide de l’option -D.

zcc -DTAILLE=100

Ceci revient à définir une macroconstante TAILLE au début de chaque fichier qui sera substituée par 100.

Substitutions d’instructions

Si les macroconstantes sont souvent utilisées en vue de substituer des constantes, il est toutefois aussi possible que leur définition comprenne des suites d’instructions. L’exemple ci-dessous définit une macroconstante BONJOUR qui sera remplacée par puts("Bonjour !").

#include <stdio.h>

#define BONJOUR  puts("Bonjour !")

int main(void)
{
    BONJOUR;
    return 0;
}

Notez que nous n’avons pas placé de point-virgule dans la définition de la macroconstante, tout d’abord afin de pouvoir en placer un lors de son utilisation, ce qui est plus naturel et, d’autre part, pour éviter des doublons qui peuvent s’avérer fâcheux.

En effet, si nous ajoutons un point virgule à la fin de la définition, il ne faut pas en rajouter un lors de l’appel, sous peine de risquer des erreurs de syntaxe.

#include <stdio.h>

#define BONJOUR puts("Bonjour !");
#define AUREVOIR puts("Au revoir !");

int main(void)
{
    if (1)
        BONJOUR; /* erreur ! */
    else
        AUREVOIR;

    return 0;
}

Le code donnant en réalité ceci.

int main(void)
{
    if (1)
        puts("Bonjour !");;
    else
        puts("Au revoir !");;

    return 0;
}

Ce qui est incorrect. Pour faire simple : le corps d’une condition ne peut comprendre qu’une instruction, or puts("Bonjour !"); en est une, le point-virgule seul (;) en est une autre. Dès lors, il y a une instruction entre le if et le else, ce qui n’est pas permis. L’exemple ci-dessous pose le même problème en étant un peu plus limpide.

#include <stdio.h>


int main(void)
{
    if (1)
        puts("Bonjour !");

    puts("Je suis une instruction mal placée !");

    else
        puts("Au revoir !");

    return 0;
}
Macros dans d’autres macros

La définition d’une macro peut parfaitement comprendre une ou plusieurs autres macros (qui, à leurs tours, peuvent également comprendre des macros dans leur définition et ainsi de suite). L’exemple suivant illustre cela en définissant une macroconstante NB_PIXELS correspondant à la multiplication entre les deux macroconstantes LONGUEUR et HAUTEUR.

#define LONGUEUR 1024
#define HAUTEUR 768
#define NB_PIXELS (LONGUEUR * LARGEUR)

La règle suivante permet d’éviter de répéter les opérations de remplacements « à l’infini » :

Une macro n’est pas substituée si elle est utilisée dans sa propre définition.

Ainsi, dans le code ci-dessous, LONGUEUR donnera LONGUEUR * 2 / 2 et HAUTEUR sera substituée par HAUTEUR / 2 * 2. En effet, lors de la substitution de la macroconstante LONGUEUR, la macroconstante HAUTEUR est remplacée par sa définition : LONGUEUR * 2. Or, comme nous sommes en train de substituer la macroconstante LONGUEUR, LONGUEUR sera laissé tel quel. Le même raisonnement peut être appliqué pour la macroconstante HAUTEUR.

#define LONGUEUR (HAUTEUR / 2)
#define HAUTEUR (LONGUEUR * 2)
Définition sur plusieurs lignes

La définition d’une macro doit normalement tenir sur une seule ligne. C’est-à-dire que dès que la fin de ligne est atteinte, la définition est considérée comme terminée. Ainsi, dans le code suivant, la définition de la macroconstante BONJOUR_AUREVOIR ne comprend en fait que puts("Bonjour");.

#define BONJOUR_AUREVOIR puts("Bonjour");
puts("Au revoir")

Pour que cela fonctionne, nous sommes donc contraints de la rédiger comme ceci.

#define BONJOUR_AUREVOIR puts("Bonjour"); puts("Au revoir")

Ce qui est assez peu lisible et peu pratique si notre définition doit comporter une multitude d’instructions ou, pire, des blocs d’instructions… Heureusement, il est possible d’indiquer au préprocesseur que plusieurs lignes doivent être fusionnées. Cela se fait à l’aide du symbole \, que nous plaçons en fin de ligne.

#define BONJOUR_AUREVOIR puts("Bonjour");\
puts("Au revoir")

Lorsque le préprocesseur rencontre une barre oblique inverse (\) suivie d’une fin de ligne, celles-ci sont supprimées et la ligne qui suit est fusionnée avec la ligne courante.

Notez bien qu'aucun espace n’est ajouté durant l’opération !

#define BONJOUR_AUREVOIR\
puts("Bonjour");\
puts("Au revoir")

Le code ci-dessus est donc incorrect car il donne en vérité ceci.

#define BONJOUR_AUREVOIRputs("Bonjour");puts("Au revoir")

L’utilisation de la fusion ne se limite pas aux définitions de macros, il est possible de l’employer n’importe où dans le code source. Bien que cela ne soit pas obligatoire (une instruction pouvant être répartie sur plusieurs lignes), il est possible d’y recourir pour couper une ligne un peu trop longue tout en indiquant cette césure de manière claire.

printf("Une phrase composée de plusieurs résultats à présenter : %d, %f, %f, %d, %d\n",\
a, b, c, d, e, f);

Si vous souhaitez inclure un bloc d’instructions au sein d’une définition, ne perdez pas de vue que celui-ci constitue une instruction et donc que vous retomberez sur le problème exposé plus haut.

#include <stdio.h>

#define BONJOUR\
    {\
        puts("Bonjour !");\
    }
#define AUREVOIR\
    {\
        puts("Au revoir !");\
    }

int main(void)
{
    if (1)
        BONJOUR; /* erreur ! */
    else
        AUREVOIR;

    return 0;
}

Une solution fréquente à ce problème consiste à recourir à une boucle do {} while avec une condition nulle (de sorte qu’elle ne soit exécutée qu’une seule fois), celle-ci ne constituant qu’une seule instruction avec le point-virgule final.

#include <stdio.h>

#define BONJOUR\
    do {\
        puts("Bonjour !");\
    } while(0)
#define AUREVOIR\
    do {\
        puts("Au revoir !");\
    } while(0)

int main(void)
{
    if (1)
        BONJOUR; /* ok */
    else
        AUREVOIR;

    return 0;
}
Définition nulle

Sachez que le corps d’une macro peut parfaitement être vide.

#define MACRO

Dans un tel cas, la macro ne sera tout simplement pas substituée par quoi que ce soit. Bien que cela puisse paraître étrange, cette technique est souvent utilisée, notamment pour la compilation conditionnelle que nous verrons bientôt.

Annuler une définition

Enfin, une définition peut être annulée à l’aide de la directive #undef en lui spécifiant le nom de la macro qui doit être détruite.

#define TAILLE 100
#undef TAILLE

/* TAILLE n'est à présent plus utilisable. */

Les macrofonctions

Pour l’instant, nous n’avons manipulé que des macroconstantes, c’est-à-dire des macros n’employant pas de paramètres. Comme vous vous en doutez, une macrofonction est une macro qui accepte des paramètres et les emploie dans sa définition.

#define MACRO(paramètre, autre_paramètre) définition

Pour ce faire, le nom de la macro est suivi de parenthèses comprenant le nom des paramètres séparés par une virgule. Chacun d’eux peut ensuite être utilisé dans la définition de la macrofonction et sera remplacé par la suite fournie en argument lors de l’appel à la macrofonction.

Notez bien que nous n’avons pas parlé de « valeur » pour les arguments. En effet, n’importe quelle suite de symboles peut-être passée en argument d’une macrofonction, y compris du code. C’est d’ailleurs ce qui fait la puissance du préprocesseur.

Illustrons ce nouveau concept avec un exemple : nous allons écrire deux macros : EUR qui convertira une somme en euro en francs (français) et FRF qui fera l’inverse. Pour rappel, un euro équivaut à 6,55957 francs français.

#include <stdio.h>

#define EUR(x) ((x) / 6.55957)
#define FRF(x) ((x) * 6.55957)

int main(void)
{
   printf("Dix francs français valent %f euros.\n", EUR(10));
   printf("Dix euros valent %f francs français.\n", FRF(10));
   return 0;
}
Résultat
Dix francs français valent 1.524490 euros.
Dix euros valent 65.595700 francs français.

Appliquons encore ce concept avec un deuxième exercice : essayez de créer la macro MIN qui renvoie le minimum entre deux nombres.

#include <stdio.h>

#define MIN(a, b)  ((a) < (b) ? (a) : (b))

int main(void)
{
   printf("Le minimum entre 16 et 32 est %d.\n", MIN(16, 32));
   printf("Le minimum entre 2+9+7 et 3*8 est %d.\n", MIN(2+9+7, 3*8));
   return 0;
}
Résultat
Le minimum entre 16 et 32 est 16.
Le minimum entre 2+9+7 et 3*8 est 18.

Remarquez que nous avons utilisé des expressions composées lors de la deuxième utilisation de la macrofonction MIN.

Priorité des opérations

Quelque chose vous a peut-être frappé dans les corrections : pourquoi écrire (x) et pas simplement x ?

En fait, il s’agit d’une protection en vue d’éviter certaines ambiguïtés. En effet, si l’on n’y prend pas garde, on peut par exemple avoir des surprises dues à la priorité des opérateurs. Prenons l’exemple d’une macro MUL qui effectue une multiplication.

#define MUL(a, b)  (a * b)

Tel quel, le code peut poser des problèmes. En effet, si nous appelons la macrofonction comme ceci.

MUL(2+3, 4+5)

Nous obtenons comme résultat 19 (la macro sera remplacée par 2 + 3 * 4 + 5) et non 45, qui est le résultat attendu. Pour garantir la bonne marche de la macrofonction, nous devons rajouter des parenthèses.

#define MUL(a, b)  ((a) * (b))

Dans ce cas, nous obtenons bien le résultat souhaité, c’est-à-dire 45 ((2 + 3) * (4 + 5)).

Nous vous conseillons de rajouter des parenthèses en cas de doute pour éviter toute erreur.

Les effets de bords

Pour finir, une petite mise en garde : évitez d’utiliser plus d’une fois un paramètre dans la définition d’une macro en vue d’éviter de multiplier d’éventuels effets de bord.

Des effets de quoi ?

Un effet de bord est une modification du contexte d’exécution. Vous voilà bien avancé nous direz-vous… En fait, vous en avez déjà rencontré, l’exemple le plus typique étant une affectation.

a = 10;

Dans cet exemple, le contexte d’exécution du programme (qui comprend ses variables) est modifié puisque la valeur d’une variable est changée. Ainsi, imaginez que la macro MUL soit appelée comme suit.

MUL(a = 10, a = 20)

Après remplacement, celle-ci donnerait l’expression suivante : ((a = 10) * (a = 20)) qui est assez problématique… En effet, quelle sera la valeur de a, finalement ? 10 ou 20 ? Ceci est impossible à dire sans fixer une règle d’évaluation et… la norme n’en prévoit aucune dans ce cas ci. :-°

Aussi, pour éviter ce genre de problèmes tordus, veillez à n’utiliser chaque paramètre qu'une seule fois.

Les directives conditionnelles

Le préprocesseur dispose de cinq directives conditionnelles : #if, #ifdef, #ifndef, #elif et #else. Ces dernières permettent de conserver ou non une portion de code en fonction de la validité d’une condition (si la condition est vraie, le code est gardé sinon il est passé). Chacune de ces directives (ou suite de directives) doit être terminée par une directive #endif.

Les directives #if, #elif et #else
Les directives #if et #elif

Les directives #if et #elif, comme l’instruction if, attendent une expression conditionnelle ; toutefois, celles-ci sont plus restreintes étant donné que nous sommes dans le cadre du préprocesseur. Ce dernier se contentant d’effectuer des substitutions, il n’a par exemple aucune connaissance des mots-clés du langage C ou des variables qui sont employées.

Ainsi, les conditions ne peuvent comporter que des expressions entières (ce qui exclut les nombres flottants et les pointeurs) et constantes (ce qui exclut l’utilisation d’opérateurs à effets de bord comme =). Aussi, les mots-clés et autres identificateurs (hormis les macros) présents dans la définition sont ignorés (plus précisément, ils sont remplacés par 0).

L’exemple ci-dessous explicite cela.

#if 1.89 > 1.88 /* Incorrect, ce ne sont pas des entiers */
#if sizeof(int) == 4 /* Équivalent à 0(0) == 4, `sizeof' et `int' étant des mots-clés */
#if (a = 20) == 20 /* Équivalent à (0 = 20) == 4, `a' étant un identificateur */

Techniquement, seuls les opérateurs suivants peuvent être utilisés : + (binaire ou unaire), - (binaire ou unaire), *, /, %, ==, !=, <=, <, >, >=, !, &&, ||, ,, l’opérateur ternaire et les opérateurs de manipulations des bits, que nous verrons au chapitre suivant.

Bien entendu, vous pouvez également utiliser des parenthèses afin de régler la priorité des opérations.

L’opérateur defined

Le préprocesseur fournit un opérateur supplémentaire utilisable dans les conditions : defined. Celui-ci prend comme opérande un nom de macro et retourne 1 ou 0 suivant que ce nom corresponde ou non à une macro définie.

#if defined TAILLE

Celui-ci est fréquemment utilisé pour produire des programmes portables. En effet, chaque système et chaque compilateur définissent généralement une ou des macroconstantes qui lui sont propres. En vérifiant si une de ces constantes existe, nous pouvons déterminer sur quelle plate-forme et avec quel compilateur nous compilons et adapter le code en conséquence.

Notez que ces constantes sont propres à un système d’exploitation et/ou compilateur donné, elles ne sont donc pas spécifiées par la norme du langage C.

La directive #else

La directive #else quant à elle, se comporte comme l’instruction éponyme.

Exemple

Voici un exemple d’utilisation.

#include <stdio.h>

#define A 2

int main(void)
{
#if A < 0
    puts("A < 0");
#elif A > 0
    puts("A > 0");
#else
    puts("A == 0");
#endif
    return 0;
}
Résultat
A > 0

Notez que les directives conditionnelles peuvent être utilisées à la place des commentaires en vue d’empêcher la compilation d’un morceau de code. L’avantage de cette technique par rapport aux commentaires est qu’elle vous évite de produire des commentaires imbriqués.

#if 0
/* On multiplie par TAILLE */
variable *= TAILLE;
printf("Variable vaut : %d\n", variable);
#endif
Les directives #ifdef et #ifndef

Ces deux directives sont en fait la contraction, respectivement, de la directive #if defined et #if !defined. Si vous n’avez qu’une seule constante à tester, il est plus rapide d’utiliser ces deux directives à la place de leur version longue.

Protection des fichiers d’en-tête

Cela étant dit, vous devriez à présent être en mesure de comprendre le fonctionnement des directives jusqu’à présent utilisées dans les fichiers en-têtes.

#ifndef CONSTANTE_H
#define CONSTANTE_H

/* Les déclarations */

#endif

La première directive vérifie que la macroconstante CONSTANTE_H n’a pas encore été définie. Si ce n’est pas le cas, elle est définie (ici avec une valeur nulle) et le bloc de la condition (comprenant le contenu du fichier d’en-tête) est inclus. Si elle a déjà été définie, le bloc est sauté et le contenu du fichier n’est ainsi pas à nouveau inclus.

Pour rappel, ceci est nécessaire pour éviter des problèmes d’inclusions multiples, par exemple lorsqu’un fichier A inclut un fichier B, qui lui-même inclut le fichier A.


Avec ce chapitre, vous devriez pouvoir utiliser le préprocesseur de manière basique et éviter plusieurs de ses pièges fréquents. :)

En résumé
  • La directive #include permet d’inclure le contenu d’un fichier dans un autre ;
  • La directive #define permet de définir des macros qui sont des substituts à des morceaux de code ;
  • Les macros sans paramètres sont appelées macroconstantes ;
  • Les macros qui acceptent des paramètres et les emploient dans leur définition sont appelées des macrofonctions ;
  • Des effets de bord sont possibles si un paramètre est utilisé plus d’une fois dans la définition d’une macrofonction ;
  • Grâce aux directives conditionnelles, il est possible de conserver ou non une potion de code, en fonction de conditions.