Rechercher
 

Améliorations / Corrections

Vous avez des améliorations (ou des corrections) à proposer pour ce document : je vous remerçie par avance de m'en faire part, cela m'aide à améliorer le site.

Emplacement :

Description des améliorations :

Le préprocesseur C

Eclipse/CDT Les types et les variables


Accès rapide :
   Macro-génération de code
               L'instruction #define
               L'instruction #undef
               Les macros prédéfinies
   Inclusion de fichiers
               L'instruction #include
               Protection contre l'inclusion multiple
   Compilation conditionnelle
               Les instructions #if #ifdef #ifndef et #endif
               Les instructions #else et #elif
   Quelques autres instructions
               L'instruction #line
               L'instruction #error
               L'instruction #pragma

Une particularité propre aux compilateurs C est qu'ils ne travaillent pas directement sur le code source fourni par le programmeur. En fait une phase, spéciale de réécriture du programme précède toute compilation. L'utilitaire se chargeant de cette phase de prétraitement se nomme le préprocesseur.

Comme nous allons le voir, ce préprocesseur permet, entre autre, de définir des macros, de réaliser l'inclusion de fichiers (afin de réaliser une approche, certes peut être un peu archaïque, de la modularité) ou encore des compilations de code C conditionnelles. Nous allons donc étudier un à un chacun de ces points et quelques autres encore.

Notons, dès à présent, que toutes les instructions du préprocesseur commencent par un caractère « # ». Si vous oubliez ce premier caractère l'instruction ne sera plus considérée comme une instruction du préprocesseur, mais comme une instruction C prise en charge par le compilateur. Il en résultera très certainement une erreur de compilation. Autre remarque : l'écriture d'une instruction du préprocesseur est « case sensitive » (sensible à la casse). C'est à dire qu'il ne faut pas se tromper entre minuscules et majuscules. Ainsi, le mot clé #Define ne sera pas compris par le préprocesseur alors que l'instruction #define sera parfaitement prise en charge. Faites très attention sur ce dernier point.

Macro-génération de code

La macro-génération de code permet de réaliser des substitutions de code avant la phase de compilation. Cela peut notamment être utile pour concentrer la définition d'une valeur constante en un seul et unique point de votre code source. Dans ce cas, la macro sera vue comme une définition d'une constante et chaque utilisation de cette macro constante sera remplacée par la valeur associée par le préprocesseur. Une autre utilisation des macros permet de simuler des fonctions : dans ce cas, on parlera de macros paramétrées. Contrairement à une fonction (pour laquelle un appel de fonction est généré) une macro paramétrée verra son code injecté en lieu et place de l'utilisation de cette macro. Ainsi, comme aucun appel de fonction ne sera généré dans ce cas, pour des petits traitements (calcule de minimum, de maximum, ...) les performances seront améliorées. Attention à ne pas trop utiliser les macros pour des traitements lourds car cela a tendance à faire grossir la taille de l'exécutable.

Remarque : dans le cas de l'utilisation d'une macro pour gérer une valeur constante, il aurait une autre possibilité équivalente : définir une constante via le mot clé const. La différence, c'est qu'avec le mot clé const, la problématique est gérée par le compilateur et plus le préprocesseur. Les deux techniques sont équivalentes et chacune d'entre elles est couramment utilisée.

L'instruction #define

L'instruction #define permet donc de définir une macro. Comme indiqué précédemment, deux types de macros peuvent utilisées : les macros constantes et les macros paramétrées. Pour les distinguer, regarder si des paramètres sont spécifiés entre parenthèses à la suite du nom de la macro. Si tel est le cas, alors vous avez à faire à une macro paramétrée. Si ce n'est pas le cas, alors ce sera une macro constante.

Pour ce qui est de la syntaxe, une macro commence donc par le mot clé #define, suivi du nom de la macro (avec éventuellement une liste de paramètres) et enfin elle se termine par son code de substitution. Les trois parties étant juste séparées par des espaces.

Attention : contrairement à une instruction du langage C, les instructions du préprocesseur ne doivent pas être terminées par un caractère « ; ». Si vous oubliez cette règle, il sera très probable que des erreurs de compilations soient produites (dans le meilleur des cas) ou que des disfonctionnement soient observés durant l'exécution de votre programme (dans le pire des cas).

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

/* Définition de quelques macros */
#define FALSE 0
#define TRUE 1
#define BOOL int
#define MINI( a, b ) ((a)<(b) ? (a) : (b))
#define MAXI( a, b ) ((a)>(b) ? (a) : (b))

/* Exemple d'utilisation de ces macros */
int main() {

    BOOL state = TRUE;

    if ( state == FALSE ) {
        printf( "Maximum(3, 5) == %d",  MAXI( 3, 5 ) );
    } else {
        printf( "Minimum(3, 5) == %d",  MINI( 3, 5 ) );
    }

    return EXIT_SUCCESS;
}

Remarque 1 : nous cherchons dans cette exemple à simuler un type booléen à partir de macro. Cela est fait par de très nombreux développeurs C étant donné que le type booléen n'existe tout simplement pas en C. Par autant, depuis la version 2011 du standard C, une nouvelle entête (<stdbool.h>) prédéfinie une fois pour toute des macros similaires aux nôtres.

Remarque 2 : assez couramment et par convention, les noms des macros constantes sont orthographiés en majuscules. Nous vous recommandons donc de respecter cette convention afin de faciliter la lecture de vos codes.

Si vous êtes curieux, il est possible de voir le résultat de la transformation du code par le préprocesseur. Pour ce faire exécutez la commande suivante (l'option -E demandant à interrompre le traitement après la phase de preprocessing) :

gcc -E -o Sample.temp Sample.c

Pour information, voici le code de la fonction main (le point d'entrée de votre programme C) après traitement par le préprocesseur.

int main() {

    int state = 1;

    if ( state == 0 ) {
        printf( "Maximum(3, 5) == %d", ((3)>(5) ? (3) : (5)) );
    } else {
        printf( "Minimum(3, 5) == %d", ((3)<(5) ? (3) : (5)) );
    }

    return 0;
}

L'instruction #undef

Cette instruction permet d'annuler la définition d'une macro : à partir de cette ligne, la macro n'existera plus et son emploie amènera donc très certainement à une erreur de compilation, sauf si un élément de même nom que la macro existe dans le programme (comme c'est le cas dans l'exemple ci-dessous). Le fait qu'une macro puisse avoir le même nom qu'une variable (par exemple), est possible, car le préprocesseur et le compilateur sont deux traitements s'exécutant à deux moments différents : ces deux éléments de même nom peuvent donc cohabiter.

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

int main() {

    int a = 123;

    #define a 456
    printf( "a == %d\n", a );
    #undef a

    printf( "a == %d\n", a );

    return EXIT_SUCCESS;
}

Si vous compilez ce programme et que vous l'exécutez, voici les résultats qui vous seront retournés.

$> gcc -o Sample -Wall Sample.c
$> ./Sample
a == 456
a == 123
$>

Les macros prédéfinies

Il existe un certain nombre de macros qui sont prédéfinies et qui vous permettent de connaître un certain nombre de choses. Ces macros ne peuvent en aucun cas être supprimées ou altérées. Elles sont donc immuables. Le tableau suivant vous donne le nom de ces macros ainsi que leur développement.

Nom des macros Développement de la macro associée
__LINE__

Se développe en une valeur numérique associée au numéro de la ligne courante dans le code du programme.

__FILE__

Cette macro sera remplacée par le nom du fichier en cours de traitement.

__DATE__

Celle-ci se transformera en une chaîne de caractères contenant la date de traitement du fichier sous un format "Mmm jj aaaa".

__TIME__

De même, se transformera en un chaîne représentant l'heure de traitement du fichier sous le format "hh:mm:ss".

__STDC__

Cette macro n'est définie que si votre compilateur respecte les normes ANSI ou ISO. Si tel est le cas, elle est alors définie à la valeur 1.

__STDC_VERSION__

Cette macro existe que depuis C99 (C ISO 19999) : elle n'est donc non définie pour les anciens compilateur. En C99 elle renverra la valeur 199901L (typée long), en C11 elle renverra la valeur 201112L et en C18 elle renverra la valeur 201710L (oui, je sais, c'est bizarre).

Faites attention : devant et derrière chaque nom se trouvent deux caractères _ (blancs soulignés et pas des tirés -).

Inclusion de fichiers

Outre la macro-génération de code, le préprocesseur permet d'autres possibilités. Parmi ces possibilités, nous retrouvons l'inclusion de fichiers. En C, c'est grâce à ce mécanisme que nous allons pouvoir faire de la programmation modulaire. Effectivement, coder 2 million de lignes de code dans un seul est unique fichier, pour être très problématique (temps de compilation extrêmement long, très mauvaise maintenabilité du programme, ...). Il est donc conseillé de répartir un tel programme dans plusieurs fichiers. Mais si tel est le cas, alors il va falloir donner un petit coup de pouce au compilateur pour qu'il sache qu'est ce qui sera fourni par un module de code précis. La définition de ces éléments sera effectuée dans un fichier d'entête (un fichier d'extension .h). Souvent nous aurons un .h par module de code. Ce fichier d'extension .h devra être inclue dans les fichiers qui utiliserons les éléments de ce module.

Dans les faits, nous utilisons déjà cette possibilité depuis un petit moment. Effectivement, nos exemples précédents utilisent des modules (des librairies) de codes pour la gestion des I/O (Input/Output) et les fonctions standards : nous avons donc régulièrement inclus <stdio.h> et <stdlib.h> via le mot clé #include. Il est important de comprendre qu'un .h ne permet que de vérifier la bonne cohérence des appels de fonctions. Les liens entre les différents modules de codes étant définitivement résolus qu'à la phase d'édition des liens.

L'instruction #include

L'instruction #include injecte, en lieu est place de cette instruction, le contenu du fichier spécifié. Si ce fichier inclut contient lui-même des instructions #include alors ces fichiers seront eux aussi récursivement inclus. Théoriquement, il n'y a pas de limite, si ce n'est les chaînes d'inclusion circulaire (à éviter);

Notez qu'il existe deux formes d'utilisation de l'instruction #include. Ces deux formes correspondent à deux localisations de recherche sur le système de fichiers. La syntaxe « #include <filename> » permet de rechercher le fichier d'entête parmi celles proposées de base avec votre compilateur. La syntaxe « #include "filename" » permet de rechercher le fichier d'entête parmi celles développées au sein de votre programme. Attention à correctement utiliser ces deux syntaxes (pour une meilleur compréhension du code) en sachant qu'avec la seconde solution, si le fichier n'est pas trouvé dans les répertories de votre application, alors une recherche est effectuée dans les répertoires du compilateur.

#include "monfichier.h";    /* Fichier local au projet */
#include <stdxxx.h>    /* Fichier fourni par le compilateur */

Protection contre l'inclusion multiple

Dernier point par rapport à ce sujet : un fichier d'entête (un .h) ne doit pas être inclue plusieurs fois dans un même fichier à compiler. Or parfois un de vos fichiers inclue d'autres fichiers qui eux-mêmes inclue encore de fichiers... Comment être sûr qu'à des niveaux 2, 3 ou supérieurs d'inclusions on ne retombe pas plusieurs fois sur le même fichier ? La réponse réside dans le fait de sécuriser un .h contre l'inclusion multiple en se servant du mécanisme de macro. L'exemple ci-dessous montre comment le fichier <stdio.h> est sécurisé.

#ifndef _STDIO_H
#define _STDIO_H   1

...

#endif /* !_STDIO_H */

Quelques explications : la première ligne cherche à tester si la macro _STDIO_H est définie ou non (#ifnef => if not defined). Si tel est le cas, alors le fichier est inclus pour la première fois. Il définit alors la macro pour que le prochain coup, le test soit négatif. La séquence « ... » symbolise le contenu du fichier. Enfin la dernière ligne marque la fin de la protection. Lors d'une seconde tentative d'inclusion, comme la macro sera définie. Tout ce qui sera compris entre le #ifndef et le #endif sera ignoré et il n'y aura donc pas plusieurs fois l'inclusion du code.

Compilation conditionnelle

Après la macro-génération de code et l'inclusion de fichier, le troisième type d'utilisation classique du préprocesseur réside dans la compilation conditionnelle. La compilation conditionnelle, comme son nom l'indique, consiste à adapter le contenu d'un fichier de code C en fonction de telle ou telle condition. Il en résulte que le compilateur traitera un fichier dont le contenu sera dépendant des conditions considéré.

Deux cas classiques d'utilisation de la compilation conditionnelle me viennent à l'esprit. Le premier cas consiste à adapter le contenu d'un fichier selon qu'on soit en mode debug ou non. Le second cas, assez similaire, consiste à adapter le contenu d'un fichier en fonction que l'on compile pour une plate-forme Linux, ou pour une autre (ainsi seuls les codes relatifs à une plate-forme seront produits dans le fichier final). En changeant la condition, le code final produit sera différent.

Il faut savoir que pour définir une macro, il y a une autre façon que l'utilisation de l'instruction #define. Effectivement, en spécifiant des paramètres sur la ligne de commande de votre compilateur, vous pouvez spécifier et affecter une valeur à vos macros. L'exemple ci-dessus montre comment définir une macro NDEBUG, en mode ligne de commande, avec le compilateur gcc (la même option fonctionne aussi avec MinGW).

gcc -c -Wall -DNDEBUG=1 Sample.c

Remarque : la macro NDEBUG est standard en C. Elle signifie No DEBUG : tous les codes relatifs au debug devront donc être supprimés si cette macro est définie.

Les instructions #if #ifdef #ifndef et #endif

Tout d'abord, j'attire votre attention sur le fait que nous avons à faire à différentes syntaxes équivalente : du coup, selon les développeurs considérés, les choses pourront s'exprimer différemment. Dans l'exemple ci-dessous, les deux premières lignes sont strictement équivalentes et il en va de même pour les deux dernières.

#ifdef MACRO_NAME
#if defined MACRO_NAME

#ifndef MACRO_NAME
#if ! defined MACRO_NAME

Remarque : le caractère « ! » correspond ici à l'opérateur de négation. On peut donc lire la dernière ligne ainsi : if not defined.

Voici un exemple concret qui cherche à afficher certains messages que si l'on compile en mode debug.

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

int main() {

    #ifndef NDEBUG
    printf( "Mode DEBUG activé\n" );
    #endif

    printf( "Code de votre application\n" );

    return EXIT_SUCCESS;
}

Voici maintenant un exemple montrant le même programme, mais compilé de deux manières différentes en fonction de si la macro NDEBUG est définie ou non.

$> gcc -o Sample -Wall Sample.c
$> ./Sample
Mode DEBUG activé
Code de votre application
$> gcc -o Sample -Wall -DNDEBUG=1 Sample.c
$> ./Sample
Code de votre application
$> 

Remarque : la valeur à laquelle nous fixons la macro NDEBUG n'est pas réellement nécessaire. Effectivement, l'instruction #ifndef test la non existence de la macro. Comme il est aussi possible de déclarer une macro sans lui fixer de valeur, on aurait pu lancer la compilation ainsi :

$> gcc -o Sample -Wall -DNDEBUG Sample.c

Les instructions #else et #elif

Ces instructions sont, par exemple, très pratique si l'on cherche à compiler du code en fonction d'une plate-forme donnée ou d'une autre. Dans l'exemple ci-dessous, du code spécifique à la plate-forme Microsoft Windows sera produit si la macro WIN32 est définie, sinon du code relatif à la plate-forme Linux sera généré.

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

#ifdef WIN32
    #include <windows.h>
#else
    #include <unistd.h> /* Just for example ; not used here */
#endif

int main() {

    #ifdef WIN32
        MessageBox( NULL, "Hello", "Title", MB_OK );
    #else
        system( "notify-send 'title' 'message'" );
    #endif

    return EXIT_SUCCESS;
}

L'instruction #elif étant, bien entendu, un moyen rapide d'enchainer un #else avec un #if.

Quelques autres instructions

Le préprocesseur C propose encore quelques autres instructions : elles sont moins importantes que celle déjà proposées, mais peuvent néanmoins être utile dans quelques cas.

L'instruction #line

Normalement, le préprocesseur communique au compilateur C le numéro de ligne censé être associé à chaque élément du fichier. Cela est normal, car entre la version du code source que vous vous fournissez en entrée de la chaîne de compilation et ce que le préprocesseur produit, il peut y avoir de très grosses différences. Si les numéros de lignes, en cas d'erreurs ou d'avertissements, vous étaient communiqué par rapport au fichier temporaire (et non par rapport au fichier source), vous auriez de grosse difficultés pour localiser les problèmes dans votre code source (pensez notamment au cas des inclusions (#include) qui s'injecte en plein milieu de votre fichier en modifiant le nombre de lignes totales). Dans l'ensemble d'exemple proposés sur ce site, nous n'avons pas utilisé l'instruction #line : le préprocesseur gérant très bien ces aspects.

Néanmoins, vous aurez peut-être un jour besoin de contrôler ces numéros de lignes. L'instruction #line est faite pour cela. Elle permet de spécifier un nouveau numéro de ligne ainsi qu'un autre éventuel nom de fichier (pour signaler une erreur localisé dans un autre fichier inclus). Dès lors qu'un nouveau numéro de ligne est spécifié, il sera considéré pour la ligne suivante et les lignes qui suivront seront incrémentés par rapport à ce nouveau numéro de ligne. Voici deux formes d'utilisation de cette instruction.

 
#line number
#line number "file_name"

L'instruction #error

L'instruction #error permet de faire afficher un message d'erreur, spécifier à la suite de la ligne.

#error Specify here your error message

L'instruction #pragma

Cette instruction permet de passer des informations additionnelles au compilateur. Par exemple, certains compilateurs acceptent de supprimer certains types d'avertissements qu'ils produisent. Il est important de comprendre que le standard C99 impose la présence du mot clé #pragma dans le langage, par contre il laisse libre les équipes de développement de ces compilateurs d'utiliser cette syntaxe pour leur propre besoins. Si le pragma spécifié n'est pas reconnu par votre compilateur, il sera alors ignoré. L'exemple ci-dessous, montre comment supprimer un type d'avertissement (dans cet exemple, les variables locales non utilisées) avec le compilateur C de la société Microsoft.

#pragma warning( disable : 4101 )



Eclipse/CDT Les types et les variables