Le langage C
Septième partie
par Guilhem de Wailly
Dans cette série d'articles, nous présentons le langage C. Il ne s'agit pas de réécrire les ouvrages de référence donnés en annexe, mais de donner, au travers du C, une méthode de programmation basée sur la notion d'interface.
Au fur et à mesure de notre progression, nous décrirons les éléments indispensables du langage et conduirons le lecteur à consulter les ouvrages de références. Le but poursuivi est clairement de parvenir à construire des programmes de manière segmentée.
Dans cet article, nous décrivons le pré processeur du langage C et son mode d'utilisation. Nous verrons comment définir des symboles, des macro-instructions et comment effectuer une compilation conditionnelle.
Le mois dernier, nous sommes parvenus à transformer notre bibliothèque des nombres rationnels de manière à ce quelle soit programmée en plusieurs fichiers.
Pour définir l'interface, nous avons fabriqué un fichier d'entête, ou header, que nous avons inclus dans les différents fichiers à l'aide de la directive #include.
Ce mois-ci, nous allons étudier quel est l'outil qui traite ces directives, et quelles sont les autres directives disponibles.
Le but poursuivit est de séparer l'interface de la bibliothèque du code réalisant cette interface. De cette manière, seule l'interface est publique et le code est privé. Ainsi, on ne s'engage que sur les fonctionnalités de l'interface en se réservant le droit de modifier le code. Ceci permet aux versions successives de la bibliothèque de rester compatibles entre elles du moment qu'elle possède la même interface.
S'il faut changer si souvent la version du système d'exploitation dans certains environnements, c'est que les programmeurs, naïvement, modifient sans cesse leurs interfaces. Ou peut être ces modifications sont imposées par des contraintes moins louables, comme la rentabilité des investissements… En effet, en changeant simplement l'interface, on rend la bibliothèque incompatible avec l'existant, poussant ainsi à la mise à jour. Changer l'interface peut se faire simplement en modifiant l'ordre des arguments des fonctions. Ceci suffit à rendre obsolète tous les appels à cette fonction. Ceci est particulièrement vrai avec les bibliothèques dynamiques (DLL).
Quoi qu'il en soit, on se rend compte que la notion d'interface est la pierre angulaire de la programmation. La conception d'un logiciel passe toujours par l'analyse préliminaire des différentes interfaces de ses composants logiciels. Ceci est vrai quelque soit la méthodologie de programmation, car l'interface peut être spécifiée indépendamment de tout langage de programmation.
L'interface, en C, est représentée par un fichier appelé header et dont l'extension du nom est .h. Il contient les déclarations des fonctions, les déclarations des types de données et des constantes. Les headers sont reliés au programme proprement dit par l'utilisation d'une directive #include.
On pourrait penser que les directives sont traitées par le compilateur C. En réalité, les directives sont traitées par un autre programme appelé pré-processeur. Ce dernier est invoqué par le compilateur de manière transparente à l'utilisateur.
En réalité, la chaîne qui conduit d'un programme en source C jusqu'à un programme binaire exécutable passe par une série de transformations, chacune effectuée par un programme particulier. On appelle cette série de transformation la chaîne de compilation.
Le compilateur gcc fait appel à chacun de ces logiciels. Il est bien sûr possible d'utiliser ces logiciels de manière séparée. Dès que l'on programme une application ayant plusieurs composantes (sources, headers, bibliothèques intermédiaires, …) , il est nécessaire de contrôler la chaîne de compilation. Il est possible d'automatiser les différents traitements en faisant appel à l'utilitaire make que nous décrirons ultérieurement.
Dans cet article, nous allons décrire succinctement le travail du pré-processeur C.
Le pré-processeur C est un programme qui traite les directives du pré-processeur :) dans un programme C. Dans un environnement Unix, ce programme s'appelle cpp. Le compilateur C gcc invoque de manière transparente cpp avant de compiler le programme.
Le langage du pré-processeur est souvent appelé langage de macro-instructions, macro langage ou macro. En C, les macros permettent de substituer des expressions par d'autres expressions, avant que la compilation ne commence.
Les directives du pré-processeur commencent en début de ligne par le caractère dièse (#) et se terminent à la fin de la ligne. Si la directive doit s'écrire sur plusieurs lignes, il faut placer le caractère \ à la fin des lignes intermédiaires.
L'une des directives les plus utilisée est sans doute #define qui s'utilise comme suit :
#define symbole expression
Le symbole est remplacé par l'expression partout ou il apparaît en dessous de sa définition, sauf dans les chaînes de caractères constantes (comme "une chaîne").
L'expression commence après le caractère suivant le symbole, jusqu'à la fin de la ligne.
Voici un exemple de programme utilisant une directive :
#define EOF (-1) void main (void) { ... while (getchar() != EOF) { ... } }
Par convention, les symboles définis par des macros sont écrits en majuscule. Lorsqu'il est traité par le pré-processeur, ce programme devient :
void main (void) { ... while (getchar() != (-1)) { ... } }
Le symbole EOF a été remplacé par son expression (-1) partout où il apparaît.
Pour invoquer le pré-processeur, il faut utiliser gcc dans un mode spéciale introduit avec l'option -E :
$ gcc -E ex.c
Le macro langage permet de faire des choses amusantes :
#define Loop while (1) { #define EndLoop } void main (void) { ... Loop ... EndLoop }
Dans cet extrait, Loop et EndLoop sont remplacés par leur définition, ce qui donne :
procédure p (réel x, réel y, entier n) début si x < y alors tant que f(x) < y répéter y PPV x ... fin fin
Il faut cependant utiliser ce genre de macros avec précautions car le programme devient vite illisible.
Les macro-instructions permettent de paramétrer la substitution par des arguments. Elles s'écrivent de la manière suivante :
#define symbole(a1,a2,...) expression
Il est indispensable que le symbole et la parenthèse ouvrante doivent être accolés. Lors de la substitution, symbole(a1,a2,...) est remplacé par expression dans laquelle toutes les occurrences des arguments formels a1, a2, ... sont remplacées par les arguments effectifs.
Par exemple :
#define carré(a) a * a int x = carré(7) + carré(2);
sera transformé en :
int x = 7 * 7 + 2 * 2;
Dans les macros instructions, il est important de placer les arguments formels entre parenthèses. Considérons :
#define fois2(a) a + a int x = fois2(2) * fois2(3);
Le code produit par le pré-processeur est :
int x = 2 + 2 * 3 + 3
Comme l'opérateur de multiplication est prioritaire sur l'opérateur d'addition, le résultat est 2 + (2 * 3) + 3, c'est à dire 11 au lieu de (2+2)*(3+3), soit 24.
Pour palier à ce problème, il est de règle d'entourer les arguments formels de parenthèses dans l'expression de la macro instruction, ainsi que l'expression elle-même. Cela donne :
#define fois2(a) ((a) + (a)) int x = fois2(2) * fois2(3);
Le code produit par lepré-processeur est :
int x = ((2) + (2)) * ((3) + (3));
Dans ce cas, le résultat de la macro expansion est conforme à ce que nous attendions.
L'inclusion de fichiers en C permet l'utilisation de la notion d'interface.
Généralement, les fichiers à inclure sont des fichiers d'entête dont l'extension est .h. La première directive d'inclusion est :
#include <fichier>
Le pré-processeur remplace la ligne par le contenu du fichier de nom fichier. Le fichier sera recherché dans le répertoire des fichiers d'entêtes standards. Sous Unix, ces répertoires sont en général /usr/include et /usr/X11/include.
Pour ajouter un répertoire standard, on utilise l'option -I du compilateur :
$ gcc -I~/mon-projet un-fichier.c
Le chemin ~/mon-projet sera ajouté dans la liste des répertoires où chercher les fichiers à inclure.
L'autre directive d'inclusion est :
#include "fichier"
Le fichier sera dans ce cas soit recherché à partir du répertoire où se situe le fichier faisant l'inclusion s'il c'est un nom relatif (ne commençant pas par /), soit à partir du répertoire racine s'il s'agit d'un nom absolu.
Le langage du macro processeur permet d'effectuer des tests. La première forme teste si une expression est nulle (fausse) ou non :
#if expression ... #endif
L'expression ne doit faire intervenir que des constantes et des opérateurs arithmétiques simples. Si l'expression est non nulle, les lignes situées entre les directives #if et #endif sont traitées. Elles sont ignorées dans le cas contraire.
Par exemple :
#define x 7 #if x-7 ... #endif
ou bien :
#define 1 1 #if A void f (void) { ... } #endif
Ce test peut être utilisé pour supprimer une portion de code rapidement :
int f (int a) { # if 0 printf ("f appelée avec %d\n", a) ; # endif ... }
Ici, on souhaite supprimer le printf ou le placer de manière rapide, lors de la mise au point, par exemple. Il suffit alors de mettre 0 ou 1 dans le test. On remarque aussi que l'on peut placer des blancs entre le dièse et le début du nom de la macro. Attention de veiller à ce que le dièse soit toujours en début de ligne car certains compilateurs ne reconnaissent les macro que comme cela.
L'autre forme test si un symbole est défini ou pas :
#ifdef symbole ... #endif
ou bien :
#ifndef symbole ... #endif
Dans le premier cas, les lignes situées entre les deux directives sont validées si le symbole est connue du pré-processeur, c'est à dire si une macro a été définie avec comme nom symbole. Le second test active les lignes si le symbole n'est pas défini.
On peut ramener cette forme de test à la précédente en utilisant l'opérateur defined :
#ifdef toto ... #endif
est équivalent à :
#if defined(toto) ... #endif
La différence est qu'il devient alors possible de placer plusieurs tests à la suite :
#if defined(toto) || defined(titi) ... #endif
Dans tous les tests, il est possible de placer une directive #else qui sera activée si la condition n'est pas vérifiée. Lorsque l'on souhaite effectué plusieurs tests à la suite, on utilise #elif :
# if exp1 ... #elif exp2 ... #elif exp3 ... #else ... #endif
Les compilateurs C maintiennent en permanence des symboles prédéfinis. Les deux principaux sont __LINE__ et __FILE__. Le premier est remplacé par le numéro de la ligne sur laquelle il se trouve. Le second est remplacé par le nom du fichier en cours de traitement. Ainsi :
#include <stdio.h> void main (void) { printf ("Numéro de ligne = %d\n", __LINE__); printf ("Fichier = %s\n", __FILE__); }
Affichera :
Numéro de ligne = 5 Fichier = main.c
Le nom du fichier dépend bien sûr du nom dans lequel vous avez enregistré le fichier.
C'est macros sont très utilises pour la mise au point des programmes.
Le compilateur C est capable de générer du code optimisé ou non. Le code non optimisé est assorti avec une table de symboles qui permet de déboguer le programme.
Lorsque le code est compilé en vue d'une utilisation finale, il est nécessaire de définir le symbole NDEBUG avec l'option -D du compilateur. En définissant ce symbole, certaines vérifications ne sont pas effectuées. C'est notamment le cas des assertions qui sont désactivées lorsque NDEBUG est défini.
Une assertion est une vérification que l'on place à certains endroits du programme et qui stoppent le programme si la vérification échoue. Pour utiliser les assertions, il faut inclure le fichier assert.h :
#include <assert.h> int f (int a) { assert (a >3); ... } ... f (4); f (-1); ...
Dans le corps de la fonction f, on place une assertion garantissant que la valeur de a est supérieure à 3. Lors du premier appel à f, l'assertion est vérifiée. Lors du second appel à f, l'assertion n'est pas vérifiée et le programme est interrompu en affichant un message d'erreur de la forme :
main.c: 8:assertion 'a > 3' failled
Les assertions sont actives tant que le symbole NDEBUG n'est pas défini.
Nous verrons dans le prochain numéro comment définir une macro similaire.
Lorsque l'on à affaire à des programmes complexes, il y a plusieurs fichier d'entête à inclure. Chacun des fichiers d'entête peuvent à leur tour inclure d'autres fichiers d'entêtes.
Il se peut alors qu'un fichier soit inclus plusieurs fois. En générale, la seule conséquence est de ralentir le temps de traitement du pré processeur, mais cela peut aussi parfois provoquer des erreurs de compilation.
Pour éviter cette situation, on trouvera souvent dans les fichiers d'entête la construction suivante :
#ifndef __NOM_FICHIER #define __NOM_FICHIER ... #endif
où NOM_FICHIER est remplacé par le nom du fichier. Lors de la première inclusion du fichier, la macro __NOM_FICHIER n'est pas définie et les lignes situées entre le #ifndef et le #endif sont traitées. Notamment, la directive #define __NOM_FICHIER est traitée, ce qui défini le symbole __NOM_FICHIER.
Lors de la seconde inclusion, le test échoue car le symbole __NOM_FICHIER est déjà défini.
Vérifiez cela avec votre distribution Linux et le fichier /usr/include/stdio.h.
L'un des problèmes bien connus en programmation est de produire du code portable entre les différentes plate-formes. En général, le code C est portable, mais il y des différences sur certaines fonctions de bibliothèque ou sur les valeurs limites des entiers.
Le langage C permet de produire du code relativement portable (c'est la raison principale de son succès). Lorsque le code ne peut pas être portable, les macros viennent au secours du programmeur en permettant une compilation conditionnelle.
Par exemple, la fonction main dans l'environnement Windows est beaucoup plus complexe que la fonction main de Unix (ce n'est d'ailleurs pas la seule fonction à être plus complexe. Certains prétendent même que la complexité des interface de Windows est destinée à occuper les développeurs afin qu'ils n'aient plus de temps pour refaire Windows… Apparemment, cette stratégie aurait échoué…). Considérons le code suivant :
#if defined(WINDOWS) int WINAPI WinMain (HINSTANCE h, HINSTANCE p, LPSTR s, int show) { #elif defined(LINUX)||defined(BEOS) int main (void) { #else # include <Erreur: OS non défini> #endif /* corps de la fonction principale */ ... }
Si WINDOWS est défini, le code déclarant WinMain est activé. Si le symbole LINUX ou BEOS est défini, le code déclarant main est activé.
Si aucun des symboles n'est défini, la directive #include est activée. Comme le fichier spécifié n'existe pas, une erreur est affichée (certains pré processeurs, dont celui de gcc, possèdent maintenant la directive #error et #warning).
L'intérêt de la méthode est qu'il est possible de définir des macros sur la ligne de commande du compilateur C en utilisant les options -Dnom ou -Dnom=exp :
$ gcc -DLINUX main.c
Ainsi, on n'a pas besoin de modifier le programme et on active la compilation conditionnelle simplement en modifiant une option sur la ligne de commande.
Guilhem de Wailly (gdw at free dot fr)