Programmation C/Préprocesseur

Un livre de Wikilivres.

Le préprocesseur est un langage de macro qui est analysé, comme son nom l'indique, avant la compilation. En fait, c'est un langage complètement indépendant, il est même théoriquement possible de l'utiliser par dessus un autre langage que le C. Cette indépendance fait que le préprocesseur ignore totalement la structure de votre programme, les directives seront toujours évaluées de haut en bas.

Ces directives commencent toutes par le symbole dièse (#), suivi d'un nombre quelconque de blancs (espace ou tabulation), suivi du nom de la directive en minuscule. Les directives doivent être déclarées sur une ligne dédiée. Les noms standards de directives sont :

  • define : définit un motif de substitution.
  • undef : retire un motif de substitution.
  • include : inclusion de fichier.
  • ifdef, ifndef, if, else, elif, endif : compilation conditionnelle.
  • pragma : extension du compilateur.
  • error : émettre un message d'erreur personnalisé et stopper la compilation.

Variables de substitution et macros[modifier | modifier le wikicode]

Déclarations de constantes[modifier | modifier le wikicode]

Les variables de substitution du préprocesseur fournissent un moyen simple de nommer des constantes. En effet :

#define CONSTANTE valeur

permet de substituer presque partout dans le code source qui suit cette ligne la suite de caractères « CONSTANTE » par la valeur. Plus précisément, la substitution se fait partout, à l'exception des caractères et des chaines de caractères. Par exemple, dans le code suivant :

#define TAILLE 100

printf("La constante TAILLE vaut %d\n", TAILLE);

La substitution se fera sur la deuxième occurrence de TAILLE, mais pas la première. Le préprocesseur transformera ainsi l'appel à printf :

printf("La constante TAILLE vaut %d\n", 100);

Le préprocesseur procède à des traitements sur le code source, sans avoir de connaissance sur la structure de votre programme. Dans le cas des variables de substitution, il ne sait faire qu'un remplacement de texte, comme le ferait un traitement de texte. On peut ainsi les utiliser pour n'importe quoi, des constantes, des expressions, voire du code plus complexe ; le préprocesseur n'ira pas vérifier si la valeur de remplacement est une expression C ou non. Dans l'exemple précédent, nous avons utilisé TAILLE pour remplacer la constante 100, mais toute suite de caractères peut être définie. Par exemple:

#define PREFIXE "erreur:"
#define FORMULE (1 + 1)

printf(PREFIXE "%d\n", FORMULE);

Ici, l'appel à printf sera transformé ainsi :

printf("erreur:" "%d\n", (1 + 1));

Une définition de constantes peut s'étendre sur plusieurs lignes. Pour cela, il faut que le dernier caractère de la ligne soit une barre oblique inverse ('\'). On peut ainsi définir une macro qui est remplacée par un bout de code plus complexe :

#define BLOC_DEFAUT            \
    default:                   \
        puts("Cas interdit."); \
        break;

Cette variable de substitution permet ainsi d'ajouter le cas par défaut à un switch.

Historiquement, les programmeurs avaient pour habitude d'utiliser des capitales pour distinguer les déclarations du préprocesseur et les minuscules pour les noms de symboles (fonctions, variables, champs, etc.) du compilateur. Ce n'est pas une règle à suivre impérativement, mais elle améliore la lisibilité des programmes.

Il est possible de définir plusieurs fois la même « CONSTANTE ». Le compilateur n'émettra un avertissement que si les deux valeurs ne concordent pas.

En fait, dans la section précédente, il était fait mention d'un mécanisme relativement similaire : les énumérations. On peut légitimement se demander ce que ces énumérations apportent en plus par rapport aux directives du préprocesseur. En fait, on peut essentiellement souligner que :

  • Lors des cas multiples (switch), le compilateur peut vérifier que l'ensemble des cas couvre l'intervalle de valeur du type, et émettre un avertissement si ce n'est pas le cas. Ce qui est évidemment impossible à faire avec des #define.
  • Là où l'utilité est plus flagrante, c'est lors du débogage. Un bon débogueur peut afficher le nom de l'élément énuméré, au lieu de simplement une valeur numérique, ce qui rends un peu moins pénible ce processus déjà très rébarbatif à la base, surtout lorsque le type en question est une structure avec des dizaines, pour ne pas dire une centaine, de champs.
  • Certains compilateurs (gcc pour ne citer que le plus connu) n'incluent pas par défaut les symboles du préprocesseur avec l'option standard de débogage (-g), principalement pour éviter de faire exploser la taille de l'exécutable. Si bien que dans un débogueur il est souvent impossible d'afficher la valeur associée à une constante du préprocesseur autrement qu'en fouillant dans les sources. À moins d'avoir un environnement plutôt évolué, cette limitation peut s'avérer très pénible.

Déclarations automatiques[modifier | modifier le wikicode]

Le langage C impose que le compilateur définisse un certain nombre de constantes. Sans énumérer toutes celles spécifiques à chaque compilateur, on peut néanmoins compter sur :

  • __FILE__ (char *) : une chaîne de caractères représentant le nom de fichier dans lequel on se trouve. Pratique pour diagnostiquer les erreurs.
  • __LINE__ (int) : le numéro de la ligne en cours dans le fichier.
  • __DATE__ (char *) : la date en cours (incluant le jour, le mois et l'année).
  • __TIME__ (char *) : l'heure courante (HH:MM:SS).
  • __STDC__ (int) : cette constante est en général définie si le compilateur suit les règles du C ANSI (sans les spécificités du compilateur). Cela permet d'encadrer des portions de code non portables et fournir une implémentation moins optimisée, mais ayant plus de chance de compiler sur d'autres systèmes.

Extensions[modifier | modifier le wikicode]

Chaque compilateur peut définir d'autres macro, pourvu qu'elles restent dans les conventions de nommage que la norme leur réserve. Les lister toutes ici est impossible et hors-sujet, mais on peut citer quelques unes, à titre d'exemple, que le lecteur pourra rencontrer régulièrement dans du code. Il va de soi que le fait qu'elles soient définies ou non, et leur éventuelle valeur, est entièrement dépendant du compilateur.

  • Détection du système d'exploitation :
    • _WIN32 ou __WIN32__ (Windows)
    • linux ou __linux__ (Linux)
    • __APPLE__ ou __MACH__ (Apple Darwin)
    • __FreeBSD__ (FreeBSD)
    • __NetBSD__ (NetBSD)
    • sun ou __SVR4 (Solaris)
  • Visual C++ : _MSC_VER
  • Compilateur gcc :
    • Version : __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__.
  • Compilateurs Borland :
    • __TURBOC__ (version de Turbo C ou Borland C)
    • __BORLANDC__ (version de Borland C++ Builder)
  • Divers : __MINGW32__ (MinGW), __CYGWIN__ ou __CYGWIN32__ (Cygwin),

Déclaration de macros[modifier | modifier le wikicode]

Une macro est en fait une constante qui peut prendre un certain nombre d'arguments. Les arguments sont placés entre parenthèses après le nom de la macro sans espaces, par exemple :

#define MAX(x,y) x > y ? x : y
#define SWAP(x,y) x ^= y, y ^= x, x ^= y

La première macro prend deux arguments et « retourne » le maximum entre les deux. La deuxième est plus subtile, elle échange la valeur des deux arguments (qui doivent être des variables entières), sans passer par une variable temporaire, et ce avec le même nombre d'opérations.

La macro va en fait remplacer toutes les occurrences de la chaîne « MAX » et de ses arguments par « x > y ? x : y ». Ainsi, si on appelle la macro de cette façon :

printf("%d\n", MAX(4,6));

Elle sera remplacée par :

printf("%d\n", 4 > 6 ? 4 : 6);

Il faut bien comprendre qu'il ne s'agit que d'une substitution de texte, qui ne tient pas compte de la structure du programme. Considérez l'exemple suivant, qui illustre une erreur très classique dans l'utilisation des macros :

#define MAX(x,y) x > y ? x : y

i = 2 * MAX(4,6); /* Sera remplacé par : i = 2 * 4 > 6 ? 4 : 6; */

L'effet n'est pas du tout ce à quoi on s'attendait. Il est donc important de bien parenthéser les expressions, justement pour éviter ce genre d'effet de bord. Il aurait mieux fallu écrire la macro MAX de la manière suivante :

#define MAX(x,y) ((x) > (y) ? (x) : (y))

En fait dès qu'une macro est composée d'autre chose qu'un élément atomique (un lexème, ou un token) il est bon de le mettre entre parenthèses, notamment les arguments qui peuvent être des expressions utilisant des opérateurs ayant des priorités arbitraires.

Il est a noter que l'emploi systématique de parenthèses ne protège pas contre tous les effets de bord. En effet :

MAX(x++,y); /* Sera remplacé par : ((x++) > (y) ? (x++) : (y)) */

Du coup, x est incrémenté de 2 et non pas de 1. La qualité et la performance des compilateurs C modernes fait que l'utilisation de fonctions inline est le plus souvent préférable.

Suppression d'une définition[modifier | modifier le wikicode]

Il arrive qu'une macro/constante soit déjà définie, mais qu'on aimerait quand même utiliser ce nom avec une autre valeur. Pour éviter un avertissement du préprocesseur, on doit d'abord supprimer l'ancienne définition, puis déclarer la nouvelle :

#undef symbole
#define symbole nouvelle_valeur

Cette directive supprime le symbole spécifié. À noter que même pour les macros avec arguments, il suffit juste de spécifier le nom de cette macro. Qui plus est, supprimer une variable de substitution inexistante ne provoquera aucune erreur.

Transformation en chaîne de caractères[modifier | modifier le wikicode]

Le préprocesseur permet de transformer une expression en chaîne de caractères. Cette technique ne fonctionne donc qu'avec des macros ayant au moins un argument. Pour transformer n'importe quel argument de la macro en chaîne de caractères, il suffit de préfixer le nom de l'argument par le caractère dièse ('#'). Cela peut être utile pour afficher des messages de diagnostique très précis, comme dans l'exemple suivant :

#define assert(condition) \
if( (condition) == 0 ) \
{ \
        puts( "La condition '" #condition "' a échoué" ); \
        exit( 1 );            \
}

À noter qu'il n'existe pas de mécanisme simple pour transformer la valeur de la macro en chaîne de caractères. C'est le cas classique des constantes numériques qu'on aimerait souvent transformer en chaîne de caractères : le préprocesseur C n'offre malheureusement rien de vraiment pratique pour effectuer ce genre d'opération.

Concaténation d'arguments[modifier | modifier le wikicode]

Il s'agit d'une facilité d'écriture pour simplifier les tâches répétitives. En utilisant l'opérateur ##, on peut concaténer deux expressions :

#define version(symbole) symbole ## _v123

int version(variable); /* Déclare "int variable_v123;" */

Déclaration de macros à nombre variable d'arguments[modifier | modifier le wikicode]

Ceci est une nouveauté du C99. La déclaration d'une macro à nombre variable d'arguments est en fait identique à une fonction, sauf qu'avec une macro on ne pourra pas traiter les arguments supplémentaires un à un. Ces paramètres sont en fait traités comme un tout, via le symbole __VA_ARGS__. Exemple :

#define debug(message, ...) fprintf( stderr, __FILE__ ":%d:" message "\n", __LINE__, __VA_ARGS__ )

Il y a une restriction qui ne saute pas vraiment aux yeux dans cet exemple, c'est que les points de suspension doivent obligatoirement être remplacés par au moins un argument, ce qui n'est pas toujours très pratique. Malheureusement le langage C n'offre aucun moyen pour contourner ce problème pourtant assez répandu.

À noter, une extension du compilateur gcc, qui permet de s'affranchir de cette limitation en rajoutant l'opérateur ## à __VA_ARGS__ :

/* Extension de gcc pour utiliser la macro sans argument */
#define debug(message, ...) fprintf( stderr, __FILE__ ":%d:" message "\n", __LINE__, ##__VA_ARGS__ )

Ces macros peuvent être utilisées par exemple pour des traitements autour de la fonction printf, voir par exemple Variadic Macros anglais.

Exemples[modifier | modifier le wikicode]

Les macro du préprocesseur sont souvent utilisées dans des situations où une fonction, éventuellement inline, aurait le même effet, avec une meilleure robustesse. Cependant, il y a des usages pour lesquels les macros ne peuvent être remplacés par des fonctions.

Par exemple, pour afficher la taille des types C, on écrirait un programme comme le suivant :

#include <stdio.h>

int main(void)
{
    printf("sizeof(char) = %zd.\n", sizeof(char));
    printf("sizeof(short) = %zd.\n", sizeof(short));
    printf("sizeof(int) = %zd.\n", sizeof(int));
    /* ...*/
    return 0;
}

Écrire un tel programme peut être fatiguant, et il est très facile de faire des erreurs en recopiant les lignes. Ici, une fonction ne pourrait simplifier l'écriture. En effet, il faudrait passer à la fonction le nom du type, pour l'afficher dans la chaîne "sizeof(XXX) = ", et la taille. On devrait donc donner deux arguments à la fonction, pour l'appeler ainsi:

print_taille("char", sizeof(char));

Ce qui ne serait pas beaucoup plus simple, surtout que la fonction elle-même serait plus complexe (utilisation de fonctions str* pour inclure le premier paramètre dans la chaîne de format). Ici, le préprocesseur fournit une solution beaucoup plus simple, à l'aide de l'opérateur # :

#include <stdio.h>

#define PRINTF_SIZEOF(type) printf("sizeof(" #type ") = %zd.\n", sizeof(type))

int main(void)
{
    PRINTF_SIZEOF(char);
    PRINTF_SIZEOF(short);
    PRINTF_SIZEOF(int);
    return 0;
}

Une technique similaire peut être utilisée pour conserver à l'affichage le nom de constantes ou d'énumérations :

#include <stdio.h>
enum etat_t { Arret, Demarrage, Marche, ArretEnCours };

#define CASE_ETAT(etat) case etat: printf("Dans l'état " #etat "\n"); break;

void affiche_etat(etat_t etat)
{
  switch (etat)
  {
     CASE_ETAT(Arret)
     CASE_ETAT(Demarrage)
     CASE_ETAT(Marche)
     CASE_ETAT(ArretEnCours)
     default:
         printf("Etat inconnu (%d).\n", etat);
         break;
  }
}

Ces exemples sont tirés d'un message de Bill Godfrey sur comp.lang.c.

Compilation conditionnelle[modifier | modifier le wikicode]

Les tests permettent d'effectuer de la compilation conditionnelle. La directive #ifdef permet de savoir si une constante ou une macro est définie. Chaque déclaration #ifdef doit obligatoirement se terminer par un #endif, avec éventuellement une clause #else entre. Un petit exemple classique :

#ifdef DEBUG
/* S'utilise : debug( ("Variable x = %d\n", x) ); (double parenthésage) */
#define debug(x) printf x
#else
#define debug(x)
#endif

L'argument de la directive #ifdef doit obligatoirement être un symbole du préprocesseur. Pour utiliser des expressions un peu plus complexes, il y a la directive #if (et #elif, contraction de else if). Cette directive utilise des expressions semblable à l'instruction if() : si l'expression évaluée est différente de zéro, elle sera considéré comme vraie, et comme fausse si l'expression s'évalue à 0.

On peut utiliser l'addition, la soustraction, la multiplication, la division, les opérateurs binaires (&, |, ^, ~, <<, >>), les comparaisons et les connecteurs logiques (&& et ||). Ces derniers sont évalués en circuit court comme leur équivalent C. Les opérandes possibles sont les nombres entiers et les caractères, ainsi que les macros elle-mêmes, qui seront remplacés par leur valeur.

À noter, l'opérateur spécial defined qui permet de tester si une macro est définie (et qui renvoit donc un booléen). Exemple :

#if defined(DEBUG) && defined(NDEBUG)
#error DEBUG et NDEBUG sont définis !
#endif

Cette directive est très pratique pour désactiver des pans entier de code sans rien y modifier. Il suffit de mettre une expression qui vaudra toujours zéro, comme :

#if 0
/* Vous pouvez mettre ce que vous voulez ici, tout sera ignoré, même du code invalide */
:-)
#endif

Un autre avantage de cette technique, est que les directives de compilations peuvent être imbriquées, contrairement aux commentaires. Dans ce cas, s'il y avait eu d'autres directives #if / #endif (correctement balancées), la clause #if 0 ne s'arrêterait pas au premier #endif rencontré, tandis que cela aurait été le cas avec des commentaires.

À noter qu'au niveau du préprocesseur il est impossible d'utiliser les opérateurs du C. Notamment l'opérateur sizeof, dont le manque aura fait grincer les dents à des générations de programmeurs, sera en fait interprété comme étant une macro. Il faut bien garder à l'esprit que le préprocesseur C est totalement indépendant du langage C.

Inclusion de fichiers[modifier | modifier le wikicode]

Il s'agit d'une autre fonctionnalité massivement employée dans toute application C qui se respecte. Comme son nom l'indique, la directive #include permet d'inclure in extenso le contenu d'un autre fichier, comme s'il avait été écrit en lieu et place de la directive. On peut donc voir ça comme de la factorisation de code, ou plutôt de déclarations. On appelle de tels fichiers, des fichiers en-têtes (header files) et on leur donne en général l'extension .h.

Il est rare d'inclure du code dans ces fichiers (définitions de variables ou de fonctions), principalement parce que ces fichiers sont destinés à être inclus dans plusieurs endroits du programme. Ces définitions seraient donc définies plusieurs fois, ce qui, dans le meilleur des cas, seraient du code superflu, et dans le pire, pourraient poser des problèmes à l'édition des liens.

On y place surtout des déclarations de type, macros, prototypes de fonctions relatif à un module. On en profite aussi pour documenter toutes les fonctions publiques, leurs paramètres, les valeurs renvoyées, les effets de bords, la signification des champs de structures et les pré/post conditions (quel état doit respecter la fonction avant/après son appel). Dans l'idéal on devrait pouvoir comprendre le fonctionnement d'un module, simplement en lisant son fichier en-tête.

La directive #include prend en fait un argument : le nom du fichier que vous voulez inclure. Ce nom doit être soit mis entre guillemets doubles ou entre balises (< >). Cette différence affecte simplement l'ordre de recherche du fichier. Dans le premier cas, le fichier est recherché dans le répertoire où le fichier contenant la directive se trouve, puis le préprocesseur regarde à des endroits préconfigurés. Dans le second cas, il regardera seulement dans les endroits préconfigurés. Les endroits préconfigurés sont le répertoire include par défaut (par exemple /usr/include/, sous Unix) et ceux passés explicitement en paramètre au compilateur.

En fait l'argument de la directive #include peut aussi être une macro, dont la valeur est un argument valide (soit une chaîne de caractères, soit un nom entre balises < et >) :

#define FICHIER_A_INCLURE <stdio.h>
#include FICHIER_A_INCLURE

Exemple[modifier | modifier le wikicode]

  • fichier.h
void affiche( void );
  • fichier.c
#include "fichier.h"

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

C'est comme si fichier.c avait été écrit :

void affiche( void );

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

Protection contre les inclusions multiples[modifier | modifier le wikicode]

À noter un problème relativement récurrent avec les fichiers en-têtes : il s'agit des inclusions multiples. À mesure qu'une application grandit, il arrive fréquemment qu'un fichier soit inclus plusieurs fois à la suite d'une directive #include. Quand bien même les déclarations sont identiques, définir deux types avec le même nom n'est pas permis en C. On peut néanmoins s'en sortir avec cette technique issue de la nuit des temps :

#ifndef H_MON_FICHIER
#define H_MON_FICHIER

/* Mettre toutes les déclarations ici */

#endif

C'est un problème tellement classique, que le premier réflexe lors de l'écriture d'un tel fichier est de rajouter ces directives. H_MON_FICHIER est bien-sûr à adapter à chaque fichier, l'essentiel est que le nom soit unique dans toute l'application. Habituellement on utilise le nom du fichier, mis en majuscule, avec les caractères non-alphabétiques remplacés par des soulignés.

Avertissement et message d'erreur personnalisés[modifier | modifier le wikicode]

Il peut être parfois utile d'avertir l'utilisateur que certaines combinaisons d'options sont dangereuses ou carrément invalides. Le préprocesseur dispose d'une directive pour effectuer cela:

#error "message d'erreur"

Cette directive affichera en fait le message tel quel, qui peut évidemment contenir n'importe quoi, y compris des caractères réservés, sans qu'on ait besoin de le mettre entre guillemets ("). Après émission du message, la compilation s'arrêtera.

À noter que certains compilateurs comme GCC proposent aussi la directive:

#warning "message d'avertissement"

qui permet d'afficher un message sans arrêter la compilation.