Fonctions et structure du programme
Les fonctions décomposent les tâches informatiques volumineuses en tâches plus petites et permettent aux utilisateurs de s'appuyer sur ce que d'autres ont fait au lieu de recommencer à zéro. Les fonctions appropriées cachent les détails de fonctionnement des parties du programme n'ayant pas besoin de les connaître, clarifiant ainsi l'ensemble et facilitant les modifications.
Le langage C a été conçu pour rendre les fonctions efficaces et faciles à utiliser ; les programmes C se composent généralement de nombreuses petites fonctions plutôt que de quelques grandes. Un programme peut résider dans un ou plusieurs fichiers sources. Les fichiers sources peuvent être compilés séparément et chargés ensemble, avec les fonctions précédemment compilées à partir de bibliothèques. Nous n'aborderons cependant pas ce processus ici, car les détails varient d'un système à l'autre.
La déclaration et la définition des fonctions sont le domaine dans lequel la norme ANSI a apporté le plus de changements au langage C. Comme indiquer dans la page Les premiers pas, il est désormais possible de déclarer le type de paramètres lorsqu'une fonction est déclarée. La syntaxe de la déclaration de fonction change également, de sorte que les déclarations et les définitions correspondent. Cela permet à un compilateur de détecter beaucoup plus d'erreurs qu'auparavant. De plus, lorsque les arguments sont correctement déclarés, les coercitions de type appropriées sont effectuées automatiquement.
La norme clarifie les règles sur la portée des noms ; en particulier, elle exige qu'il n'y ait qu'une seule définition de chaque objet externe. L'initialisation est plus générale : les tableaux et les structures automatiques peuvent désormais être initialisés.
Le préprocesseur C a également été amélioré. Les nouvelles fonctionnalités du préprocesseur incluent un ensemble plus complet de directives de compilation conditionnelle, un moyen de créer des chaînes de caractères entre guillemets à partir de paramètres de macro et un meilleur contrôle du processus d'expansion des macros.
Fonctions renvoyant des valeurs non entières
Jusqu'ici, les exemples de fonctions n'ont renvoyé aucune valeur (void) ou un int. Que se passe-t-il si une fonction doit renvoyer un autre type ? De nombreuses fonctions numériques comme sqrt, sin et cos renvoient double; d'autres fonctions spécialisées renvoient d'autres types. Pour illustrer comment gérer cela, écrivons et utilisons la fonction atof(s), convertissant la chaîne de caractères s en son équivalent à virgule flottante double précision. atof est une extension de atoi, dont nous avons montré des versions dans les pages Types, opérateurs et expressions et Contrôle du flux. Elle gère un signe et un point décimal facultatifs, ainsi que la présence ou l'absence d'une partie ou d'une partie fractionnaire. La version n'est pas une routine de conversion d'entrée de haute qualité ; cela prendrait plus de place que nous ne le souhaitons. La bibliothèque standard inclut un atof ; l'entête <stdlib.h> le déclare.
Tout d'abord, atof lui-même doit déclarer le type de valeur qu'il renvoie, car il ne s'agit pas d'un int. Le nom du type précède le nom de la fonction :
- #include <ctype.h>
- /* atof: convertir la chaîne s en double */
-
- double atof(char s[]) {
- double val, power;
- int i, sign;
- for (i = 0; isspace(s[i]); i++) /* ignorer les espaces blancs */
- ;
- sign = (s[i] == '-') ? -1 : 1;
- if (s[i] == '+' || s[i] == '-') i++;
- for (val = 0.0; isdigit(s[i]); i++) val = 10.0 * val + (s[i] - '0');
- if (s[i] == '.') i++;
- for (power = 1.0; isdigit(s[i]); i++) {
- val = 10.0 * val + (s[i] - '0');
- power *= 10;
- }
- return sign * val / power;
- }
Deuxièmement, et tout aussi important, la routine appelante doit savoir que atof renvoie une valeur non-int. Une façon de s'en assurer est de déclarer atof explicitement dans la routine appelante. La déclaration est affichée dans cette calculatrice primitive (à peine suffisante pour équilibrer un chéquier), lisant un nombre par ligne, éventuellement précédé d'un signe, et les additionne, en affichant la somme courante après chaque entrée :
La déclaration :
dit que sum est une variable double et que atof est une fonction prenant un paramètre char[] et renvoie un double.
La fonction atof doit être déclarée et définie de manière cohérente. Si atof lui-même et son appel dans main ont des types incohérents dans le même fichier source, l'erreur sera détectée par le compilateur. Mais si (comme c'est plus probable) atof était compilé séparément, l'incompatibilité ne serait pas détectée, atof renverrait un double que main traiterait comme un int, et des réponses dénuées de sens en résulteraient.
A la lumière de ce que nous avons dit sur la façon dont les déclarations doivent correspondre aux définitions, cela peut paraître surprenant. La raison pour laquelle une incompatibilité peut se produire est que s'il n'y a pas de prototype de fonction, une fonction est implicitement déclarée par sa première apparition dans une expression, comme :
- sum += atof(line)
Si un nom n'ayant pas été déclaré précédemment apparaît dans une expression et est suivi d'une parenthèse gauche, il est déclaré par le contexte comme étant un nom de fonction, la fonction est supposée renvoyer un int et rien n'est supposé sur ses paramètres. De plus, si une déclaration de fonction n'inclut pas de paramètres, comme dans :
cela signifie également que rien ne doit être supposé sur les paramètres de atof ; toute vérification des paramètres est désactivée. Cette signification particulière de la liste de paramètres vide est destinée à permettre aux anciens programmes C de compiler avec les nouveaux compilateurs. Mais c'est une mauvaise idée de l'utiliser avec de nouveaux programmes C. Si la fonction prend des paramètres, déclarez-les ; si elle n'en prend aucun, utilisez void.
Étant donné atof, correctement déclaré, nous pourrions écrire atoi (convertir une chaîne de caractères en int) en termes de celui-ci :
Notez la structure des déclarations et de l'instruction de retour. La valeur de l'expression dans :
return expression; |
est convertie au type de la fonction avant que le retour ne soit effectué. Par conséquent, la valeur de atof, un double, est automatiquement convertie en int lorsqu'elle apparaît dans ce retour, puisque la fonction atoi renvoie un int. Cette opération peut toutefois potentiellement ignorer des informations, c'est pourquoi certains compilateurs en avertissent. Le cast indique explicitement que l'opération est voulue et supprime tout avertissement.
Variables externes
Un programme C est constitué d'un ensemble d'objets externes, qui sont soit des variables, soit des fonctions. L'adjectif «externe» est utilisé par opposition à «interne», décrivant les paramètres et les variables définis à l'intérieur des fonctions. Les variables externes sont définies en dehors de toute fonction et sont donc potentiellement disponibles pour de nombreuses fonctions. Les fonctions elles-mêmes sont toujours externes, car C ne permet pas de définir des fonctions à l'intérieur d'autres fonctions. Par défaut, les variables et fonctions externes ont la propriété que toutes les références à elles par le même nom, même à partir de fonctions compilées séparément, sont des références à la même chose. (La norme appelle cette propriété « liaison externe ».) En ce sens, les variables externes sont analogues aux blocs COMMON de Fortran ou aux variables du bloc le plus externe de Pascal. Nous verrons plus tard comment définir des variables et des fonctions externes qui ne sont visibles que dans un seul fichier source. Comme les variables externes sont accessibles globalement, elles fournissent une alternative aux arguments de fonction et aux valeurs de retour pour la communication de données entre les fonctions. Toute fonction peut accéder à une variable externe en s'y référant par son nom, si le nom a été déclaré d'une manière ou d'une autre.
Si un grand nombre de variables doivent être partagées entre les fonctions, les variables externes sont plus pratiques et efficaces que de longues listes de paramètres. Comme indiqué dans la page Les premiers pas, cependant, ce raisonnement doit être appliqué avec une certaine prudence, car il peut avoir un effet négatif sur la structure du programme et conduire à des programmes avec trop de connexions de données entre les fonctions.
Les variables externes sont également utiles en raison de leur portée et de leur durée de vie plus grandes. Les variables automatiques sont internes à une fonction ; elles apparaissent lorsque la fonction est activée et disparaissent lorsqu'elle est désactivée. Les variables externes, en revanche, sont permanentes, de sorte qu'elles peuvent conserver des valeurs d'une invocation de fonction à l'autre. Ainsi, si deux fonctions doivent partager des données, mais qu'aucune n'appelle l'autre, il est souvent plus pratique que les données partagées soient conservées dans des variables externes plutôt que d'être transmises en entrée et en sortie via des paramètres.
Examinons ce problème avec un exemple plus large. Le problème consiste à écrire un programme de calculatrice fournissant les opérateurs +, -, * et /. Comme il est plus facile à implémenter, la calculatrice utilisera la notation polonaise inversée au lieu de l'infixe. (La notation polonaise inversée est utilisée par certaines calculatrices de poche et dans des langages de programmation comme Forth et Postscript.)
Dans la notation polonaise inversée, chaque opérateur suit ses opérandes ; une expression infixe comme :
(1 - 2) * (4 + 5) |
est entré comme :
1 2 - 4 5 + * |
Les parenthèses ne sont pas nécessaires; la notation est sans ambiguïté tant que nous savons combien d'opérandes chaque opérateur attend.
L'implémentation est simple. Chaque opérande est poussé sur une pile ; lorsqu'un opérateur arrive, le nombre approprié d'opérandes (deux pour les opérateurs binaires) est extrait, l'opérateur leur est appliqué et le résultat est repoussé sur la pile. Dans l'exemple ci-dessus, par exemple, 1 et 2 sont poussés, puis remplacés par leur différence, -1. Ensuite, 4 et 5 sont poussés puis remplacés par leur somme, 9. Le produit de -1 et 9, qui est -9, les remplace sur la pile. La valeur en haut de la pile est extraite et affichée lorsque la fin de la ligne d'entrée est atteinte.
La structure du programme est donc une boucle effectuant l'opération appropriée sur chaque opérateur et opérande tel qu'il apparaît :
TANT QUE (prochaine opérateur ou opérande n'est pas un indicateur de fin de fichier) SI nombre ALORS Empile-le SINON SI opérateur ALORS Dépile l'opérande Faire l'opération Empile le résultat SINON SI (nouvelle ligne) Depile et et affiche le haut de la pile SINON Erreur FIN TANT QUE |
Les opérations de poussée et de dépilement d'une pile sont triviales, mais au moment où la détection et la récupération d'erreurs sont ajoutées, elles sont suffisamment longues pour qu'il soit préférable de placer chacune d'elles dans une fonction distincte plutôt que de répéter le code dans tout le programme. Et il devrait y avoir une fonction distincte pour récupérer l'opérateur ou l'opérande d'entrée suivant.
La principale décision de conception n'ayant pas encore été discutée concerne l'emplacement de la pile, c'est-à-dire les routines y accédant directement. Une possibilité est de la conserver dans main et de transmettre la pile et la position actuelle de la pile aux routines la poussant et la font sortir. Mais main n'a pas besoin de connaître les variables contrôlant la pile ; elle n'effectue que des opérations de poussée et de dépilement. Nous avons donc décidé d'entreposer la pile et ses informations associées dans des variables externes accessibles aux fonctions de poussée et de dépilement mais pas à main.
La traduction de ce schéma en code est assez simple. Si pour l'instant nous pensons que le programme existe dans un fichier source, il ressemblera à ceci :
- Les #include
- Les #define
- déclarations de fonctions pour main :
- main() { ... }
- variables externes pour push et pop
- routines appelées par getop
On verra plus tard comment cela peut être divisé en deux ou plusieurs fichiers sources.
La fonction main est une boucle contenant un gros commutateur sur le type d'opérateur ou d'opérande ; il s'agit d'une utilisation plus courante du commutateur que celle présentée dans la section suivante :
- #include <stdio.h>
- #include <stdlib.h> /* pour atof() */
-
- #define MAXOP 100 /* taille maximale de l'opérande ou de l'opérateur */
- #define NUMBER '0' /* signaler qu'un numéro a été trouvé */
-
- int getop(char []);
- void push(double);
-
- double pop(void);
-
- /* calculatrice polonaise inversée */
- main() {
- int type;
- double op2;
- char s[MAXOP];
- while ((type = getop(s)) != EOF) {
- switch (type) {
- case NUMBER:
- push(atof(s));
- break;
- case '+':
- push(pop() + pop());
- break;
- case '*':
- push(pop() * pop());
- break;
- case '-':
- op2 = pop();
- push(pop() - op2);
- break;
- case '/':
- op2 = pop();
- if (op2 != 0.0) push(pop() / op2);
- else printf("erreur: diviseur de zéro\n");
- break;
- case '\n':
- printf("\t%.8g\n", pop());
- break;
- default:
- printf("erreur: commande inconnu %s\n", s);
- break;
- }
- }
- return 0;
- }
Étant donné que + et * sont des opérateurs commutatifs, l'ordre dans lequel les opérandes extraits sont combinés n'a pas d'importance, mais pour - et /, les opérandes gauche et droit doivent être distingués. Dans :
- push(pop() - pop()); /* MAUVAIS */
l'ordre dans lequel les deux appels de pop sont évalués n'est pas défini. Pour garantir le bon ordre, il est nécessaire de faire un pop de la première valeur dans une variable temporaire comme nous l'avons fait dans main :
- #define MAXVAL 100 /* profondeur maximale de la pile de valeurs */
-
- int sp = 0; /* prochaine position de pile libre */
- double val[MAXVAL]; /* pile de valeur */
-
- /* push: pousser f sur la pile de valeurs */
- void push(double f) {
- if (sp < MAXVAL) val[sp++] = f;
- else printf("erreur : pile pleine, impossible de pousser %g\n", f);
- }
-
- /* pop : dépile et renvoie la valeur supérieure de la pile */
- double pop(void) {
- if (sp > 0) return val[--sp];
- else {
- printf("erreur : pile vide\n");
- return 0.0;
- }
- }
Une variable est externe si elle est définie en dehors de toute fonction. Ainsi, la pile et l'index de pile qui doivent être partagés par push et pop sont définis en dehors de ces fonctions. Mais main elle-même ne fait pas référence à la pile ou à la position de la pile - la représentation peut être masquée.
Passons maintenant à l'implémentation de getop, la fonction récupérant l'opérateur ou l'opérande suivant. La tâche est facile. Ignorez les espaces et les tabulations. Si le caractère suivant n'est pas un chiffre ou un point hexadécimal, renvoyez-le. Sinon, collectez une chaîne de chiffres (qui peut inclure un point décimal) et renvoyez NUMBER, le signal qu'un nombre a été collecté.
- #include <ctype.h>
-
- int getch(void);
- void ungetch(int);
-
- /* getop: obtenir le caractère suivant ou l'opérande numérique */
- int getop(char s[]) {
- int i, c;
- while ((s[0] = c = getch()) == ' ' || c == '\t')
- ;
- s[1] = '\0';
- if (!isdigit(c) && c != '.') return c; /* pas un numéro */
- i = 0;
- if (isdigit(c)) /* collecter la partie entière */
- while (isdigit(s[++i] = c = getch()))
- ;
- if (c == '.') /* collecter une partie fractionnaire */
- while (isdigit(s[++i] = c = getch()))
- ;
- s[i] = '\0';
- if (c != EOF) ungetch(c);
- return NUMBER;
- }
Que sont getch et ungetch ? Il arrive souvent qu'un programme ne puisse pas déterminer s'il a lu suffisamment d'entrées jusqu'à ce qu'il en ait lu trop. Un exemple est la collecte de caractères composant un nombre : jusqu'à ce que le premier non-chiffre soit vu, le nombre n'est pas complet. Mais alors le programme a lu un caractère de trop, un caractère pour lequel il n'est pas préparé.
Le problème serait résolu s'il était possible de «dé-lire» le caractère indésirable. Ensuite, chaque fois que le programme lit un caractère de trop, il pourrait le renvoyer sur l'entrée, de sorte que le reste du code pourrait se comporter comme s'il n'avait jamais été lu. Heureusement, il est facile de simuler la dé-lecture d'un caractère, en écrivant une paire de fonctions coopérantes. getch fournit le prochain caractère d'entrée à prendre en compte ; ungetch les renverra avant de lire une nouvelle entrée.
Leur fonctionnement est simple. ungetch place les caractères repoussés dans un tampon partagé -- un tableau de caractères. getch lit à partir du tampon s'il y a autre chose, et appelle getchar si le tampon est vide. Il doit également y avoir une variable d'index enregistrant la position du caractère actuel dans le tampon.
Étant donné que le tampon et l'index sont partagés par getch et ungetch et doivent conserver leurs valeurs entre les appels, ils doivent être externes aux deux routines. Ainsi, nous pouvons écrire getch, ungetch et leurs variables partagées comme suit :
- #define BUFSIZE 100
-
- char buf[BUFSIZE]; /* tampon pour la récupération */
- int bufp = 0; /* prochaine position libre dans buf */
-
- int getch(void) { /* obtenir un caractère (éventuellement repoussé) */
- return (bufp > 0) ? buf[--bufp] : getchar();
- }
-
- void ungetch(int c) { /* repousser le caractère sur la saisie */
- if (bufp >= BUFSIZE) printf("ungetch: trop de caractères\n");
- else buf[bufp++] = c;
La bibliothèque standard inclut une fonction ungetch fournissant un caractère de pushback. On utilise un tableau pour le pushback, plutôt qu'un seul caractère, pour illustrer une approche plus générale.
Règles de portée
Les fonctions et les variables externes composant un programme C n'ont pas besoin d'être toutes compilées en même temps ; le texte source du programme peut être conservé dans plusieurs fichiers et les routines précédemment compilées peuvent être chargées à partir de bibliothèques. Parmi les questions d'intérêt figurent :
- Comment les déclarations sont-elles écrites pour que les variables soient correctement déclarées lors de la compilation ?
- Comment les déclarations sont-elles organisées pour que tous les éléments soient correctement connectés lors du chargement du programme ?
- Comment les déclarations sont-elles organisées pour qu'il n'y ait qu'une seule copie ?
- Comment les variables externes sont-elles initialisées ?
Nous allons aborder ces sujets en réorganisant le programme de calculatrice en plusieurs fichiers. En pratique, la calculatrice est trop petite pour mériter d'être divisée, mais elle illustre bien les problèmes qui se posent dans les programmes plus volumineux.
La portée d'un nom est la partie du programme dans laquelle le nom peut être utilisé. Pour une variable automatique déclarée au début d'une fonction, la portée est la fonction dans laquelle le nom est déclaré. Les variables locales du même nom dans différentes fonctions ne sont pas liées. Il en va de même pour les paramètres de la fonction, étant en fait des variables locales.
La portée d'une variable externe ou d'une fonction s'étend du point où elle est déclarée à la fin du fichier en cours de compilation. Par exemple, si main, sp, val, push et pop sont définis dans un fichier, dans l'ordre indiqué ci-dessus, c'est-à-dire :
alors les variables sp et val peuvent être utilisées dans push et pop simplement en les nommant ; aucune autre déclaration n'est nécessaire. Mais ces noms ne sont pas visibles dans main, pas plus que push et pop eux-mêmes.
D'un autre côté, si une variable externe doit être référencée avant d'être définie, ou si elle est définie dans un fichier source différent de celui où elle est utilisée, alors une déclaration extern est obligatoire.
Il est important de faire la distinction entre la déclaration d'une variable externe et sa définition. Une déclaration annonce les propriétés d'une variable (principalement son type) ; une définition entraîne également la mise de côté d'entreposage. Si les lignes :
apparaissent en dehors de toute fonction, elles définissent les variables externes sp et val, provoquent la mise de côté d'entreposage et servent également de déclarations pour le reste de ce fichier source. D'autre part, les lignes :
déclarent pour le reste du fichier source que sp est un int et que val est un tableau double (dont la taille est déterminée ailleurs), mais ils ne créent pas les variables ni ne leur réservent d'entreposage.
Il ne doit y avoir qu'une seule définition d'une variable externe parmi tous les fichiers composant le programme source; d'autres fichiers peuvent contenir des déclarations extern pour y accéder. (Il peut également y avoir des déclarations extern dans le fichier contenant la définition.) Les tailles de tableau doivent être spécifiées avec la définition, mais sont facultatives avec une déclaration extern.
L'initialisation d'une variable externe ne va qu'avec la définition.
Bien que ce ne soit pas une organisation probable pour ce programme, les fonctions push et pop pourraient être définies dans un fichier, et les variables val et sp définies et initialisées dans un autre. Ces définitions et déclarations seraient alors nécessaires pour les lier :
dans file1 :
dans file2 :
Comme les déclarations externes dans file1 se trouvent avant et en dehors des définitions de fonctions, elles s'appliquent à toutes les fonctions ; un seul ensemble de déclarations suffit pour l'ensemble de file1. Cette même organisation serait également nécessaire si la définition de sp et val suivait leur utilisation dans un seul fichier.
Fichiers d'entête
Considérons maintenant la division du programme de calculatrice en plusieurs fichiers sources, comme cela pourrait être le cas si chacune des composantes était sensiblement plus gros. La fonction principale serait placée dans un fichier, que nous appellerons main.c ; push, pop et leurs variables seraient placées dans un deuxième fichier, stack.c ; getop serait placée dans un troisième, getop.c. Enfin, getch et ungetch seraient placées dans un quatrième fichier, getch.c ; nous les séparons des autres car elles proviendraient d'une bibliothèque compilée séparément dans un programme réaliste.
Il reste encore une chose à prendre en compte : les définitions et les déclarations partagées entre les fichiers. Autant que possible, nous voulons centraliser cela, afin qu'il n'y ait qu'une seule copie à obtenir et à conserver au fur et à mesure de l'évolution du programme. En conséquence, nous placerons ce matériel commun dans un fichier d'entête, calc.h, étant inclus si nécessaire.Le programme résultant ressemble alors à ceci :
calc.h | ||
|
||
main.c | getop.c | stack.c |
|
|
|
getch.c | ||
|
Il y a un compromis à faire entre le souhait que chaque fichier ait accès uniquement aux informations dont il a besoin pour sa tâche et la réalité pratique selon laquelle il est plus difficile de maintenir davantage de fichiers d'entête. Jusqu'à une taille de programme modérée, il est probablement préférable d'avoir un fichier d'en-tête contenant tout ce qui doit être partagé entre deux parties du programme ; c'est la décision que nous avons prise ici. Pour un programme beaucoup plus volumineux, une organisation plus poussée et davantage d'entêtes seraient nécessaires.
Variables statiques
Les variables sp et val dans stack.c, et buf et bufp dans getch.c, sont destinées à l'usage privé des fonctions dans leurs fichiers sources respectifs, et ne sont pas destinées à être consultées par quoi que ce soit d'autre. La déclaration statique, appliquée à une variable ou une fonction externe, limite la portée de cet objet au reste du fichier source en cours de compilation. La déclaration statique externe fournit ainsi un moyen de masquer des noms comme buf et bufp dans la combinaison getch-ungetch, devant être externes pour pouvoir être partagés, mais qui ne doivent pas être visibles pour les utilisateurs de getch et ungetch.
L'entreposage statique est spécifié en préfixant la déclaration normale avec le mot static. Si les deux routines et les deux variables sont compilées dans un seul fichier, comme dans :
alors aucune autre routine ne pourra accéder à buf et bufp, et ces noms n'entreront pas en conflit avec les mêmes noms dans d'autres fichiers du même programme. De la même manière, les variables que push et pop utilisent pour la manipulation de la pile peuvent être masquées, en déclarant sp et val comme étant statiques.
La déclaration statique externe est le plus souvent utilisée pour les variables, mais elle peut également être appliquée aux fonctions. Normalement, les noms de fonction sont globaux, visibles depuis n'importe quelle partie du programme entier. Si une fonction est déclarée statique, cependant, son nom est invisible en dehors du fichier dans lequel elle est déclarée.
La déclaration statique peut également être appliquée aux variables internes. Les variables statiques internes sont locales à une fonction particulière, tout comme les variables automatiques, mais contrairement aux variables automatiques, elles restent en existence plutôt que d'aller et venir à chaque fois que la fonction est activée. Cela signifie que les variables statiques internes fournissent un stockage privé et permanent au sein d'une seule fonction.
Variables de registre
Une déclaration de registre informe le compilateur que la variable en question sera fortement utilisée. L'idée est que les variables de registre doivent être placées dans des registres machine, ce qui peut donner lieu à des programmes plus petits et plus rapides. Mais les compilateurs sont libres d'ignorer ce conseil. La déclaration de registre ressemble à ceci :
et ainsi de suite. La déclaration register ne peut s'appliquer qu'aux variables automatiques et aux paramètres formels d'une fonction. Dans ce dernier cas, elle ressemble à :
En pratique, il existe des restrictions sur les variables de registre, reflétant les réalités du matériel sous-jacent. Seules quelques variables de chaque fonction peuvent être conservées dans des registres, et seuls certains types sont autorisés. Les déclarations de registre en excès sont toutefois inoffensives, car le mot register est ignoré pour les déclarations en excès ou non autorisées. Et il n'est pas possible de prendre l'adresse d'une variable de registre, que la variable soit ou non réellement placée dans un registre. Les restrictions spécifiques sur le nombre et les types de variables de registre varient d'une machine à l'autre.
Structure en blocs
Le C n'est pas un langage de programmation structuré en blocs au sens de Pascal ou de langages similaires, car les fonctions ne peuvent pas être définies dans d'autres fonctions. En revanche, les variables peuvent être définies de manière structurée en blocs dans une fonction. Les déclarations de variables (y compris les initialisations) peuvent suivre l'accolade gauche introduisant toute instruction composée, pas seulement celle commençant une fonction. Les variables déclarées de cette manière masquent toutes les variables portant le même nom dans les blocs extérieurs et restent en existence jusqu'à l'accolade droite correspondante. Par exemple, dans :
La portée de la variable i est la branche «vrai» du if ; ce i n'est lié à aucun i extérieur au bloc. Une variable automatique déclarée et initialisée dans un bloc est initialisée à chaque entrée dans le bloc.
Les variables automatiques, y compris les paramètres formels, cachent également les variables externes et les fonctions du même nom. Étant donné les déclarations :
alors, dans la fonction f, les occurrences de x font référence au paramètre, étant un double ; en dehors de f, elles font référence à l'int externe. Il en va de même pour la variable y.
Par souci de style, il est préférable d'éviter les noms de variables cachant des noms dans une portée externe ; le risque de confusion et d'erreur est trop grand.
Initialisation
L'initialisation a été évoquée à plusieurs reprises jusqu'à présent, mais toujours en marge d'un autre sujet. Cette section résume certaines des règles, maintenant que nous avons discuté des différentes classes d'entreposage.
En l'absence d'initialisation explicite, les variables externes et statiques sont garanties d'être initialisées à zéro ; les variables automatiques et les variables de registre ont des valeurs initiales indéfinies (c'est-à-dire inutiles).
Les variables scalaires peuvent être initialisées lorsqu'elles sont définies, en faisant suivre le nom d'un signe égal et d'une expression :
Pour les variables externes et statiques, l'initialiseur doit être une expression constante ; l'initialisation est effectuée une fois, de manière conceptuelle, avant que le programme ne commence son exécution. Pour les variables automatiques et les variables de registre, l'initialiseur n'est pas limité à être une constante : il peut s'agir de n'importe quelle expression impliquant des valeurs définies précédemment, voire des appels de fonction. Par exemple, l'initialisation du programme de recherche binaire pourrait s'écrire comme suit :
au lieu de
- int low, high, mid;
- low = 0;
- high = n - 1;
En fait, l'initialisation des variables automatiques n'est qu'un raccourci pour les instructions d'affectation. La forme à privilégier est en grande partie une question de goût. Nous avons généralement utilisé des affectations explicites, car les initialiseurs dans les déclarations sont plus difficiles à voir et plus éloignés du point d'utilisation.
Un tableau peut être initialisé en faisant suivre sa déclaration d'une liste d'initialiseurs entre accolades et séparés par des virgules. Par exemple, pour initialiser un tableau days avec le nombre de jours de chaque mois :
- int days[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
Lorsque la taille du tableau est omise, le compilateur calculera la longueur en comptant les initialiseurs, qui sont au nombre de 12 dans ce cas.
S'il y a moins d'initialiseurs pour un tableau que la taille spécifiée, les autres seront nuls pour les variables externes, statiques et automatiques. C'est une erreur d'avoir trop d'initialiseurs. Il n'y a aucun moyen de spécifier la répétition d'un initialiseur, ni d'initialiser un élément au milieu d'un tableau sans fournir également toutes les valeurs précédentes.
Les tableaux de caractères sont un cas particulier d'initialisation ; une chaîne de caractères peut être utilisée à la place de la notation entre accolades et virgules :
- char pattern = "Sylvain";
est un raccourci pour le plus long mais équivalent :
- char pattern[] = { 'S', 'y', 'l', 'v', 'a', 'i', 'n', '\0' };
Dans ce cas, la taille du tableau est de huit (sept caractères plus le « \0 » de fin).
Récursivité
Les fonctions C peuvent être utilisées de manière récursive ; c'est-à-dire qu'une fonction peut s'appeler elle-même directement ou indirectement. Considérez l'impression d'un nombre sous forme de chaîne de caractères. Comme nous l'avons mentionné précédemment, les chiffres sont générés dans le mauvais ordre : les chiffres de poids faible sont disponibles avant les chiffres de poids fort, mais ils doivent être affichés dans l'autre sens.
Il existe deux solutions à ce problème. L'une consiste à entreposer les chiffres dans un tableau au fur et à mesure de leur génération, puis à les afficher dans l'ordre inverse. L'alternative est une solution récursive, dans laquelle printd s'appelle d'abord pour gérer les chiffres de début, puis affiche le chiffre de fin. Là encore, cette version peut échouer sur le plus grand nombre négatif.
Lorsqu'une fonction s'appelle elle-même de manière récursive, chaque invocation obtient un nouvel ensemble de toutes les variables automatiques, indépendamment de l'ensemble précédent. Dans printd(123), le premier printd reçoit le paramètre n = 123. Il passe 12 à un deuxième printd, qui à son tour passe 1 à un troisième. Le printd de troisième niveau imprime 1, puis revient au deuxième niveau. Ce printd imprime 2, puis revient au premier niveau. Celui-ci affiche 3 et se termine.
Un autre bon exemple de récursivité est quicksort, un algorithme de tri développé par C.A.R. Hoare en 1962. Étant donné un tableau, un élément est choisi et les autres sont partitionnés en deux sous-ensembles - ceux inférieurs à l'élément de partition et ceux supérieurs ou égaux à celui-ci. Le même processus est ensuite appliqué de manière récursive aux deux sous-ensembles. Lorsqu'un sous-ensemble a moins de deux éléments, il n'a pas besoin d'être trié ; cela arrête la récursivité.
Notre version de Quicksort n'est pas la plus rapide possible, mais c'est l'une des plus simples. Nous utilisons l'élément central de chaque sous-tableau pour le partitionnement :
- /* qsort: trier v[gauche]...v[droite] dans l'ordre croissant */
- void qsort(int v[], int left, int right) {
- int i, last;
- void swap(int v[], int i, int j);
- if (left >= right) /* ne rien faire si le tableau contient */
- return; /* moins de deux éléments */
- swap(v, left, (left + right)/2); /* déplacer l'élément de partition */
- last = left; /* to v[0] */
- for (i = left + 1; i <= right; i++) /* partition */
- if (v[i] < v[left]) swap(v, ++last, i);
- swap(v, left, last); /* restaurer l'élément de partition */
- qsort(v, left, last-1);
- qsort(v, last+1, right);
- }
Nous avons déplacé l'opération d'échange dans une fonction swap distincte car elle se produit trois fois dans qsort :
La bibliothèque standard inclut une version de qsort pouvant trier des objets de tout type.
La récursivité peut ne pas permettre d'économiser de l'espace d'entreposage, car une pile des valeurs traitées doit être maintenue quelque part. Elle ne sera pas non plus plus rapide. Mais le code récursif est plus compact et souvent beaucoup plus facile à écrire et à comprendre que son équivalent non récursif. La récursivité est particulièrement pratique pour les structures de données définies de manière récursive comme les arbres.
Le préprocesseur C
Le langage C fournit certaines fonctionnalités au moyen d'un préprocesseur, étant théoriquement une première étape distincte de la compilation. Les deux fonctionnalités les plus fréquemment utilisées sont #include, pour inclure le contenu d'un fichier lors de la compilation, et #define, pour remplacer un jeton par une séquence arbitraire de caractères. Les autres fonctionnalités décrites dans cette section incluent la compilation conditionnelle et les macros avec paramètres.
Inclusion de fichiers
L'inclusion de fichiers facilite la gestion des collections de définitions et de déclarations (entre autres). Toute ligne source de la forme :
#include "filename" |
ou
#include <filename> |
est remplacé par le contenu du fichier filename. Si le filename est entre guillemets, la recherche du fichier commence généralement là où le programme source a été trouvé ; s'il n'est pas trouvé là, ou si le nom est entouré de < et >, la recherche suit une règle définie par l'implémentation pour trouver le fichier. Un fichier inclus peut lui-même contenir des lignes #include.
Il y a souvent plusieurs lignes #include au début d'un fichier source, pour inclure les instructions #define courantes et les déclarations externes, ou pour accéder aux déclarations de prototype de fonction pour les fonctions de bibliothèque à partir d'entêtes comme <stdio.h>. (À proprement parler, il n'est pas nécessaire que ce soient des fichiers ; les détails de la façon dont les en-têtes sont accessibles dépendent de l'implémentation.)
#include est la méthode préférée pour lier les déclarations ensemble pour un programme volumineux. Elle garantit que tous les fichiers sources seront fournis avec les mêmes définitions et déclarations de variables, et élimine ainsi un type de bogue particulièrement désagréable. Naturellement, lorsqu'un fichier inclus est modifié, tous les fichiers en dépendant doivent être recompilés.
Substitution de macros
Une définition a la forme :
#define name replacement text |
Cela nécessite une substitution de macro du type le plus simple - les occurrences ultérieures du nom de jeton seront remplacées par le replacement text. Le nom dans un #define a la même forme qu'un nom de variable ; le texte de remplacement est arbitraire. Normalement, le texte de remplacement est le reste de la ligne, mais une longue définition peut être continuée sur plusieurs lignes en plaçant un \ à la fin de chaque ligne à continuer. La portée d'un nom défini avec #define s'étend de son point de définition à la fin du fichier source en cours de compilation. Une définition peut utiliser des définitions précédentes. Les substitutions ne sont effectuées que pour les jetons et n'ont pas lieu dans des chaînes entre guillemets. Par exemple, si YES est un nom défini, il n'y aura pas de substitution dans printf("YES") ou dans YESMAN.
Tout nom peut être défini avec n'importe quel texte de remplacement. Par exemple :
- #define forever for (;;) /* boucle infinie */
définit un nouveau mot, pour toujours, pour une boucle infinie.
Il est également possible de définir des macros avec des paramètres, de sorte que le texte de remplacement peut être différent pour différents appels de la macro. Par exemple, définissez une macro appelée max :
- #define max(A, B) ((A) > (B) ? (A) : (B))
Bien que cela ressemble à un appel de fonction, une utilisation de max se transforme en code en ligne. Chaque occurrence d'un paramètre formel (ici A ou B) sera remplacée par le paramètre réel correspondant. Ainsi la ligne :
- x = max(p+q, r+s);
sera remplacé par la ligne :
- x = ((p+q) > (r+s) ? (p+q) : (r+s));
Tant que les paramètres sont traités de manière cohérente, cette macro servira pour n'importe quel type de données ; il n'est pas nécessaire d'avoir différents types de max pour différents types de données, comme ce serait le cas avec les fonctions.
Si vous examinez le développement de max, vous remarquerez quelques pièges. Les expressions sont évaluées deux fois, ce qui est mauvais si elles impliquent des effets secondaires comme des opérateurs d'incrémentation ou des entrées et sorties. Par exemple :
- max(i++, j++) /* MAUVAIS */
incrémentera la plus grande deux fois. Il faut également prendre certaines précautions avec les parenthèses pour s'assurer que l'ordre d'évaluation est préservé ; considérez ce qui se passe lorsque la macro :
- #define square(x) x * x /* MAUVAIS */
est appelé comme square(z+1).
Néanmoins, les macros sont précieuses. Un exemple pratique vient de <stdio.h>, dans lequel getchar et putchar sont souvent définis comme des macros pour éviter la surcharge d'exécution d'un appel de fonction par caractère traité. Les fonctions dans <ctype.h> sont également généralement implémentées comme des macros.
Les noms peuvent être indéfinis avec #undef, généralement pour s'assurer qu'une routine est vraiment une fonction, pas une macro :
Les paramètres formels ne sont pas remplacés dans les chaînes entre guillemets. Si, toutefois, un nom de paramètre est précédé d'un # dans le texte de remplacement, la combinaison sera développée en une chaîne de caractères entre guillemets avec le paramètre remplacé par le paramètre réel. Cela peut être combiné avec la concaténation de chaînes de caractères pour créer, par exemple, une macro d'affichage de débogage :
- #define dprint(expr) printf(#expr " = %g\n", expr)
Lorsque cela est appelé, comme dans :
- dprint(x/y)
la macro est étendue en :
- printf("x/y" " = \n", x/y);
et les chaînes de caractères sont concaténées, donc l'effet est :
- printf("x/y = \n", x/y);
Dans le paramètre réel, chaque " est remplacé par \" et chaque \ par \\, de sorte que le résultat est une constante de chaîne de caractères légale.
L'opérateur de préprocesseur ## fournit un moyen de concaténer des paramètres réels lors de l'expansion de la macro. Si un paramètre dans le texte de remplacement est adjacent à un ##, le paramètre est remplacé par le paramètre réel, le ## et les espaces blancs environnants sont supprimés et le résultat est réanalysé. Par exemple, la macro paste concatène ses deux paramètres :
- #define paste(front, back) front ## back
donc paste(name, 1) crée le jeton name1.
Les règles d'utilisation imbriquée de ## sont obscures.
Inclusion conditionnelle
Il est possible de contrôler le prétraitement lui-même avec des instructions conditionnelles qui sont évaluées pendant le prétraitement. Cela permet d'inclure du code de manière sélective, en fonction de la valeur des conditions évaluées pendant la compilation.
La ligne #if évalue une expression entière constante (qui peut ne pas inclure de constantes sizeof, casts ou enum). Si l'expression est différente de zéro, les lignes suivantes jusqu'à un #endif ou #elif ou #else sont incluses. (L'instruction de préprocesseur #elif est comme else-if.) L'expression defined(name) dans un #if est 1 si le nom a été défini, et 0 sinon. Par exemple, pour s'assurer que le contenu d'un fichier hdr.h n'est inclus qu'une seule fois, le contenu du fichier est entouré d'une condition comme celle-ci :
- #if !defined(HDR)
- #define HDR
- /* le contenu de hdr.h va ici */
- #endif
La première inclusion de hdr.h définit le nom HDR ; les inclusions suivantes trouveront le nom défini et passeront à #endif. Un style similaire peut être utilisé pour éviter d'inclure des fichiers plusieurs fois. Si ce style est utilisé de manière cohérente, chaque entête peut lui-même inclure tous les autres entêtes dont il dépend, sans que l'utilisateur de l'en-tête n'ait à gérer l'interdépendance.
Cette séquence teste le nom SYSTEM pour décider quelle version d'un entête inclure :
- #if SYSTEM == SYSV
- #define HDR "sysv.h"
- #elif SYSTEM == BSD
- #define HDR "bsd.h"
- #elif SYSTEM == MSDOS
- #define HDR "msdos.h"
- #else
- #define HDR "default.h"
- #endif
- #include HDR
Les lignes #ifdef et #ifndef sont des formes spécialisées testant si un nom est défini. Le premier exemple de #if ci-dessus aurait pu être écrit :
- #ifndef HDR
- #define HDR
- /* le contenu de hdr.h va ici */
- #endif