Tout sur le langage C++ (episode 11)

Publié le par Missa Dioma

Autres éléments :

Dans sa partie publique, la classe ios comprend aussi une énumération seek_dir de trois éléments ios::beg, ios::cur, ios::end, qui sont utilisés dans les changements de position (voir plus loin pour les flots de sortie et les flots d’entrée).

On trouve encore les quatre méthodes suivantes qui permettent, si vous créez votre propre système d’entrées-sorties, d’ajouter des champs personnels à la classe :

 static long bitalloc(); static int xalloc(); long & iword(int); void* & pword(int); 

La première indique le premier bit libre dans le champ de format sous la forme d’un masque. La seconde crée un champ utilisateur de type int, et renvoie un numéro ; ce numéro doit être réutilisé dans la troisième et la quatrième pour obtenir ce champ utilisateur sous sa forme int ou comme un pointeur.

Enfin on peut associer un flot de sortie à une instance de ios, à l’aide de la méthode ostream* tie(ostream*) ; le flot de sortie courant peut être obtenu par ostream* tie(void). Cela permet par exemple de définir un canal d’erreur. Ce champ n’est pas utilisé par les implantations standard de la classe ios.

Précisons enfin que s’il existe un constructeur public ios::ios(streambuf&), qui associe le tampon au flot, le constructeur par défaut ios::ios() et l’opérateur d’affectation ios::operator=(ios&) sont déclarés privés et de surcroît non définis, ce qui interdit la recopie d’une instance de ios dans une autre ; une telle copie serait en effet probablement erronée, voire catastrophique.

Flots de sortie : classe ostream :

La classe fondamentale des flots de sortie est ostream. Elle dérive de ios de manière publique et virtuelle :

 class ostream : public virtual ios { ... 

On y trouve un constructeur ostream::ostream(streambuf*) (qui associe le tampon au flot) et un destructeur virtuel, comme dans ios. Comme dans ios encore, l’opérateur d’affectation et le constructeur de copie n’y sont pas utilisables, car ils ne sont pas redéfinis (et comme ils ne sont pas accessibles dans ios, on ne peut utiliser ceux par défaut).

Les flots de sortie sont retardés, c’est-à-dire que les données prennent place dans le tampon jusqu’à ce qu’il soit plein ou jusqu’à la fermeture du flot. Pour forcer celui-ci à écrire ces données tout de suite, il suffit d’appeler la méthode ostream& flush(void) (la valeur renvoyée est le flot lui-même).

Une instance de ostream occupe 38 octets en mémoire.

Changement de position :

Un flot de sortie pointant sur un fichier ou une organisation du même genre possède un indicateur de position. Cet indicateur marque l’emplacement de la prochaine lecture ; il avance à chaque écriture du nombre de caractères écrits.

On peut connaître la valeur de cet indicateur de position par la fonction membre streampos tellp(void) ; le type streampos est identique à long.

Il y a deux moyens de modifier cet indicateur, autrement qu’en faisant des écritures. Le premier consiste à appeler la méthode ostream& seekp(streampos) avec la nouvelle valeur souhaitée. Le second consiste à donner un déplacement par rapport à une position de référence (type streamoff, qui est aussi égal à long). On utilise pour cela ostream& seekp(streamoff, seek_dir). Le type seek_dir est l’énumération de ios décrite précédemment et contenant trois éléments :

  • ios::beg : référence = début du fichier

  • ios::cur : référence = position courante

  • ios::end : référence = fin du fichier.

Selon les cas, le déplacement est ajouté à 0, à la position courante, ou au nombre de caractères du fichier pour obtenir la nouvelle position. Par exemple :

 ofstream fl; // ... fl.seekp(-10, ios::cur); 

fait reculer l’indicateur de position de dix caractères.

Écriture non formatée :

Les flots de sortie permettent une écriture non formatée, c’est-à-dire sans examen des caractères, bien adaptée aux fichiers binaires par exemple.

La fonction membre ostream& put(char) écrit un caractère dans le flot de sortie. La fonction membre ostream& write(const char*, int n) (qui en fait existe en deux versions pour caractères signed et unsigned) écrit n caractères dans le flot. Dans les deux cas, les champs de formats ne sont pas utilisés, et le tampon n’est pas vidé, sauf s’il est plein.

Écriture formatée :

L’opérateur << est redéfini pour les flots de sortie sous la forme d’une méthode ostream& operator<<(type) pour tous les types prédéfinis (y compris unsigned char* et signed char* pour les chaînes de caractères), ainsi que void* (pour écrire la valeur d’un pointeur) et même streambuf* (pour prendre les caractères d’un autre tampon et les écrire).

Nous avons déjà utilisé bien des fois cet opérateur avec cout. Comme il renvoie une référence sur le flot courant, on peut chaîner les écritures comme ceci :

 cout << i << " ce jour " << d << 'n'; 

ce qui équivaut à :

 cout.operator<<(i).operator<<("ce jour"). operator<<(d).operator<<('n'); 

et donc à l’appel de quatre fonctions différentes.

Les paramètres de formatage sont utilisés ici pleinement (voir le paragraphe correspondant sur la classe ios).

Nouvelles sorties :

Lorsqu’on écrit une nouvelle classe d’objets, il peut être très intéressant de pouvoir les écrire de la même façon que d’autres. Rien n’est plus facile, il suffit de définir un opérateur << adapté. Par exemple, avec la classe fraction que nous avons définie au chapitre 7, il suffit d’écrire :

 class fraction { int num, den; public : // ... friend ostream& operator<<(ostream& os, fraction f) { return os << f.num << '/' << f.den; } }; 

et le tour est joué. Noter que la déclaration n’est pas identique à celle des précédents opérateurs <<, qui étaient des membres de la classe ostream. Cependant, cela n’a pas d’importance, l’effet reste le même.

Flots d’entrée : classe istream :

La classe istream, utilisée pour les flots d’entrée, dérive de manière virtuelle et publique de ios ; comme ostream, elle ne possède qu’un constructeur iostream:: iostream(streambuf*).

Une instance de istream occupe 40 octets en mémoire.

Changement de position :

Un flot d’entrée a aussi un indicateur de position qui peut être lu par la méthode streampos tellg(void) ; cette méthode ne porte pas le même nom que dans les flots de sortie, car il peut exister indépendamment un indicateur d’entrée et un de sortie (flots mixtes, voir ci-après).

L’indicateur de position peut être modifié par istream& seekg(streampos) et par istream& seekg(streamoff, seek_dir), de la même façon que pour les flots de sortie.

Lecture non formatée :

Une lecture non formatée est possible dans un flot d’entrée, via les méthodes istream& get(char&) et sa variante int get(void) pour un caractère unique. Pour une série de caractères, on utilisera l’une des méthodes suivantes :

 istream& get(char*, int max, char = 'n'); istream& read(char*, int max); istream& getline(char*, int max, char = 'n'); 

Toutes ces méthodes existent en fait en deux versions, pour signed char et unsigned char. La fonction get à trois arguments lit une série de caractères et les place dans un tableau ; elle s’arrête soit quand le nombre maximal indiqué est dépassé, soit quand le caractère final (de valeur par défaut 'n') est rencontré (ou encore si elle arrive en fin de fichier). Un caractère nul final est ajouté. La fonction getline a le même effet sans troisième argument ; avec un troisième argument différent de 'n', elle s’arrête lorsqu’elle rencontre le caractère final précisé ou la fin de la ligne 'n'. Enfin la fonction read lit un bloc de caractères de longueur indiquée sans aucun formatage.

Il existe aussi une fonction membre istream& get(streambuf&, char = 'n') qui prend ses données dans un autre tampon.

Réinsertion :

Il est possible de lire le prochain caractère sans le sortir du tampon d’entrée en utilisant la fonction membre int peek(void). On peut aussi savoir combien de caractères il reste dans le tampon (sans formats) par int gcount(void).

Lorsqu’un caractère a été retiré du tampon par erreur, il est possible de l’y replacer en utilisant istream& putback(char).

Lecture formatée :

L’opérateur >> est redéfini pour les flots d’entrée sous la forme istream& operator>>(type&), pour tous les types prédéfinis, et sous la forme istream& operator>>(char*) pour les chaînes de caractères.

L’effet obtenu est le suivant, en fonction du type de l’opérande :

  • Pour les entiers short, int et long, signés ou non, les espaces (blancs, tabulations, etc.) sont sautés et le signe éventuel puis les chiffres sont lus jusqu’à rencontrer un caractère autre qu’un chiffre ; les préfixes sont acceptés comme dans les constantes de C++ : les nombres commençant par 0 sont lus en octal, et les nombres commençant par 0x ou 0X sont lus en hexadécimal. Ce comportement peut toutefois être modifié en spécifiant une entrée décimale, octale ou hexadécimale obligatoire à l’aide du champ de format (voir exemple ci-après). Les suffixes entiers U, L et UL ne sont pas acceptés. Lorsque le nombre de chiffres entrés est important, le résultat est obtenu modulo 65536 (pour les entiers courts ; pour les longs, modulo le carré de 65536).

  • Pour les nombres à virgule flottante, les espaces initiaux sont sautés, et le nombre est lu conformément aux règles d’écriture des nombres à virgule flottante jusqu’à ce qu’un caractère soit incorrect. Lorsque la valeur rentrée est supérieure à la valeur maximale possible, c’est cette dernière qui est placée dans la variable.

  • Pour les caractères, les espaces initiaux sont sautés (pour éviter cela, utiliser get) et un caractère unique est lu (attention avec cin il faut tout de même taper un retour chariot après pour finir l’entrée).

  • Pour les chaînes de caractères char*, les espaces initiaux sont sautés, et les caractères suivants placés dans la chaîne jusqu’à la rencontre d’un caractère d’espacement ; un zéro final est ajouté. Pour éviter un débordement de la chaîne, il est recommandé d’utiliser le champ de largeur en le positionnant avec width ; en effet, la valeur par défaut de zéro implique une lecture sans limite absolue.

Dans tous les cas, si la fin de fichier est rencontrée en cours de lecture, rien n’est placé dans la variable et le bit failbit est positionné.

Comme le résultat est une référence sur le flot d’entrée courant, on peut chaîner les lectures. Voici quelques exemples :

 int i, j; float f; char chaine[40]; cin >> i >> j; // si vous écrivez : 145 789634 // alors i devient 145 et j 3202 == 789634 % 65536 cin.setf(ios::hex, ios::basefield); cin >> i; // si vous écrivez : 07a89 // alors i devient 0x7A89 cin.setf(ios::dec, ios::basefield); cin >> i; // si vous écrivez : 077 // alors i devient 77 (et non la valeur octale 077 égale à 63) cin >> d; // si vous écrivez : 125.89e-14 // alors d devient 1.2589E-12 cin.width(39); // n’oubliez pas le zéro final cin >> chaine; // si vous écrivez : "Bonjour !" // alors la chaine devient ""Bonjour" (8 caractères  // plus zéro final), car la lecture s’arrête au // premier espace rencontré 

La suppression des espaces initiaux, notamment pour les lectures de chaînes de caractères, peut être invalidée en mettant à 0 le bit ios::skipws du champ de format.

On peut évidemment aussi redéfinir l’opérateur d’entrée pour les nouvelles classes.

Exercice 9.1 :

Définir un opérateur d’entrée pour la classe fraction du chapitre 7.

Solution de l’exercice 9.1 :

On suppose que la fraction est entrée sous la forme num/den, où num et den sont deux entiers :

 istream& operator>>(istream& is, fraction& f) { int i, j; is >> i; if (!is) return is; char c; is >> c; // a-t-on une barre (/) ? if (c != '/') { // non f.num = i; f.den = 1; // f vaut i/1 return is.putback(c); // remettre en place } is >> j; if ( (is) && (j) ) // si tout est ok { f.num = i; f.den = j; } return is; } 

Cette fonction doit être déclarée amie de la classe fraction.

Flots mixtes : classe iostream :

La classe iostream est utilisée lorsqu’on souhaite faire à la fois des lectures et des écritures. Elle hérite tout simplement de ostream et istream, et sa définition est très simple :

 class iostream : public istream, public ostream { public: iostream(streambuf*); virtual ~iostream(); protected: iostream(); }; 

Le constructeur par défaut est protégé, comme dans ostream et istream, de sorte qu’il n’est pas possible de déclarer une instance sans l’initialiser avec un tampon streambuf*, sauf pour les classes descendantes (voir iostream_withassign ci-après).

Les deux opérateurs >> et << restent bien entendu disponibles, ainsi que tous les autres membres.

Une instance de cette classe occupe 44 octets de mémoire.

Flots prédéfinis :

Nous avons dit qu’il n’est pas possible d’écrire une assignation d’une instance de ostream vers une autre, et qu’il en est de même pour istream et iostream.

Les classes ostream_withassign, istream_withassign et iostream_withassign se distinguent de leur homologues en ce que l’opérateur d’affectation y est redéfini. On peut ainsi affecter un ostream à une instance de ostream_withassign, etc.

Voici par exemple comment est définie ostream_withassign :

 class ostream_withassign : public ostream { public: ostream_withassign(); virtual ~ostream_withassign(); ostream_withassign& operator= (ostream&); ostream_withassign& operator= (streambuf*); }; 

Le constructeur par défaut ne fait rien ; l’opérateur d’affectation avec un argument streambuf* est sensiblement identique au constructeur équivalent de ostream.

Si ces classes existent, c’est que c’est généralement une erreur de recopier un flot dans un autre : en particulier, si deux flots partagent le même fichier sur disque, des dégâts risquent de survenir. Le fait de déclarer un flot _withassign indique clairement que l’on souhaite faire une telle copie. Cela ne pose pas de problèmes avec les flots prédéfinis, qui sont au nombre de quatre :

ostream_withassign cout

Comme on le sait déjà, ce flot envoie ses sorties à l’écran.

istream_withassign cin

Ce flot prend ses entrées au clavier.

ostream_withassign cerr

Flot d’erreur. Par défaut identique à cout.

ostream_withassign clog

Flot d’erreur mais avec un tampon.

Il est parfaitement possible de modifier ces flots. Par exemple, pour envoyer les messages d’erreur vers un fichier error.msg, il suffit d’écrire :

 ofstream ferr("ERROR.MSG"); if (ferr) // si on a pu ouvrir le fichier... cerr = ferr; 

Les classes _withassign occupent la même place mémoire que leurs homologues de base.

Flots sur disques et fichiers

Les lectures et écritures sur disques sont évidemment un aspect essentiel des entrées-sorties. On dispose pour cela de quatre classes fstreambase, ifstream, ofstream et fstream équivalant à ios, istream, ostream et iostream respectivement. Ces quatre classes sont définies dans le fichier <fstream.h>, qu’il faut donc inclure dans votre programme si vous souhaitez les utiliser (cela inclut automatiquement <iostream.h>).

Le tampon utilisé par ces classes est de type filebuf, qui est une dérivation de streambuf adaptée aux fichiers disques. Cette classe se charge notamment des opérations de bas niveau.

La classe fstreambuf sert surtout de classe de base pour les trois autres. Elle implémente notamment les fonctions open et close décrites ci-après pour les classes dérivées.

La classe ofstream sert pour les fichiers de sortie. Elle comprend essentiellement les méthodes suivantes :

 class ofstream : public fstreambase, public ostream { public: ofstream(); ofstream(const char*, int = ios::out, int = filebuf::openprot); ~ofstream(); void open(const char*, int = ios::out, int = filebuf::openprot); void close(); // héritée de fstreambase en fait }; 

Le constructeur par défaut ne fait rien. Lorsqu’on l’a utilisé, il faut employer la méthode open pour ouvrir le fichier, en donnant son nom complet (avec le chemin d’accès dans le système d’exploitation), et éventuellement un mode d’ouverture (par exemple ios::app si l’on ne veut pas détruire le fichier de départ, mais seulement y ajouter des éléments) ; le troisième paramètre régit le niveau de protection, il n’a pas lieu d’être changé.

Une manière plus rapide d’ouvrir un fichier consiste à employer le constructeur adéquat, ce qui permet déclaration et ouverture simultanément.

La fonction membre close, qui est en fait définie dans fstreambase, ferme le fichier en vidant le tampon. Il ne faut pas oublier de l’appeler, sans quoi des données seraient perdues. Noter toutefois que le destructeur appelle cette fonction.

Les écritures se font comme avec cout ; on notera que par défaut les fichiers sont ouverts en mode texte ; dans ce mode, les caractères 'n' sont transformés en une paire de caractères saut de ligne + retour chariot (sur DOS ou Windows) conformément aux standards texte, et inversement en lecture. Pour éviter de telles transformations catastrophiques sur des fichiers binaires, il faut positionner le bit ios::binary dans le champ de mode (deuxième paramètre de open).

La classe istream est semblable à ostream, sauf que la valeur par défaut du second paramètre de open est ios::in et qu’elle hérite de istream.

Quant à la classe fstream, elle est aussi semblable, sauf que le second paramètre de open n’a pas de valeur par défaut et doit donc être précisé impérativement.

À titre d’exemple, voici une fonction qui recopie un fichier dans un autre :

 int copiefichier(char *dest, char *srce) // copie le fichier srce dans dest // renvoie 1 si ok, 0 sinon { ifstream fi(srce, ios::in|ios::binary); if (!fi) return 0; // srce impossible à lire ofstream fo(dest, ios::out|ios::binary); if (!fo) return 0; char tampon; while ( fo && fi.get(tampon) ) fo.put(tampon); return fo.good() && fi.eof(); } 

En fin de fonction, on teste l’état des fichiers ; normalement, le fichier de sortie doit se trouver dans un état normal (sinon c'est qu’une erreur d’écriture s’est produite), et le fichier d’entrée doit avoir ses bits fail et good positionnés, indiquant un état anormal dû à l’échec de la dernière lecture ; pour vérifier que le lecture est cependant achevée, on utilise la fonction eof. On notera que les fichiers sont automatiquement fermés, puisque le compilateur appelle les destructeurs pour ces objets automatiques.

Les classes fstreambase, ofstream, ifstream et fstream occupent respectivement 74, 78, 80 et 84 octets de mémoire.

Flots en mémoire :

Les flots d’entrées-sorties ne concernent pas que les périphériques. Parfois, il peut être utile de les utiliser en mémoire. Ainsi, si l’on souhaite avoir une chaîne de caractères représentant la valeur d’un entier, il suffit de l’écrire dans un flot en mémoire, puis de lire ce flot comme une chaîne.

Les classes strstreambase, istrstream, ostrstream et strstream sont les homologues en mémoire de ios, istream, ostream et iostream. Elles sont définies dans <strstrea.h> ; vous devez donc inclure ce fichier dans votre programme (cela inclut automatiquement <iostream.h>).

Ces classes utilisent le type de tampon spécial strstreambuf. La classe de base sert uniquement pour les dérivations des trois autres, qui ont l’allure suivante :

 class istrstream : public strstreambase, public istream { public: istrstream(char*;; istrstream(char*, int); ~istrstream(); }; class ostrstream : public strstreambase, public ostream { public: ostrstream(char*, int, int = ios::out); ostrstream(); ~ostrstream(); char* str(); int pcount(); }; class strstream : public strstreambase, public iostream { public: strstream(); strstream(char*, int, int); ~strstream(); char* str(); }; 

Les constructeurs par défaut initialisent les instances sur des chaînes vides. Les autres constructeurs permettent de donner un tampon de mémoire et une taille maximale aux instances ; le troisième paramètre de celui de ostrstream et strstream est le mode d’ouverture.

Les méthodes str donnent simplement le début du tampon mémoire utilisé. La méthode pcount de ostrstream indique le nombre de caractères en attente dans le tampon.

Lorsque les tampons sont créés par les instances (appel des constructeurs par défaut), elles les gèrent entièrement et les augmentent à chaque écriture ; ils sont alors détruits par le destructeur.

Voici par exemple une fonction qui renvoie la chaîne de caractères correspondant à l’écriture d’un nombre à virgule flottante (avec un paramètre optionnel pour le nombre de décimales souhaitées), et une fonction inverse qui renvoie la valeur stockée dans une chaîne :

 char *chainedouble(double d, int precis = -1) { static char tampon[30]; ostrstream os(tampon, 30); if (precis >= 0) os.precision(precis); os << d; return tampon; } double valeurdouble(char *s) { double d; istrstream is(s); is >> d; return d; } 

Les classes strstreambase, istrstream, ostrstream et strstream occupent respectivement 68, 74, 72 et 78 octets en mémoire.

Manipulateurs :

Nous avons vu que l’on pouvait formater de différentes façons les entrées-sorties à l’aide du champ de format, du champ de largeur et du champ de remplissage.

Cependant, des écritures faisant fréquemment intervenir des modifications de ces champs deviennent vite assez lourdes. Pour les simplifier, on dispose de manipulateurs. Ceux qui sont définis dans <iostream.h> sont les suivants :

endl

(sorties) Passe à la ligne et vide le tampon.

ends

(sorties) Insère un caractère nul.

flush

(sorties) Vide le tampon.

dec

Mode décimal.

hex

Mode hexadécimal.

oct

Mode octal.

ws

(entrées) Supprime les espaces.

Pour les employer, il suffit de les écrire sur le flot de la même façon qu’un objet normal, au moment où l’on souhaite changer le mode. Par exemple, l’écriture suivante :

 cout.setf(ios::dec, ios::basefield); cout << i; cout.setf(ios::hex, ios::basefield); cout << j << 'n'; 

sera plus élégante ainsi :

 cout << dec << i << hex << j << endl; 

avec le même effet.

Le fichier <iomanip.h> fournit des manipulateurs supplémentaires prenant des paramètres :

setbase(int)

Fixe la base d’écriture ou de lecture ; les valeurs admises sont 8 (octal), 10 (décimal), 16 (hexadécimal), et 0 qui indique un comportement standard : sorties en décimal sauf pour les pointeurs, entrées suivant le préfixe.

setfill(char)

Fixe le caractère de remplissage.

setprecision(int)

Fixe la précision (nombre de décimales en virgule flottante).

setw(int)

Fixe le champ de largeur width.

resetiosflags(long)

Met à zéro dans le champ de forme les bits qui sont à 1 dans le paramètre.

setiosflags(long)

Met à 1 dans le champ de forme les bits qui sont à 1 dans le paramètre.

On pourra donc écrire par exemple :

 int i = 32; cout << setfill('*') << setw(9) << hex << i; // écrit : *******20 double d = 1/3.141592; cout << setprecision(3) << d; // écrit : 0.318 

Nous terminons ce chapitre avec un exercice assez difficile, où le lecteur pourra exercer sa sagacité...

Exercice 9.2 :

Sans aller regarder dans les fichiers d’en-têtes, comment implanteriez-vous ces manipulateurs afin de permettre de telles écritures ? Indication : c’est beaucoup plus facile pour les manipulateurs sans paramètres

Solution de l’exercice 9.2 :

Une première possibilité consiste à définir un type spécial struct manip par exemple, et de redéfinir les opérateurs pour ce type afin d’avoir l’effet souhaité ; les manipulateurs seraient alors des constantes de ce type. malheureusement cela conduit à « fermer » le processus, en ce sens qu’il est alors impossible de définir de nouveaux manipulateurs.

Une méthode plus astucieuse est utilisée en réalité. Elle consiste à noter qu’il est parfaitement possible de passer un argument de type « pointeur sur fonction » à un opérateur. Voici donc par exemple comment sont implantés les manipulateurs endl, ends et flush sur les flots de sorties :

 class ostream : virtual public ios { // ... ostream& operator<< (ostream& (*f)(ostream&)) { return (*f)(*this); } // ... }; ostream& endl(ostream& os) { os << 'n' // nouvelle ligne os.flush() // vider le tampon return *this; } ostream& ends(ostream& os) { os << '' // caractère nul return *this; } ostream& flush(ostream& os) { os.flush() // vider le tampon return *this; } 

Les manipulateurs dec, hex et oct agissent sur la classe ios simplement en changeant le bit adéquat dans le champ de forme. Le manipulateur ws agit sur istream également en positionnant le bit adéquat. Il est tout à fait possible de définir ses propres manipulateurs sur ce modèle.

Les manipulateurs avec paramètres sont nettement plus complexes, mais le principe de base est le même. Il faut cependant utiliser une classe intermédiaire dans ce cas. Voici, en simplifiant, une implantation de setw :

 class smanip { ios& (*fn)(ios&, int); int ag; public: smanip(ios& (*f)(ios&, int), int a) { fn = f; ag = a; } friend istream& operator>>(istream& s, smanip& f) { (*f.fn)(s, f.ag); return s; } friend ostream& operator<<(ostream& s, smanip& f) { (*f.fn)(s, f.ag); return s; } }; ios& setw_fonc(ios& io, int w) { io.width(w); return io; } smanip setw(int w) { return smanip(setw_fonc, w); } 

Le comportement est en fait un peu plus complexe, et de plus le fichier <iomanip.h> est rendu pratiquement illisible par l’emploi de macros et de classes génériques.

10/ PREPROCESSEUR , EDITEUR DE LIENS ET FICHIERS MULTIPLES:

Jusqu’à présent, nous avons utilisé des exemples très courts qui tenaient parfaitement dans un seul fichier. En réalité, à partir de quelques centaines de lignes de code, il devient rentable d’utiliser plusieurs fichiers. Le langage fournit divers moyens pour cela, que nous allons étudier à présent. Nous examinons aussi le préprocesseur, qui travaille avant le compilateur.

Enfin nous détaillons l’usage de macros (héritage du C), et expliquons pourquoi elles sont largement obsolètes en C++.

Le préprocesseur :

La compilation d’un programme se déroule en trois phases. La première est exécutée par le préprocesseur, elle vise à remplacer les directives de compilation par du texte C++ normal. La seconde est la compilation proprement dite. La troisième est l’édition de liens.

Le préprocesseur recherche dans une ligne des macros pour les transformer (voir paragraphe suivant), et des directives de compilation ; ces directives commencent par le symbole # et se terminent avec la fin de la ligne :

 #directive [paramètres] 

On peut placer des espaces blancs avant et après la directive mais, contrairement au compilateur, les sauts de lignes et les commentaires ne sont pas considérés comme des blancs par le préprocesseur. Par conséquent, on ne doit pas couper une ligne de directive, ni y placer un commentaire qui pourrait poser problème. Notons qu’il ne faut pas de point-virgule en fin de ligne.

Si la directive ne tient pas sur une seule ligne, il suffit d’écrire le caractère juste avant le saut de ligne ; dans ce cas, la ligne courante est considérée comme la suite de la précédente, la paire + saut de ligne étant ignorée. Ainsi :

 #define CHAINE "Ceci est une très très longue chaîne de caractères" 

sera considérée comme une seule ligne ; rappelons que ceci est vrai aussi du compilateur qui ignore les paires + saut de ligne (chapitre 1).

La directive nulle est constituée d’un symbole # seul sur une ligne ; elle est ignorée.

Nous détaillons ci-après les principales directives de compilation.

Directive d’inclusion #include

Nous avons déjà utilisé la directive d’inclusion. Elle indique au préprocesseur de remplacer la ligne courante par l’ensemble des lignes du fichier nommé en paramètre. On l’utilise essentiellement en pratique pour inclure les en-têtes de librairies (fichiers *.h), comme on le verra plus en détail dans le paragraphe sur l’éditeur de liens.

Il existe trois variantes de la directive d’inclusion, indiquant au préprocesseur comment trouver le fichier à inclure. L’écriture :

 #include <fichier> 

indique au préprocesseur d’aller chercher le fichier dans le ou les répertoires d’inclusion spécialement définis comme tels dans l’environnement du système ou du compilateur. On l’utilise essentiellement pour les fichiers en-têtes fournis avec le compilateur (librairies).

L’écriture :

 #include "fichier" 

indique au préprocesseur de chercher le fichier d’abord dans le répertoire courant, puis éventuellement dans le ou les répertoires d’inclusion. On l’utilise surtout pour les fichiers d’en-têtes faisant partie du projet courant et définis pour lui.

Dans les deux cas, on peut spécifier un chemin d’accès pour le fichier ; les écritures sont alors équivalentes.

Enfin l’écriture :

 #include identificateur 

provoque le remplacement de l’identificateur par la macro de ce nom ; celle-ci doit avoir été définie (voir le paragraphe sur les macros) et correspondre à un nom de fichier correct enclos entre < > ou entre guillemets " ".

Directive d’inclusion #include :

Nous avons déjà utilisé la directive d’inclusion. Elle indique au préprocesseur de remplacer la ligne courante par l’ensemble des lignes du fichier nommé en paramètre. On l’utilise essentiellement en pratique pour inclure les en-têtes de librairies (fichiers *.h), comme on le verra plus en détail dans le paragraphe sur l’éditeur de liens.

Il existe trois variantes de la directive d’inclusion, indiquant au préprocesseur comment trouver le fichier à inclure. L’écriture :

 #include <fichier> 

indique au préprocesseur d’aller chercher le fichier dans le ou les répertoires d’inclusion spécialement définis comme tels dans l’environnement du système ou du compilateur. On l’utilise essentiellement pour les fichiers en-têtes fournis avec le compilateur (librairies).

L’écriture :

 #include "fichier" 

indique au préprocesseur de chercher le fichier d’abord dans le répertoire courant, puis éventuellement dans le ou les répertoires d’inclusion. On l’utilise surtout pour les fichiers d’en-têtes faisant partie du projet courant et définis pour lui.

Dans les deux cas, on peut spécifier un chemin d’accès pour le fichier ; les écritures sont alors équivalentes.

Enfin l’écriture :

 #include identificateur 

provoque le remplacement de l’identificateur par la macro de ce nom ; celle-ci doit avoir été définie (voir le paragraphe sur les macros) et correspondre à un nom de fichier correct enclos entre < > ou entre guillemets " ".

Définition de paramètres par #define

L’écriture suivante :

 #define identificateur 

permet de « définir » un paramètre de nom identificateur qui pourra être utilisé dans une clause #if (voir ci-après). Le nom doit être un identificateur au format normal de C++ : suite de lettres, de chiffres et de caractères de soulignement (_) ne commençant pas par un chiffre. La directive #define sert aussi à la définition de macros (voir paragraphe à ce sujet).

Un identificateur peut au contraire être rendu « indéfini » en utilisant la clause #undef :

 #undef identificateur 

Même s’il n’avait pas été défini auparavant, aucune erreur n’est produite.

Contrôle de compilation par #if

On peut contrôler ce qui sera compilé effectivement ou non, avec une clause adéquate. Si l’on écrit :

 #if condition ..... #endif 

la condition, qui doit être une constante numérique au format normal de C++, est évaluée par le préprocesseur ; si elle est non nulle, la clause #if est ignorée ; si elle vaut zéro, tout ce qui se trouve entre #if et #endif est ignoré (et donc non compilé en particulier).

On peut utiliser dans l’expression le pseudo-opérateur unaire defined qui renvoie 1 si l’identificateur qui le suit est défini (par #define comme indiqué au paragraphe précédent), et 0 sinon. Par exemple, on peut écrire (les parenthèses sont facultatives) :

 #if defined(__cplusplus) && !defined(__IOSTREAM_H) ...... #endif 

L’écriture :

 #if defined(identificateur) 

peut être abrégée en :

 #ifdef identificateur 

De même, l’écriture :

 #if !defined(identificateur) 

peut être abrégée en :

 #ifndef identificateur 

La clause #if peut avoir une clause #else, plus éventuellement des clauses intermédiaires #elif (pour else if). Voici un exemple :

 #ifdef __cplusplus inline void ecrire (char *messg) { cout << messg; } #elif defined(_VIDEO) void ecrire(char *messg) { gotoxy(1, 25); printf("%s", messg); clreoln(); } #else #define ecrire(messg) printf("%s", messg); #endif 

Les clauses de compilation conditionnelles peuvent être imbriquées comme les clauses if en C++.

Constantes prédéfinies :

Quelques constantes sont éventuellement prédéfinies au début de la compilation par le compilateur C++ ; elles peuvent être utilisées dans des clauses de compilation conditionnelles. Leur nom et valeur dépendent du compilateur, du système et de la machine utilisés. Voici par exemple les principales utilisées par Turbo C++ sous MS-DOS :

__cpluplus

Définie si le compilateur est en mode C++. Si on la rend indéfinie, le compilateur repasse en mode C standard, et refuse les déclarations internes, les nouveaux mots réservés, etc.

__MSDOS__

Toujours définie ; indique que le système d’exploitation est MS-DOS.

__DATE__

Date du début de la compilation.

__HEURE__

Heure du début de la compilation.

__FILE__

Nom du fichier courant.

__TURBOC__

Numéro de version de Turbo C++ sous la forme d’une constante hexadécimale : 0x0100 pour la version 1.0, etc.

__STDC__

Définie si la compilation se fait en standard ANSI, non définie sinon (valeur par défaut).

__CDECL__

Indique des formats d’appel de fonctions en C (par opposition au format de Pascal). Son opposé est __PASCAL__.

Messages d’erreur par #error :

La directive #error provoque une erreur de compilation, accompagnée éventuellement d’un message précisé en paramètre. Voici un exemple simple :

 #ifndef __cplusplus #error Ce programme ne fonctionne qu'avec C++ #endif 
Directives particulières #pragma :

Les directives #pragma sont spécifiques à un compilateur particulier. Lorsque la directive est inconnue au compilateur courant, il l’ignore. Nous ne donnons ici que les principales directives de ce type de Turbo C++.

Placée avant une fonction, la directive :

 #pragma argsused 

invalide le message Warning : Parameter 'xxx' is never used, le paramètre 'xxx' n’est jamais utilisé. Elle ne vaut que pour la fonction qui la suit, mais peut être répétée.

La directive :

 #pragma startup fonction [priorité] 

indique au compilateur d’exécuter la fonction de démarrage fonction avant main. Il doit s’agir d’une fonction sans paramètre et sans résultat : void fonction(void). Le numéro de priorité qui suit est facultatif, sa valeur par défaut est 100. Les fonctions de démarrage sont lancées dans l’ordre du plus petit numéro de priorité au plus grand ; ces numéros doivent se trouver entre 64 et 255, les valeurs 0 à 63 étant réservées aux librairies standard.

De manière similaire, la directive :

 #pragma exit fonction [priorité] 

indique au compilateur d’exécuter la fonction de sortie fonction après la fin du programme ; il doit aussi s’agir d’une fonction sans paramètre et sans résultat. Le sens du numéro de priorité est identique. Les fonctions de sortie ne sont pas exécutées si le programme est interrompu par _exit ou abort, mais elles le sont s’il est interrompu par exit ou en cas de terminaison normale (fin de main).

Macros :

La clause #define sert aussi à définir des macros. Il s’agit d’abréviations ou de noms symboliques pour d’autres objets, et elles ont traditionnellement un nom en majuscules. Voici quelques exemples classiques de macros en C :

 #define PI 3.141592 #define ERRMSG "Une erreur s'est produite.n" #define CARRE(x) (x)*(x) 

Le préprocesseur examine chaque ligne de code à la recherche du nom d’une macro ; s’il la trouve, il remplace le nom de la macro par sa valeur. Si la macro a un ou plusieurs paramètres, comme CARRE ci-dessus, ils sont remplacés littéralement par leur valeur effective. Ce processus se poursuit dans une ligne jusqu’à ce qu’il n’y ait plus de noms de macros, de sorte qu’une macro peut en contenir une autre, etc. (mais sans faire de cycle !). Par exemple, l’écriture suivante :

 if ( CARRE(d) > PI) printf(ERRMSG); 

sera transformée ainsi par le préprocesseur :

 if ( (d)*(d) > 3.141592) printf("Une erreur s'est produite.n"); 

Noter que dans le cas de CARRE, les occurrences de x dans la valeur de la macro ont été remplacées littéralement par d. Les occurrences qui se trouveraient dans des chaînes de caractères ne seraient toutefois pas remplacées.

On peut « coller » deux paramètres, ou un paramètre et un identificateur, à l’aide du symbole ##. Ainsi, si l’on écrit :

 #define VAR(x) variable_##x 

les occurrences de VAR(1) par exemple seront remplacées par variable_1.

Enfin, en plaçant un # devant un paramètre, on demande son remplacement par la chaîne de caractères de son nom :

 #define AFFICHE(x) printf("Valeur de " #x " = %dn" , (x) ) 

Noter le caractère de continuation + saut de ligne pour faire une directive plus longue, comme indiqué précédemment. Les occurrences de AFFICHE(index) seront remplacées par :

 printf("Valeur de " "index" " = %dn" , (index) ) 

qui équivaut à :

 printf("Valeur de index = %dn" , (index) ) 
Un outil à employer avec prudence :

Les macros sont la source de nombreuses erreurs très difficiles à repérer, puisqu’on ne dispose pas de la version étendue du code. Par exemple, on peut se demander pourquoi dans la macro CARRE ci-dessus nous avons placé ces parenthèses. Mais si l’on écrit :

 #define CARRE(x) x * x // ... j = CARRE(i+1); 

la dernière ligne deviendra :

 j = i+1 * i+1; 

qui est interprété comme i + (1*i) +1, soit 2*i+1.

Même avec une définition correcte de CARRE, on peut avoir des surprises :

 #define CARRE(x) (x)*(x) // ... int i = 3, j = CARRE(i++); 

en sortie i vaut 5, et non 4, parce que la macro a été étendue sous la forme (i++)*(i++) et provoque deux incrémentations. Ce genre d’erreur est particulièrement ardu à repérer.

D’une façon générale les macros sont dangereuses, car il n’y a aucun contrôle des types ; ainsi, si l’on utilise la macro AFFICHE définie ci-avant avec un paramètre non entier, on risque de sérieux problèmes.

En C++, les macros qui définissent des constantes diverses seront avantageusement remplacées par des déclarations de constantes :

 const Pi = 3.141592; const Errmsg = "Une erreur s'est produite.n"; 

Les macros qui définissent de courtes actions, avec ou sans paramètres, seront remplacées par des fonctions en ligne :

 inline long carre(long l) { return l*l; } inline double carre(double d) { return d*d; } 

qui ne posent pas de problèmes, même avec des effets de bord, et qui vérifient les types de leurs paramètres.

Certaines fonctions en ligne sont d’ailleurs bien plus simples que les macros correspondantes. Essayez d’écrire la macro correspondant à :

 inline void echange(int& i, int& j) { int k = i; i = j; j = k; } inline void echange(long& i, long& j) { long k = i; i = j; j = k; } inline void echange(double& i, double& j) { double k = i; i = j; j = k; } 

Inversement, certaines macros ne peuvent pas être évitées. C’est le cas par exemple de AFFICHE dans la section précédente, qui utilise le nom des variables. On peut toutefois la rendre plus sûre en utilisant les flots de sortie :

 #define AFFICHE(x) cout << "Valeur de " #x " = " << ;; 

qui peut alors fonctionner quel que soit le type de x.

Les classes génériques fournissent un autre exemple d’utilisation pratique des macros en C++.

Publié dans Informatique

Pour être informé des derniers articles, inscrivez vous :
Commenter cet article