Faire tout et (surtout) n’importe quoi avec le préprocesseur

Bonjour à toutes et à tous !

Je souhaiterais aborder une fonctionnalité du langage C – et du C++ entre autres – que je vois souvent mal comprise et/ou mal utilisée : le préprocesseur. Pour ce faire, je vais détailler les différents mots-clés spécifiques au préprocesseur, en donnant quelques exemples d’utilisation et quelques pièges à éviter.

Mais avant toute chose…

Qu’est-ce que le préprocesseur ?

On désigne généralement par préprocesseur les instructions qui sont exécutées avant la compilation proprement dite. Pour rappel, un compilateur procède en plusieurs étapes pour compiler un programme à partir d’un code source, notamment en procédant à plusieurs analyses pour retirer les commentaires, isoler les mots-clés, vérifier la syntaxe et la sémantique des instructions, puis à la transformation en un code intermédiaire avant de lier le tout en un exécutable ou une bibliothèque.

Contrairement aux instructions du programme en C, les directives de préprocesseur sont exécutées pendant la compilation – et UNIQUEMENT pendant la compilation. Ces directives ne sont pas nombreuses mais permettent beaucoup de choses.

Définir des macros

Lorsqu’on définit une macro en C/C++, on cherche généralement à remplacer un bout de code par un mot-clé plus compréhensible.

Par exemple, la macro suivante permet de définir une macro nommée NB_PATTES_ARAIGNEE pour la remplacer lors de la compilation par la valeur 8.

#define NB_PATTES_ARAIGNEE    8
...
int i = NB_PATTES_ARAIGNEE;

Il y a de nombreux avantages à procéder ainsi :

  • Plutôt que d’utiliser une valeur brute dans un code sans indication claire de ce à quoi elle correspond, nous avons un mot-clé qui explicite la signification de cette valeur, sans devoir rajouter un commentaire à chaque endroit où cette valeur est utilisée ou, pire, laisser cette valeur sans explication au risque de ne pas pouvoir connaître sa signification lors de la relecture ;
  • Si la valeur de cette macro est susceptible de changer, il suffit de la modifier là où elle est définie. On évite ainsi d’avoir à modifier la valeur partout où elle est utilisée.

Il existe une autre façon d’utiliser les macros : les macros fonctions. Le principe reste le même, sauf que cette fois la macro peut recevoir des arguments.

#define ma_macro_fonction(x)    x + 2
...
int i = NB_PATTES_ARAIGNEE;
int n = ma_macro_fonction(i);

Ce code définit une macro fonction qui prend un argument quelconque appelé x puis ajoute 2 à sa valeur. Au moment de la compilation, le code sera remplacé par ceci :

int i = 8;
int n = i + 2;

En effet, le principe d’une macro est qu’elle sera remplacée telle quelle par sa valeur partout dans le code. Une macro fonction représente un gain par rapport à une fonction classique. Là où cette dernière nécessite un espace dédié dans la pile pour y stocker ses arguments et s’exécuter, la macro fonction se contente de placer uniquement les instructions dans le code comme si on les y avait placées nous-mêmes.

Cependant, il s’agit d’une arme à double tranchant. Comme je l’ai dit précédemment, le préprocesseur remplace toute occurrence d’une macro par sa valeur telle quelle.

Je vous invite à jeter un œil au code suivant (il est correct, vous pouvez vérifier), à essayer de deviner les valeurs de i et n lors de leur affichage, puis à les comparer en les vérifiant à l’exécution.

#define ma_macro_fonction(x) i
#define ma_macro_vide(x)

int main() {
  int i = 1;
  int n = 2;

  ma_macro_function(n)++;
  printf("i = %d ; n = %d\n", i, n);

  ma_macro_function(n++);
  printf("i = %d ; n = %d\n", i, n);

  m_macro_vide(n++);
  printf("i = %d ; n = %d\n", i, n);
}

Bizarre, n’est-ce pas ? On peut remarquer plusieurs choses :

  • La première macro fonction n’a pour valeur que le caractère i ;
  • Les deux macros n’utilisent jamais leur argument ;
  • La première macro équivaut à i, il est donc tout à fait possible d’utiliser cette macro fonction – en lui passant n’importe quel argument puisqu’il sera de toute façon ignoré – comme substitut d’une variable i ;
  • Il est possible de définir une macro sans valeur. Ce qui peut avoir son utilité, notamment dans le cas où une fonction n’est pas implémentée mais est dispensable, auquel cas on déclare une macro fonction vide pour retirer simplement les appels à ces fonctions sans avoir à les rechercher.

Bien entendu, le code précédent n’a aucune utilité en soi, c’est juste pour illustrer la trop grande liberté que peut offrir la définition des macros. À la limite, cela pourrait servir pour obscurcir son code (c’est-à-dire le rendre difficilement lisible pour d’autres personnes), soit par esprit de sabotage, soit pour participer à une compétition comme l’IOCCC.

En tout cas, le langage C dispose, depuis C99, des fonctions inline qui permettent en gros d’effectuer la même chose que les macros fonctions, avec la sûreté des fonctions classiques. Dans la mesure du possible, privilégiez ces fonctions-là plutôt que les macros fonctions.

Une autre possibilité est d’utiliser quelques astuces pour protéger l’utilisation des macros. Pour une macro avec une valeur simplement numérique, il est généralement recommandé de définir sa valeur entre parenthèses pour forcer la priorité des opérateurs. En effet, dans l’exemple suivant :

#define MACHIN     2
#define TRUC       6
#define MACHINTRUC MACHIN + TRUC
...
int i = 2 * MACHINTRUC;

i sera égal à 2 * 2 + 6 = 10 au lieu de 2 * (2 + 6) = 16. Il vaut mieux donc placer, au moins, la valeur de MACHINTRUC entre parenthèses comme ceci :

#define MACHINTRUC (MACHIN + TRUC)

On ne peut cependant pas utiliser des parenthèses pour protéger des instructions. Pour cela, il existe une autre astuce, consistant à placer une suite d’instructions dans une structure do { … } while(0) qui aura pour effet d’exécuter une seule fois la série d’instructions avant de sortir de la boucle.

Enfin, pour dé-définir une macro, il existe la directive #undef :

#undef ma_macro

Très pratique pour délimiter la portée d’une macro ou redéfinir une macro déjà définie comme ceci :

#ifdef MACRO_EXISTANTE
#undef MACRO_EXISTANTE
#endif
#define MACRO_EXISTANTE

Les directives #ifdef et #endif servent à tester des conditions, ce que nous allons voir tout de suite.

Vérifier des conditions

Le préprocesseur permet de tester des conditions grâce aux directives #if, #ifdef, #ifndef, #elif, #else et #endif.

Chaque test de condition ouvert par une directive #if, #ifdef ou #ifndef doit se terminer par la directive #endif. #if permet de tester la valeur d’une macro, tandis que #ifdef et #ifndef testent respectivement la définition ou la non définition d’une macro.

#else délimite le comportement du préprocesseur dans le cas où le test de condition initié par #if, #ifdef ou #ifndef échoue. #elif permet la même chose en initiant un autre test de condition (l’équivalent du « sinon si »).

De base, ces directives permettent d’activer une partie du code source si certaines conditions sont réunies. C’est pratique, par exemple, pour assurer la portabilité d’un programme en activant une portion de code qui est propre à une plateforme ou un compilateur spécifique.

#ifdef _WIN32
// Cette portion de code ne s'exécutera que sur un système Windows
#elif defined __unix__ // defined permet la même chose que #ifdef
// Cette portion de code ne s'exécutera que sur un système Unix (Linux, BSD, etc).
#endif

Une variante de cette technique existe pour le cas où du code C est intégré dans du code en C++. Pour éviter que le compilateur C++ traite du code en C comme étant du C++, il convient de délimiter la portion de code en C comme ceci :

#ifdef __cplusplus
extern "C" {
#endif

// Portion de code en C

#ifdef __cplusplus
}
#endif

Ainsi, tout ce qui sera compris dans le bloc

extern "C" { ... }

sera considéré par le compilateur C++ comme étant du C et le compilera donc comme du C.

Un autre usage très courant du test de condition est pour éviter l’inclusion multiple de fichiers, que nous allons voir dans la partie suivante.

Inclure des fichiers

En C/C++, il est d’usage de séparer le code source en plusieurs fichiers : les fichiers sources avec l’extension .c et les fichiers d’entête avec l’extension .h (1). Pour un module dédié à une tâche particulière, par exemple gérer la configuration d’un programme, on séparera ce module en un fichier source config.c qui contiendra la définition des fonctions dédiées à la configuration et un fichier config.h qui contiendra les déclarations de ces fonctions (je dis « fonctions », mais c’est également valable pour des variables, des structures, etc).

Pour qu’un fichier puisse prendre connaissance du contenu d’un fichier .h, il existe une directive de préprocesseur appelée #include. Cette directive indique au préprocesseur qu’il doit rechercher un certain fichier dans les répertoires qu’il connaît et inclure le contenu de ce fichier à l’emplacement de la directive #include. À vrai dire, le préprocesseur ne vérifie pas le nom du fichier qu’il doit inclure – à part pour indiquer par une erreur que ce fichier n’existe pas – il est donc théoriquement possible d’inclure n’importe quel fichier texte – par opposition à un fichier binaire – pour peu que le contenu de ce fichier soit cohérent avec la syntaxe du C/C++. Il est même possible d’inclure un fichier avec l’extension .c.

Cependant, il est possible qu’à la compilation nous ayons des erreurs dues à des inclusions multiples. C’est-à-dire, si le fichier main.c inclue deux fichiers .h (fichier1.h et fichier2.h) mais que chacun de ces deux fichiers inclue un fichier en commun (fichier_commun.h), les fonctions, variables, etc de ce dernier fichier seront déclarées/définies plusieurs fois et le compilateur remontera à une erreur.

Pour éviter cela, la technique généralement utilisée est de définir dans chaque fichier .h une macro propre à ce fichier – généralement à partir du nom du fichier – en prenant soin de vérifier au préalable que cette macro n’a pas déjà été définie.

#ifndef FICHIER_COMMUN_H
#define FICHIER_COMMUN_H
// Tout ce qui est compris ici ne sera activé que si la macro FICHIER_COMMUN_H vient d'être définie
#endif /* FICHIER_COMMUN_H */

Ainsi, quand le préprocesseur inclura ce fichier, cette portion de code ne sera inclue que la première fois.

Générer des messages d’erreur

Il est possible de générer des messages d’erreur avec le préprocesseur en utilisant la directive #error.

Posons le cas d’un test de condition visant à vérifier la portabilité d’un programme. Si ce test de condition échoue, on est possiblement dans une situation où le programme risque de ne pas pouvoir être compilé ou de ne pas fonctionner comme prévu, voire ne pas fonctionner du tout. Face à ce genre de situation, il est avisé de signaler à l’utilisateur que ce programme ne peut pas être compilé et d’interrompre la compilation car il est inutile d’aller plus loin.

Par exemple, si nous avons un programme utilisant des fonctionnalités propres à la norme C99, il faudrait d’abord vérifier que le compilateur est configuré pour compiler selon cette norme. Une telle vérification se présente comme ceci :

#if __STDC_VERSION__ 199901L
#error Ce programme n'est pas supporté par les normes du C antérieures à C99
#endif

Notez que le message n’a pas besoin de guillemets pour être affiché. Tout ce qui figure sur une ligne commençant par #error est considéré comme un message d’erreur à afficher. Le seul moyen d’écrire un message sur plusieurs lignes est de terminer une ligne par un antislash (\) pour indiquer que la ligne courante se poursuit sur la ligne suivante.

Si vous préférez plutôt avertir l’utilisateur d’un risque sans interrompre la compilation, vous pouvez utiliser la directive #warning pour générer un avertissement plutôt qu’une erreur.

#warning Ce programme est susceptible de ne pas fonctionner correctement sur cette plateforme

Conclusion

Le préprocesseur C est un outil puissant à utiliser et largement sous-estimé, le plus souvent car il est mal compris. Le revers de la médaille est qu’il est très facile de faire n’importe quoi avec – c’est également valable pour le C/C++ en général. Il est important de maîtriser le préprocesseur pour l’utiliser de manière utile et sûre.

Certains maîtrisent le préprocesseur au point qu’ils parviennent à réaliser un interpréteur brainfuck écrit uniquement avec des directives du préprocesseur C. Je déconseille de l’utiliser car, comme le dit son créateur :

The preprocessor brainfuck interpreter is very very inefficient and will use around 16 GIGABYTES of memory and 15 to 20 minutes of processing time while running hello.bf.

Traduction : L’interpréteur brainfuck via le préprocesseur est très très inefficace et prendra environ 16 GIGAOCTETS de mémoire et 15 à 20 minutes de temps de calcul pour exécuter hello.bf (NdT: un simple « Hello World! »).

C’est particulièrement ridicule, mais il faut admettre que c’est une bonne performance – sans mauvais jeu de mots.

Je n’ai ici abordé que les directives les plus basiques du préprocesseur. Avec ça, vous avez déjà de quoi faire du code efficace et sécurisé, à la condition que vous l’utilisiez avec prudence. Je vous invite à vous renseigner davantage sur les différentes directives du préprocesseur C – et C++ – pour en apprendre toutes les subtilités.

La directive #pragma mériterait une attention particulière. Je ne l’ai pas abordée ici car je ne la maîtrise pas encore assez pour en parler dans un article de blog et ses possibilités sont bien trop vastes et spécifiques pour être intégrées dans un article qui essaye d’aborder le préprocesseur de manière générale.

Sur ce, je vous laisse et vous dis à bientôt.

Nicolas SAN AGUSTIN

(1) Cette façon de faire n’est pas systématique. L’essentiel du code d’un module figure dans un fichier .c et le fichier .h n’a d’utilité que si l’on souhaite que des fonctions, des variables, des structures, etc de ce module soit accessibles facilement depuis le reste du code sans avoir à les redéclarer dans chaque fichier où elles seront utilisées. Ne soyez donc pas étonnés de voir du code source ne comportant pas de fichier .h pour chacun de ses modules.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *