Previous Up Next

4  Le préprocesseur

Par commodité le langage C exploite un préprocesseur. Un préprocesseur de texte est un logiciel qui parcourt un fichier source pour le transformer en un autre source, avant de donner ce nouveau source au véritable compilateur. A priori, le préprocesseur utilisé par le langage C est destiné au sources écrites en C, mais aussi en C++ et en Objective C. Cependant il peut être utilisé sur d’autres langages pourvu que ces derniers utilisent des conventions de commentaires et de chaînes de caractères équivalentes à celles du langage C. Il pourrait même être utilisé sur des sources Java, même si ce n’est pas l’usage.

4.1  Substitution de texte, les macros du préprocesseur

Le préprocesseur permet donc de remplacer des symboles d’un fichier source par «autre chose», c’est-à-dire par une chaîne de caractères quelconque que le compilateur traitera en lieu et place du symbole.

Considérez le code source suivant disponible dans le fichier macro1.c (on travaille pour cetet section sur le processeur dans le sous-répertoire prepro/ du dépôt Git) :

extern int putchar(int c);

int main() 
{
    int u=68;
    putchar(u);                 /* premier char */
    putchar(T);                 /* deuxième char */
}

Exercice 31
 (Ceci n’est pas défini)   Pourquoi ce programme ne peut-il pas fonctionner ?

Il est possible d’indiquer au compilateur que le symbole T doit être transformé en autre chose.

Ces symboles qui sont simplement, syntaxiquement, remplacés par autre chose sont appelés des macros.

Il existe deux manières de définir une macro.


Exercice 32
 (Directive de préprocesseur en ligne de commande)   La première solution consiste à indiquer en ligne de commande que l’on souhaite que le préprocesseur remplace un symbole par autre chose :
bash$ gcc macro1.c -D T=65@
Sur cet exemple, on indique au préprocesseur du compilateur C que les symboles T doivent être remplacés par des 65.

Expliquez ce qu’affiche le programme ainsi compilé ?

La seconde solution consiste à utiliser la directive #define. dans le fichier source. À l’instar de la solution précédente, cette directive permet d’indiquer au préprocesseur qu’il doit remplacer un symbole par autre chose. Ajoutez la ligne suivante en tête du fichier source :

#define T (65+1)

Notez qu’il ne s’agit pas d’une instruction du langage C. Elle ne se termine pas par un ;. Elle ne correspond pas à une affectation, il n’y a pas de =... Ici on dit simplement au préprocesseur du compilateur, qu’à partir de maintenant, à chaque fois qu’il rencontre T, il doit le remplacer par (65+1).


Exercice 33
 (Directive #define de préprocesseur)   Expliquez ce qu’affiche le programme ainsi compilé ?

Il est possible de demander un compilateur de n’exécuter que le traitement préprocesseur et de produire le fichier résultant de ce traitement sur la sortie standard (ou dans un fichier). Il s’agit de l’option -E.


Exercice 34
 (Sortie du préprocesseur)   Observez la sortie du préprocesseur. Les lignes qui commencent par un # sont des informations que le préprocesseur transmet au compilateur. Pour cette question, ne les prenez pas en compte. Vous pouvez retirez ces lignes en filtrant la sortie de gcc avec
... | grep -v "#"
qui ne retiendra que les lignes qui ne contiennent pas un #. Comment le préprocesseur a modifié le source ?

Dans les exemples précédents la substitution opère sur ce qui semble être une simple variable. Il n’en est rien. Il s’agit en fait d’une substitution syntaxique, qui n’a aucune valeur sémantique : t n’est pas une variable, elle n’a d’ailleyrs pas étét déclarée, mais un symbole qui a été remplacé par (65+1).

Il est donc possible de remplacer n’importe quel symbole par autre chose, pour vu que ce soit par quelque chose que le compilateur C définisse, comme une fonction, une instruction élémentaire, ou une séquence d’instructions.

Considérez le programme elif.c suivant :

extern int putchar(int c);
extern int getchar(void);

#define DIGIT 'D'
#define LOWER 'L'
#define UPPER 'U'
#define OTHER 'O'

int
main()
{
    int c;
    for (;;) {
        c = getchar();
        if ('0'<= c && c<='9')
            putchar(DIGIT);
        else if ('a'<=c && c<='z')
            putchar(LOWER);
        else if ('A'<=c && c<='Z')
            putchar(UPPER);
        else 
            putchar(OTHER);
    }
    return 0;
}

Exercice 35
 (elif)   Le langage Python, que vous avez étudié depuis la première année de Licence, dispose d’un mot clef du langage : elif qui pourrait être utilisé dans l’exemple précédent. Définissez une macro, c’est-à-dire une directive #define, de telle sorte qu’il soit possible de remplacer les else if du programme précédent pour un elif tel que le langage Python le propose.

Il existe un certain nombre de macros qui sont prédéfinies. elles qui permettent d’instrumenter le code source. Les plus utiles sont les macros implicites suivantes :

__LINE__
donne le numéro de la ligne courante au moment de la compilation ;
__FILE__
donne le nom du fichier courant au moment de la compilation ;
__DATE__
donne la date courante, au moment de la compilation ;
__TIME__
donne l’heure minute seconde courante, au moment de la compilation.

Exercice 36
 (Macros prédéfinies)   En réutilisant votre fonction putdec(), afficher la ligne courante, au début de la fonction main(). Déclarez aussi une variable globale ln qui sera nitialisée par
int ln=__LINE__;
et affichez la valeur de ln dans la fonction main() avant, et après le putdec(). Qu’observez vous ?

Le système de substitution de symbole permet de définir des paramètres de substitution. On parle alors de macros paramétrées. L’exemple suivant illustre cette possibilité (fichier macrop.c) :

extern int putchar(int c);

#define bit(i,j) (i>>j)&1

int main() {
    int i=16;
    int i0=bit(i,0);
    int i4=bit(i,4);
    putchar('0'+i0);
    putchar('0'+i4);
    putchar('\n'); 
    putchar('0'+bit(i,0));
    putchar('0'+bit(i,4));
    putchar('\n'); 
}

Dans ce source macrop.c, la macro bit est paramétrée par deux informations, i et j. Dans une lecture maladroite, on pourrait percevoir cette macro comme une fonction qui prend 2 paramètres (i et j) et qui renvoie la valeur du j-ième bit de i (0 ou 1).

Cependant ce n’est pas le sens de cette ligne de C. Cette ligne doit être comprise comme une indication au préprocesseur pour qu’il remplace, partout dans le code source, les occurrences de bit(x,y) par des (x>>y)&1.


Exercice 37
 (Macros paramétriques)   Compilez et exécuter ce programme macrop.c. Expliquez l’affichage obtenu.

Pour vous aider à mieux comprendre ce qui ce passe exactement, vous pouvez consulter le code produit par le préprocesseur, option -E du compilateur.

4.2  Suppression de lignes de code, la compilation conditionnelle

Le préprocesseur permet aussi de supprimer du fichier source des lignes de codes, selon des conditions qui seront appréciées au moment de la compilation. Il est par exemple possible que le même fichier source soit compiler avec, ou sans, certaines fonctions, selon que des macros soient définies ou pas dans la ligne de commande lors de la compilation.

Le préprocesseur permet de conditionner le fait que certaines portions de codes seront (ou pas) données au compilateur. Le développeur peut par exemple conditionner la compilation d’une portion du code source, en fonction de la valeur d’une macro particulière.

Considérez le programme suivant

extern int putchar(int c);

#if NO_LOG==1

int logchar(int c) {
    return 0;
}

#else

int logchar(int c) {
    return putchar(c);
}

#endif

int main() {
    int i=1;
    i=3*i;
    logchar('0'+i);
    return i;
}

disponible dans le fichier compcond.c (compilation conditionnelle).


Exercice 38
 (Un fichier source pour deux programmes)   Donnez la ligne de compilation pour que la macro NO_LOG produise un code qui va afficher le resultat avant de le retourner. Utilisez l’option -E pour vous assurer que vous avec compiler le code voulu.

Notez que l’expression évaluée par le préprocesseur ne peut porter que sur des constantes et sur des macros. Le préprocesseur ne peut pas apprécier la valeur d’une variable C par exemple.

Simplement le préprocesseur ne «comprends rien» au langage C.

En général nous n’avons pas besoin de tester une valeur particulière mais simple de tester un «oui ou non». Pour cela l’usage consiste à tester l’existence ou non d’une macro et non sa valeur. Les primitives #ifdef MACRO sont alors utilisées.

#ifdef MACRO est validée par le préprocesseur si la macro MACRO est définie, quelque soit sa valeur (et en particulier, même si elle n’a aucune valeur, ce que l’on obtiendrait avec un #define MACRO sans rien de plus...).


Exercice 39
 (Tester l’existence plutôt que la valeur)   Remplacez la compilation conditionnelle du programme précédent de tel sorte que l’affichage n’ai pas lieu lorsque la macro NO_LOG est définie. Vérifiez que votre implémentation fonctionne en faisant :
bash$ gcc compcond.c -D NO_LOG
Notez qu’il n’est plus utile de donner une valeur à la macro NO_LOG puisque seule son existance est testée.

Comparez le résultat avec celui de la compilation obtenue par :

bash$ gcc compcond.c

Considérez maintenant le programme suivant (compchk.c:

#ifndef SIZE
#error "définissez SIZE avec l'option -D SIZE=n"
#define SIZE 0
#endif
#if SIZE & (SIZE-1)
#warning "SIZE devrait être une puissance de 2."
#endif

int main(void)
{
    return SIZE;
}

Exercice 40
 (Erreur de compilation programmée)   Compilez ce programme. Que ce passe-t’il si vous ne définissez pas SIZE sur la ligne de compilation ?

Exercice 41
 (Avertissement de compilation programmé)   Compilez maintenant le programme en définissant une valeur pour la macro SIZE.

Choisissez une valeur qui n’est pas une puissance de 2. Que ce passe-t-il ? Pourquoi ?

Avant de pourusivre avec l’étude des fonctionnalités du préprocesseur, assurez-vous de bien comprendre les quatre premières recommandations de l’encart Bon usage du préprocesseur.

Bon usage du préprocesseur   ...indispensable...

Le préprocesseur permet d’écrire de transformer le code source, et, dans une certaine mesure, de faire évoluer la syntaxe du langage C, de façon assez radicale. Si cet usage a connu son heure de gloire dans les années 1990, il est aujourd’hui largement déprécié. En effet, l’usage intensif du préprocesseur, pour créer de nouveaux «pseudo mots clefs», pose deux problèmes principaux :

Pour mettre en évidence les segments de codes qui seront transformés par le préprocesseur, dans un source C, un certain nombre d’usages se sont progressivement imposés :

  1. les symboles du préprocesseur sont écrits en majuscule : #define MA_MACRO 17
  2. les expressions évaluées sont sur-parenthèsées : #define MA_MACRO (3+i*2) plutôt que 3+i*2...
  3. les macros paramétrées sont dépréciées au profit de fonctions inline du type inline int max(int i,int j) { return (i<j)?i:j; }
  4. les notices précisent toujours les fonctions susceptibles d’être implémentées avec des macros : voir par exemple $ man putc
  5. les #include portent toujours sur des fichiers de prototypes .h et pas sur des fichiers .c;
  6. les fichiers de prototypes ne devraient jamais contenir d’implémentation mais seulement des déclarations (à l’exception des fonctions inline) ;
  7. il est conseillé d’utiliser des include guard : consulter
    https://fr.wikipedia.org/wiki/Include_guard.

4.3  Inclure des fichiers sources, les uns dans les autres

Le préprocesseur du langage C permet d’inclure un fichier dans un autre. C’est le sens de la directive #include "un_fichier".

Lorsque le préprocesseur rencontre cette commande il effectue l’équivalent d’un «copié-collé» de un_fichier en lieu est place du #include "un_fichier". Il est ainsi possible d’inclure un autre fichier source dans un fichier source C.


Exercice 42
 (L’inclusion de fichiers)   Éditez un fichier include_file.c comme suit :
int f(int x)
{
    return 2*x;
}

Puis un fichier main_file.c4

#include "include_file.c"

int main()
{
    return f(3);
}

Exercice 43
 (Un premier #include)   Observez le résultat du traitement du fichier main_file.c par le préprocesseur en utilisant les mêmes options que précédemment. Qu’a fait le préprocesseur ? À quoi correspondent les lignes qui commencent par un # ?

En utilisant les #include il devient possible de mettre des fonctions dans les fichiers que l’on réutilise d’un programme à l’autre. (Cependant le langage C nous invite à ne pas faire usage du préprocesseur en ce sens, mais à plutôt privilégier la création de fichiers objets que nous expérimenterons dans un exercice ultérieur.)

L’inclusion de fichiers est trés utilisées pour éviter d’avoir à redéclarer les fonctions externes que nous avons l’intention d’utiliser.

Précédemment, nous avons de nombreuses fois déclaré la fonction int putchar(int c); qui est implémentée dans une librairie de fonctions standard du langage C. Cette fonction, comme toutes celles de la librarie standard sont très fréquemment utilisées. Aussi il peut être fastidieux, de déclarer chacune d’elles au début de chaque nouveau programme. C’est pourquoi un fichier de déclaration de ces fonctions a été standardisé. Notez bien que ce fichier ne contient pas l’implémentation mais seulement la déclaration des fonctions (c’est-à-dire la ligne e.g. int putchar(c) ; sans le corps de la fonction).


Exercice 44
 (Les fichiers .h)   Trouver le nom du fichier dans lequel la fonction putchar() est déclarée en interrogeant le manuel.

Notez que dans le manuel le fichier a pour extension .h et pas .c. Un fichier .h est un source en langage C au même titre qu’un fichier .c. Cependant l’extension .h a pour vocation d’indiquer que le fichier contient des déclarations, mais pas d’implementation. On parle de fichiers de prototypes. Ils sont donc destinés à être inclus dans d’autres fichiers C mais pas à être compilés pour eux-mêmes.

Vous pouvez observer que votre répertoire courant (celui de vos fichiers sources) ne contient pas le fichier .h mentionné par le manuel. Vous pouvez aussi remarquer que dans le manuel le nom du fichier à inclure n’est pas donné entre guillemets " ... ", mais pas entre chevrons < ... >.

Lorsqu’un fichier est donné entre guillemets, c’est que le préprocesseur doit le trouver dans le repertoire courant (ou qu’il doit éventuellement être trouvé dans des repertoires annexes définis explicitement au compilateur par l’option -I dir).

Lorsqu’un fichier à inclure est donné entre chevrons, c’est qu’il doit être trouvé dans des répertoires annexes définis implicitement par le compilateur. Pour connaitre la liste de ces répertoires définit par gcc vous pouvez faire :

bash$ echo | gcc -E -Wp,-v -

Exercice 45
 (Les #include par défaut)   Modifier le programme précédent pour qu’il affiche le résultat de f(3) sur l’écran avant de sortir. Pour cela utilisez la fonction putchar(), mais au lieu de la déclarer en début de fichier, demandez au préprocesseur d’inclure le fichier indiqué par le manuel.

Vérifiez que votre nouveau programme fonctionne.

Observez le fichier source produit par le préprocesseur. Combien de ligne contient le fichier produit par le préprocesseur ? Comment est déclarée la fonction putchar() ?

Lorsque les programmes deviennent plus complexes, il peut être tentant de mettre des directives #include dans des fichiers .h. Dans ce cas, par défaut, le préprocesseur va inclure, récursivement les fichiers référencés. La norme du langage C précise qu’un compilateur doit pouvoir gérer jusqu’à 16 niveaux d’inclusion (d’un fichier, dans un autre, dans un autre...). gcc en gère jusqu’à 200.

Fonctions inline   ...en savoir plus...

Les fonctions ...

Considérez le petit exemple suivant composé de trois fichiers :

Un fichier abs.h :

inline int abs(int x) { return (x<0)?-x:x; }

Un fichier minmax.h :

#include "abs.h"

inline int min(int x,int y) { return (x+y-abs(x-y))/2; }
inline int max(int x,int y) { return (x+y+abs(x-y))/2; }

Et enfin un dernier fichier guard.c :

#include "minmax.h"
#include "abs.h"

int putchar(int c);

int main() {
    putchar('0' + min(3, 4));
    putchar('0' + max(3, 4));
    putchar('0' + abs(-2));
    return 0;
}

Dans cet exemple, on imagine que les deux fichiers .h ont été conçus par Alice. Bob désire utiliser les fonctions proposées par Alice a écrit le fichier .c indépendamment.

La compilation de guard.c échoue, le compilateur rapporte que la fonction abs() est définie plusieurs fois.


Exercice 46
 (Règle de la simple définition)   Observez le résultat du traitement du préprocesseur sur le fichier guard.c.

Pourquoi Bob transgresse la règle de simple définition ?

Bob pourrait corriger son fichier guard.c. Cependant sa première version est raisonnable et ne devrait pas poser de problème de compilation.

C’est à Alice de fournir, en tant que le développeuse expérimentée, de livrer des fichiers sources qui puissent toujours être utilisés.


Exercice 47
 (#include guard)   Proposez une modifcation des fichiers d’Alice pour que Bob ne rencontre plus de problème de compilation, sans toucher à son programme.

Notez aussi que Alice ne souhaite pas fusionner les deux fichiers .h et qu’elle souhaite bien inclure abs.h dans minmax.h (pour que min() et max() profitent des prochaines évolutions de abs()).


Previous Up Next