Mercredi 21 novembre 2007 3 21 /11 /Nov /2007 13:31

Compatibilité des pointeurs :

Les pointeurs d’une classe de base sont compatibles avec ceux des classes dérivées :

rectangle *pr; rectplein rp, *prp = &rp; pr = prp;            // autorisé

Il n’est pas nécessaire de préciser un changement de type. Par contre, en sens inverse il faut le faire, et l’opération est alors périlleuse, car l’ordinateur risque fort de se planter si l’on fait un appel à une méthode de rectplein avec un pointeur à qui l’on a affecté une valeur rectangle*. On sait de toute façon que les changements de types sur les pointeurs doivent être employés avec prudence.

Contrairement à l’affectation d’une instance de la classe dérivée vers la classe de base, qui fait perdre de l’information (les membres spécifiques à la classe dérivée sont perdus), l’affectation identique avec les pointeurs ne fait rien perdre : les membres dérivés sont simplement momentanément inaccessibles. Les méthodes virtuelles de la classe de base sont cependant appelées correctement. Par exemple, si l’on a défini un destructeur virtuel et deux méthodes virtuelles trace et efface, les appels suivants seront corrects :

rectangle r, *pr = new rectangle(r); pr->trace();        // appel de rectangle::trace delete pr;          // appel de rectangle::~rectangle pr = new rectplein(); pr->trace();        // appel de rectplein::trace delete pr;          // appel de rectplein::~rectplein

Cela explique pourquoi l’on parle de polymorphisme. On retiendra l’importance qu’il y a à déclarer des destructeurs virtuels, même s’ils ne font rien : il n’en sera pas forcément de même dans les classes dérivées, et le compilateur, comme l’exemple ci-dessus le montre clairement, ne peut pas déterminer correctement sur quel genre d’objet pointe pr, et donc quel destructeur appeler s’il n’est pas virtuel. Noter que pour la même raison, l’appel de sizeof(*pr) donnera toujours la taille de la classe de base rectangle, même si pr pointe sur un objet rectplein. On se méfiera de cet opérateur qui ne peut de surcroît pas être redéfini.

Polymorphisme par héritage :

Nous allons réutiliser notre exemple du chapitre 6, où nous avions donné deux implantations différentes d’un type liste reproduisant (extérieurement) une liste chaînée. La représentation interne de ces classes n’était pas forcément une vraie liste chaînée.

Il se peut que dans un même programme on souhaite disposer des deux types de listes chaînées. Une première solution consiste à donner des noms différents aux deux classes (et non identiques comme on l’a fait au chapitre 6), et à utiliser l’une ou l’autre selon les besoins.

Cela pose toutefois des problèmes spécifiques. En effet, si l’on veut par exemple utiliser un tableau de listes (ou plutôt de pointeurs de listes), il faudra choisir un des deux types, et faire constamment des changements de types, avec les risques que cela suppose.

Une méthode bien meilleure consiste à faire dériver l’une des classes de l’autre, comme ceci :

class liste {      noeud* courant;      protected :      int nombre;       public :      liste() { nombre = 0; courant = 0; }      liste(int n, const element*);    // consructeur avec table      virtual ~liste();       virtual void avance(int combien = 1);      void recule(int combien = 1)          { avance(-combien); }       virtual element& valeur(void)          { if (courant) return courant->contenu(); }       unsigned nombre_elt(void) { return  nombre; }      void affiche(unsigned combien = 65535);       virtual int insere(const element&);      virtual void supprime(int n = 1);      };  class listetab : public liste {      element *tab, *courant;       public :      listetab() { courant = tab = 0; }      listetab(int n, const element*);    // consructeur avec table      ~listetab();       void avance(int combien = 1);      element& valeur(void)          { if (courant) return *courant; }       int insere(const element&);      void supprime(int n = 1);      };

On observe d’abord que l’on gagne du temps et du code, puisque certaines méthodes n’ont pas besoin d’être redéfinies ; il suffit généralement de bien choisir les méthodes virtuelles.

Exercice 8.1 :

On a supposé que la méthode d’affichage utilisait la méthode valeur, au lieu de faire référence directement aux membres privés comme c’est le cas dans l’implantation du chapitre 6. Écrire la méthode correspondante.

Solution de l’exercice 8.1 :

Voici une solution simple :

void liste::affiche(unsigned combien) // affiche combien éléments de la liste // (et nombre au maximum) {      if (combien > nombre) combien = nombre;      int reste = nombre -combien;      while (combien--) {          cout << 't' << valeur();          avance();          }      avance(reste);      cout << 'n'; }

On avance à la fin de reste positions pour remettre le début de la liste sur sa valeur de départ.

Exercice 8.2 :

Quelles sont les méthodes virtuelles et celles qui ne le sont pas dans les classes liste et listetab ? Que pensez-vous de ce choix ?

Solution de l’exercice 8.2 :

Outre le destructeur, les méthodes avance, valeur, insere et supprime sont virtuelles. Il s’agit d’un choix évident, ne serait-ce que parce que la classe dérivée ne les implante pas de la même façon ; ce sont manifestement des opérations qui dépendent tout à fait du type de liste implémentée. Les méthodes recule et nombre_elt, vu leur extrême simplicité, n’ont pas besoin d’être virtuelles. Quant à la méthode affiche, il n’y a pas de raison en principe de la redéfinir ultérieurement (ce n’est pas le cas en tous cas dans listetab) ; ce choix est cependant plus discutable, il dépend de ce que l’on estime acceptable comme type de donnée dérivée de liste. Si seules les listes sont acceptées, il n’y a aucun problème. Si des données plus complexes (matrices par exemple) sont acceptables, il faut déclarer la méthode comme virtuelle, car une matrice n’est pas affichée de la même façon qu’une liste.

Cela fait, rien n’empêche plus de définir un tableau de pointeurs :

element table[5] = { 1, 3, 5, 7, 11 }; liste *listes[3] = {    new liste(5, table),                          new listetab(2, table +3),                          new listetab(3, table) };  for (int i = 0; i < 3; i++)      if (listes[i]) listes[i]->affiche(); // ... for (i = 0; i < 3; i++) delete listes[i];

Bien remarquer que ce sont les bonnes fonctions d’affichage et de destruction qui sont appelées dans cet exemple.

Exercice 8.3 :

Quel est l’affichage produit par cet exemple ? Quelle est la place mémoire totale occupée dans le tas, en fonction de la taille S = sizeof(element) des éléments de liste ?

Solution de l’exercice 8.3 :

On affiche :

1    3    5    7    11 7    11 1    3    5

Il y a une liste qui occupe 4 octets avec des pointeurs courts (petits modèles de mémoire), et 6 avec des longs, plus deux listetab qui occupent chacune 8 octets avec des pointeurs courts, et 14 avec des longs. La première liste utilise 5 noeuds de 4+S octets chacun avec des pointeurs courts, ou 8+S avec des longs. Les deux autres listes utilisent des tableaux de 2*S et 3*S éléments respectivement. La place mémoire totale occupée dans le tas est donc de 4 + 2*8 + 5*(4 + S) + 2*S + 3*S, soit 40 + 10*S avec des pointeurs courts, et de 74 + 10*S avec des longs. Ce décompte ne tient pas compte toutefois du fait que les blocs alloués dans le tas occupent en fait plus de place que la taille alloué

L’inconvénient de cette méthode simple, c’est que le champ noeud* est inutilisé dans listetab, ce qui gaspille de la mémoire (en faible quantité cependant). Évidemment, on aurait pu le réutiliser pour tenir le rôle de tab par exemple, mais cela aurait exigé de désagréables changements de types à tout moment. En outre, il aurait fallu le déclarer protégé et non privé. Et il est clair que dans tous les exemples la situation ne sera pas si simple.

Un autre inconvénient important résulte de ce qu’il devient impossible de définir le type listetab avant l’autre, et difficile de recommencer avec de nouveaux types de listes. Pour régler ce problème plus élégamment, le langage fournit les classes abstraites.

Classes abstraites :

Une classe est abstraite lorsque l’une au moins de ses méthodes virtuelles est pure. Pour déclarer une méthode virtuelle pure, il suffit de ne pas donner d’implantation et d’écrire = 0 ; derrière sa déclaration. Seules les fonctions virtuelles peuvent être déclarées pures, sous peine d’erreur (Error : Non-virtual function 'xxx' declared pure).

Lorsqu’une classe est abstraite, elle ne peut être utilisée directement ; en particulier, on ne peut pas déclarer d’objets de cette classe, ni d’arguments ou de résultats de fonction. Si vous tentez de le faire, vous obtiendrez Error : Cannot create a variable for abstract class 'xxx', on ne peut pas créer une variable de la classe abstraite 'xxx'.

On peut par contre utiliser des références, des pointeurs et dériver de nouvelles classes, et c’est en fait l’usage de ces classes abstraites. Voici comment déclarer une classe abstraite liste, puis les deux classes de listes concrètes identiques à celles du chapitre 6 :

class liste {       // classe  abstraite      protected :      int nombre;       public :      virtual ~liste() { nombre = 0; };       virtual void avance(int combien = 1)  = 0;    // pure      void recule(int combien = 1)          { avance(-combien); }       virtual element& valeur(void) = 0;    // pure      unsigned nombre_elt(void) { return nombre; }      void affiche(unsigned combien = 65535);       virtual int insere(const element&) = 0;    // pure      virtual void supprime(int n = 1) = 0;    // pure      };   class listech : public liste {    // liste chaînée      noeud* courant;       public :      listech() { nombre = 0; courant = 0; }      listech(int n, const element*);    // c. avec table      ~listech();       void avance(int combien = 1);      element& valeur(void)          { if (courant) return courant->contenu(); }       int insere(const element&);      void supprime(int n = 1);      };   class listetab : public liste {      element *tab, *courant;       public :      listetab() { courant = tab = 0; }      listetab(int n, const element*);    // c. avec table      ~listetab();       void avance(int combien = 1);      element& valeur(void)          { if (courant) return *courant; }       int insere(const element&);      void supprime(int n = 1);      };

La classe liste est abstraite, puisque quatre de ses méthodes ont été déclarées pures. On notera que certaines ne le sont pas : il s’agit essentiellement (et pas par hasard) de celles qui n’étaient pas virtuelles dans notre exemple précédent.

Le petit programme de démonstration reste identique, sauf que le premier élément de listes doit être initialisé en écrivant new listech..., au lieu de new liste.

Les classes abstraites n’ont généralement pas de constructeur, sauf si l’initialisation des membres est un peu compliquée (ici il suffit de mettre la valeur adéquate dans le champ nombre, et les constructeurs de listch et listtab le font). Par contre, il est généralement souhaitable d’y placer un destructeur virtuel, même s’il ne fait rien comme dans notre exemple : on est ainsi certain de la bonne destruction des objets des classes dérivées.

Exercice 8.4 :

Comment créer un opérateur d’affectation pour les listes ? Et les constructeurs de copie ?

Solution de l’exercice 8.4 :

On peut bien sûr créer un opérateur d’affectation pour chaque classe concrète, mais cela ne permet pas de faire des affectations de l’une de ces classes à l’autre. Voici une solution à ce problème, basée sur la remarque simple que les méthodes de liste permettent de connaître entièrement le contenu d’une liste, et donc d’en construire une copie :

class liste {       // classe  abstraite      // ...       virtual liste& operator=(liste&) = 0;      };  class listech : public liste {      // ...      listech(liste& ls) { nombre = 0; *this = ls; }      listech(listech& lc) { nombre = 0; *this = lc; }      liste& operator=(liste&);      };  class listetab : public liste {      /  /...      listetab(liste& ls) { nombre = 0; *this = ls; }      listetab(listetab& lt) { nombre = 0; *this = lt;}      liste& operator=(liste&);      };  liste& listech::operator=(liste&  ls) // copie une liste dans this {      supprime(nombre);      int reste = ls.nombre_elt();      if (!reste) return *this;      noeud *np = 0;      while ( (np = new noeud(ls.valeur(),               courant = np)) && (reste--) ) {           ls.avance();           nombre++;           }      ls.avance(reste);    // rétablir début      if (np) courant = np->suivant();      return *this; }  liste& listetab::operator=(liste&  ls) // copie la liste dans this {      supprime(nombre);      if ( !ls.nombre_elt() ||          !(tab = new element[ls.nombre_elt()]) )              return *this;      courant = tab;      nombre = ls.nombre_elt();      for (int i = nombre; i; i--) {          *courant++ = ls.valeur();          ls.avance();          }      courant = tab;      return *this; }

On notera que dans le cas de la classe listech par exemple, il faut définir un constructeur de copie pour un argument de liste& et un pour un argument listech&, bien qu’il y ait une conversion automatique de la seconde classe vers la première ; en effet, le premier constructeur avec son argument liste& n’est pas considéré comme un constructeur de copie par C++, puisqu’il n’inclut aucun argument de type listech& ; de ce fait, le compilateur fournit un constructeur implicite qui fait une copie membre à membre, ce qui n’est pas souhaitable ici. Le même raisonnement vaut pour listetab évidemment.

Remarquer également que la copie n’est pas parfaitement identique dans ses effets dans les deux cas, s’il n’y a pas assez de mémoire : listech recopie tout ce qui est possible, tandis que listetab ne recopie rien.

Ce type de classe peut paraître curieux au premier abord. En fait, il est assez pratique, notamment quand, comme dans notre exemple, on souhaite implanter de plusieurs façons différentes une forme d’objet ; l’utilisateur n’a plus alors qu’à choisir celle qu’il préfère. Le seul inconvénient, assez léger, vient de ce qu’il faut utiliser des pointeurs dans ce cas ; cela évite cependant de se tromper en utilisant un tableau de liste alors qu’il faut un tableau de pointeurs.

 

Polymorphisme automatique :

Un comportement idéal serait qu’une classe abstraite ait plusieurs implantations, et que celles-ci puissent changer toutes seules pour passer de l’une à l’autre. Par exemple, lorsqu’une liste chaînée classique listech commencerait à déborder, elle se transformerait toute seule en liste-tableau listetab qui est plus compacte.

Cela n’est pas possible directement, car il peut exister plusieurs pointeurs sur une même instance de classe dans un programme. Si celle-ci change, elle changera probablement de position en mémoire, et les pointeurs vont se retrouver incorrects ; une telle chose n’est pas prévisible directement dans les méthodes des classes.

Ce polymorphisme automatique peut cependant être implanté.

Héritage multiple :

Jusqu’à présent nous avons utilisé des classes qui dérivaient d’une unique classe de base. Il est parfaitement possible qu’une classe hérite de plusieurs classes. Voici un exemple :

class A {      // ...      };  class B {      // ...      };  class C : public A, B {      // ...      };

La classe C hérite de manière publique de A et de manière privée de B (il faut préciser à chaque classe le type de dérivation, sinon c’est le type par défaut qui s’applique). Elle a trois sortes de membres : les siens propres ; ceux hérités de A ; ceux hérités de B. Les règles d’héritage sont les mêmes que dans l’héritage simple. Le constructeur de C appelle les constructeurs de A et B, implicitement ou non :

C::C() : A(), B()  {      // ... }

Noter que dans cette écriture, tout comme dans la déclaration d’héritage, c’est une virgule qui sépare les différentes classes de base, et non le symbole deux-points.

Conflits de noms :

Lorsqu’une classe hérite de plusieurs autres, il se peut que deux des classes de base aient des champs ou des méthodes ayant le même nom. S’il s’agit d’un champ d’une part, et d’une méthode d’autre part, ou de deux méthodes mais avec des listes d’arguments différents, il n’y a pas d’ambiguïté et le compilateur se débrouillera en fonction du contexte d’utilisation.

Par contre, lorsqu’il s’agit de deux champs, ou de deux méthodes ayant les mêmes arguments, le compilateur se trouve face à une ambiguïté insoluble. Pour la résoudre, il faut utiliser le nom d’une des classes de base et l’opérateur de résolution de portée. Par exemple, si les classes A et B ont toutes deux un champ x, il faudra écrire :

C c; c.A::x = 0;

Lorsqu’il s’agit de méthodes, il est préférable de recouvrir les

Arbre de dérivation :

Il n’est pas permis de faire des cycles en cours de dérivation ; c’est-à-dire qu’une classe C ne peut pas hériter d’elle-même, ni directement, ni indirectement par l’intermédiaire d’une ou plusieurs autres classes.

Par contre, si deux classes A et B dérivent toutes deux d’une classe Z, on peut dériver une classe C de A et B ; la classe comprendra alors deux instances de Z. (Il faut alors utiliser l'opérateur de résolution de portée :: pour distinguer les membres hérités par l'intermédiaire de l'une ou l'autre.)

Assez étrangement, si une classe B dérive de A, on ne peut pas dériver une classe C de A et B ; le compilateur affiche une erreur (Error : 'A' is inaccessible because also in 'B', 'A' est inaccessible car également dans 'B').

Héritage virtuel :

Nous avons dit précédemment que si deux classes A et B dérivent d’une même troisième Z, une dérivation de A et B placera dans la classe dérivée C deux copies de Z.

Il est possible d’éviter ce comportement lorsqu’il n’est pas souhaitable. Il faut pour cela que les classes A et B aient été dérivées de manière virtuelle de Z :

class Z { ... };                                  class A : virtual public Z { ... };                                  class B : virtual Z { ... };                                  class C : public A, B { ... };

La classe C dans ce cas ne contient qu’une instance de Z. Les classes A et B sont identiques à ce qu’elles étaient auparavant, sauf que le compilateur sait que l’instance de Z peut être à un emplacement inhabituel (c’est le cas dans C) ; les deux classes doivent être dérivées virtuellement de Z.

On notera que dans ce cas, il n’y a pas d’ambiguïté lorsqu’on utilise avec une instance de C les membres hérités de Z (alors qu’il y en a une si la dérivation n’est pas virtuelle, même pour les méthodes puisqu’elles ne savent sur quel instance s’appliquer si l’on n’utilise pas un spécificateur A:: ou B::), sauf si les deux classes intermédiaires ont redéfini une méthode virtuelle de Z (auquel cas le compilateur ne peut choisir) ; si une seule des deux classes intermédiaires a redéfini une méthode virtuelle de Z, c’est la méthode redéfinie, et non la méthode de Z, qui sera utilisée.

Fonctionnement interne :

Après toutes ces observations sur l’héritage des classes, le lecteur se demande peut-être « comment ça marche » . Il n’est pas nécessaire de le savoir en pratique (il suffit de savoir que ça marche en effet), mais cela peut être utile à l’occasion. Nous expliquons ci-après comment Turbo C++ implante les classes (il peut y avoir des variations selon les compilateurs) ; pourquoi les instances de classes contenant des méthodes virtuelles prennent deux octets de mémoire de plus que les autres ; pourquoi certaines opérations sont impossibles.

Prenons d’abord le cas simple suivant :

class A {      int a1;      public :      // ... méthodes      };  class B : A {      int b1;      public :      // ... méthodes      };

La configuration en mémoire d’une instance de B est alors la suivante (chaque petit carré représente un octet) :

La partie grisclair représente ce qui est hérité de la classe A, tandis que la partie blanche indique ce qui est défini directement dans B.

Si une méthode de A est appelée, elle reçoit comme les autres l’adresse de l’objet par l’intermédiaire du pointeur this. Or, vu l’ordre dans lequel les champs sont placés, ce pointeur indique en mémoire la partie « instance de A » de l’objet ; de ce fait, les méthodes de A fonctionnent exactement de la même façon sur une instance de A ou de B, et n’utilisent dans ce dernier cas que la partie gris clair.

Compliquons un peu les choses en supposant que B a des méthodes virtuelles :

class B : A {      int b1;      public :      virtual void b2();      virtual void b3();      void b4();      };

Dans ce cas, les instances de B sont représentées différemment en mémoire :

Chaque instance contient à présent un pointeur caché sur une table fixe (il en existe une seule pour toute la classe B) qui contient les adresses des méthodes virtuelles. Lorsque le compilateur rencontre un appel à b2 par exemple, il regarde ce pointeur caché dans this (ou dans l’objet qui appelle b2), puis l’augmente d’autant que nécessaire (ici 0) pour être sur la bonne méthode ; ayant ainsi l’adresse de la méthode, il ne reste plus qu’à y sauter.

Ce processus s’appelle lien dynamique ou lien tardif (en anglais late binding). On notera que les méthodes non virtuelles en sont exclues (b4).

Nous allons voir comment cela fonctionne plus exactement en imaginant une nouvelle classe :

class C : B {      int c1;      public :      void b2();      void b3();      virtual void c2();      };

Cette classe recouvre les deux méthodes virtuelles de B. Une instance de C a l’allure suivante :

Le pointeur caché n’a maintenant plus la même valeur que dans les instances de B : il pointe sur une nouvelle table particulière à C, et dans laquelle les adresses des méthodes recouvertes figurent à la place de celles de B. Lorsque le compilateur rencontrera un appel à b2 avec une instance de C, il ira chercher dans cette table-ci (sans le savoir, car il exécute exactement le même travail qu’avant), et passera donc dans la bonne méthode recouverte C::b2. Ceci explique le fonctionnement des méthodes virtuelles : le pointeur caché est identique pour deux instances d’une même classe, mais différent pour deux classes distinctes ; de ce fait, il caractérise la classe à laquelle appartient l’instance, et permet donc de choisir la bonne méthode.

Compliquons encore le jeu avec un héritage multiple :

class D : B {      int d1;      public :      virtual void d2();      void b3();      };  class E : D, C {      int e1;      public :      void b3();      void c2();      };

L’allure d’une instance de D est la suivante :

On remarque que, comme la classe D n’a pas recouvert la méthode virtuelle b2, c’est l’adresse de B::b2 qui figure en première place dans la table.

Jusqu’à présent nous n’avons augmenté la taille des objets que de deux octets au maximum. Il n’en est plus de même avec l’héritage multiple. Voici l’allure en mémoire d’une instance de E :

Dans ce cas, il y a deux pointeurs cachés, dans chacune des deux instances de base C et D contenues dans E. Les tables sur lesquelles ils pointent sont semblables à ce qu’elles étaient dans C et D, sauf que les méthodes recouvertes de E y remplacent celles de C ou D. On notera que la méthode E::b3 figure deux fois dans la table de E, parce qu’elle recouvre à la fois C::b3 et D::b3.

Lorsqu’on appelle une méthode de D avec une instance de E, ce n’est pas le pointeur this qui est passé, mais celui que nous avons nommé « this bis » , qui correspond au début de D en mémoire.

Lorsqu’on utilise un héritage virtuel, la situation est beaucoup plus complexe. Supposons que les classes C et D aient été déclarées en héritage virtuel de B :

class C : virtual B { ... }                                  class D : virtual B { ... }

Dans ce cas, l’allure d’une instance de C en mémoire est bien différente :

Trois pointeurs sont ajoutés ; le premier, en tête, indique l’emplacement dans l’instance du début de la partie héritée de B (cet emplacement variera dans les classes dérivées de C). À la fin de l’objet, un pointeur désigne une table formellement identique à celle de B, mais avec les adresses des méthodes virtuelles recouvertes. Au milieu, un troisième pointeur donne les adresses des méthodes virtuelles de C (dans l’ordre de déclaration) ; les méthodes b2 et b3 sont ici remplacées par b2* et b3*, qui sont identiques à ceci près qu’un petit bout de code avant fait remplacer this par le pointeur de tête, afin que les méthodes aient la bonne adresse.

L’allure de D est assez semblable, sauf que b2, qui n’est pas recouverte dans D, n’apparaît pas dans la première table :

On a à présent ceci dans E :

La partie initiale correspond à C ; elle comprend le pointeur de tête sur la base héritée de B, le champ c1 et un pointeur sur les trois méthodes virtuelles de C, toutes trois recouvertes dans E ; notons que la partie B de C n’existe plus (pas de duplication). La suite correspond à D : on trouve le pointeur de tête sur la partie B, le champ d1 et un pointeur sur les deux méthodes virtuelles de D, dont une recouverte dans E (b3). Vient ensuite le nouveau champ e1 ; puis enfin la partie héritée de B, en un seul exemplaire, avec à la fin un pointeur sur les méthodes virtuelles de B, toutes deux modifiées dans E (une directement par E, l’autre indirectement par C). Tout cela est compliqué par le fait que les méthodes doivent être augmentées de petits bouts de code destinés à récupérer la bonne adresse de this. L’adresse « this bis » est celle qui est utilisée pour les méthodes de D.

On retiendra surtout qu’il s’agit d’un processus complexe, dans lequel il est préférable de ne pas intervenir, et que la taille des objets est difficile à prévoir (utiliser l’opérateur sizeof pour la connaître).

9/ FLOTS D' ENTRES-SORTIES :

Les entrées-sorties, c’est-à-dire les opérations de lecture à partir du clavier, d’un fichier disque ou d’un périphérique, et les écritures à l’écran, sur disque, imprimante ou périphérique, sont parmi les opérations les plus fréquentes sur ordinateur. Leur maîtrise est donc essentielle en programmation. En C++, on dispose de « flots » d’entrée ou de sortie qui permettent facilement ces opérations. Nous décrivons ici ces flots et leur utilisation. Nous donnons aussi quelques indications sur leur structure interne, lorsqu’elle met en relief certaines capacités intéressantes de C++, ou des astuces de programmation connues.

Les programmeurs C noteront que nous ne présentons pas ici les entrées-sorties standard de C. Celles-ci sont en effet bien moins pratiques et de ce fait, pratiquement obsolètes en C++.

Classes de flots :

Les classes de flots sont au nombre de dix-huit, réparties dans trois fichiers distincts : <iostream.h>, <fstream.h> et <strstrea.h>. Un quatrième fichier <iomanip.h> peut être utilisé dans certains cas (voir fin du chapitre).

Le schéma ci-après montre la répartition des classes ; les flèches grises indiquent une dérivation. On peut distinguer les catégories suivantes :

  • Les tampons d’entrées-sorties, divisés en trois classes streambuf, strstreambuf et filebuf.

  • Les flots d’entrées-sorties, que l’on peut répartir en quatre groupes :

    • le groupe fondamental, qui comprend la classe de base ios, la classe de sortie ostream, celle d’entrée istream, et la mixte iostream ;

    • le groupe des périphériques standard, avec la même structure mais sans classe de base : ostream_withassign, istream_withassign, iostream_withassign ;

    • le groupe des fichiers sur disques, qui a la même structure que le fondamental ; la base est fstreambase, la sortie ofstream, l’entrée ifstream et la mixte fstream ;

    • le groupe des chaînes de caractères, qui a aussi la même structure que le fondamental ; la base est strstreambase, la sortie ostrstream, l’entrée istrstream et la mixte strstream.


La répartition peut sembler complexe, mais elle est en fait assez simple à comprendre. Un flot d’entrées-sorties est une liste de caractères qu’on ne charge pas entièrement en mémoire. Donc en premier lieu un flot doit avoir un tampon, c’est-à-dire un petit bloc de mémoire où ranger les caractères en attente. Ce tampon est géré par un élément de la classe streambuf ou de ses dérivées, qui fournit des opérations comme « placer n caractères dans le tampon » , « retirer n caractères » , etc. Ces opérations sont de bas niveau, elles ne nous regardent pas.

Chaque flot va donc contenir un pointeur sur un tampon (ou plusieurs éventuellement), plus un certain nombre de renseignements auxiliaires indiquant notamment l’état dans lequel il se trouve. C’est en fait le type de tampon qui détermine en grande partie le type de flot ; par contre, le type de flot indique les opérations autorisées. En particulier, il n’est évidemment pas permis d’écrire sur un flot d’entrée ou de lire dans un flot de sortie.

Flots généraux classe ios :

La classe ios est la base des flots. Il ne s’agit pas d’une classe abstraite, mais peu s’en faut. Elle ne permet qu’un petit nombre d’opérations, et n’a pas en principe à être utilisée telle quelle. Cependant elle fournit un certain nombre de constantes énumérées pour la gestion des flots, avec les petites fonctions membres en ligne afférentes.

Une instance de ios est normalement toujours rattachée à un tampon streambuf. La fonction membre streambuf* rdbuf() renvoie un pointeur sur ce tampon.

Une instance de ios occupe 34 octets de mémoire.

État des flots :

Une première énumération dans ios contient une liste de masques unitaires (c’est-à-dire d’entiers dont un seul bit vaut 1) ; utilisés sur un champ particulier, ils indiquent l’état du flot. Ce champ d’état n’est pas accessible directement mais peut être lu par la fonction membre int rdstate(void). Voici les bits indicateurs qui peuvent être positionnés :

ios::goodbit

Lorsque ce bit vaut 0, ainsi que tous les autres, tout va bien. La fonction membre int good(void) renvoie 1 si tous les bits d’état sont à zéro (tout va bien), 0 sinon.

ios::eofbit

Lorsque ce bit vaut 1, la fin du fichier est atteinte. La fonction membre int eof() renvoie 1 dans ce cas, 0 sinon.

ios::failbit

Ce bit est à 1 lorsqu’une opération a échoué. Le flot peut être réutilisé.

ios::badbit

Ce bit est à 1 lorsqu’une opération invalide a été tentée ; en principe le flot peut continuer à être utilisé mais ce n’est pas certain.

ios::hardfail

Ce bit est à 1 lorsqu’une erreur grave s’est produite ; il ne faut plus utiliser le flot.

La fonction membre int bad(void) renvoie 1 si l’un des deux bits ios::badbit ou ios::hardfail est à 1, 0 sinon. La fonction membre int fail(void) renvoie 1 si l’un des trois bits ios::badbit ou ios::failbit ou ios::hardfail est à 1, et 0 sinon.

La fonction membre void clear(int i = 0) permet de modifier l’état du flot. Par exemple, l’écriture fl.clear(ios::failbit) positionne le bit ios::failbit du flot fl, indiquant une erreur grave.

Signalons les deux opérateurs suivants :

class ios {      public:      // ...      operator void* ();      int operator! (); };

L’opérateur ! (redéfini pour cette classe) renvoie 1 si l’un des bits d’état est à 1 (flot incorrect), 0 sinon. Au contraire l’opérateur void*, lui aussi redéfini, renvoie 0 si l’un des bits d’état est à 1, un pointeur non nul (et dépourvu de signification) sinon. Cela permet des écritures du type :

iostream fl; // ... if (fl) cout << "Tout va bien !n"; // ... if (!fl) cout << "Une erreur s'est produite.n";

plus agréables que l’appel à fl.good.

 

Mode d’écriture :

Une autre énumération regroupe les masques unitaires utilisés pour le champ de mode. Celui-ci indique de quelle façon les données sont lues ou écrites, et ce qui se passe au moment de l’ouverture du flot. On l’utilise surtout pour les fichiers. Voici la liste de ces bits :

ios::in

Fichier ouvert en lecture.

ios::out

Fichier ouvert en écriture.

ios::app

Ajoute les données, en écrivant toujours à la fin (et non à la position courante).

ios::ate

Aller à la fin du fichier à l’ouverture (au lieu de rester au début).

ios::trunc

Supprime le contenu du fichier, s’il existe déjà ; cette suppression est automatique pour les fichiers ouverts en écriture, sauf si ios::ate ou ios::app a été précisé dans le mode.

ios::nocreate

Pour une ouverture en écriture, ne crée pas le fichier s’il n’existe pas déjà ; une erreur (bit ios::failbit positionné) est produite dans le cas où le fichier n’existe pas encore.

ios::noreplace

Pour une ouverture en écriture, si ni ios::ate ni ios::app ne sont positionnés, le fichier n’est pas ouvert s’il existe déjà, et une erreur est produite.

ios::binary

Fichier binaire, ne faire aucun formatage.

Par exemple l’écriture suivante :

fstream fl("EXEMP.CPP", ios::in|ios::out|ios::app);

ouvre le fichier EXEMP.CPP en lecture et écriture, avec ajout des nouvelles données à la fin (voir aussi le paragraphe sur les flots sur disques).

Indicateurs de format :

Les flots permettent un grand nombre de formatages des données. Il existe un champ de format dans ios, et une liste de masques unitaires qui lui correspondent, dont voici la signification lorsqu’ils sont à 1 dans ce champ :

ios::skipws

Supprime les espaces (blancs, tabulations, etc.) en lecture. Ce bit est à 1 par défaut, contrairement aux autres.

ios::left

Ajustement à gauche en écriture.

ios::right

Ajustement à droite en écriture.

ios::internal

Remplissage après le signe + ou -, ou l’indicateur de base (et non avant).

ios::dec

Écriture décimale (base 10).

ios::oct

Écriture en octal (base 8).

ios::hex

Écriture en hexadécimal (base 16).

ios::showbase

En écriture, écrire un indicateur de base.

ios::showpoint

Écrit obligatoirement le point décimal pour les nombres à virgule flottante, même si toutes les décimales sont nulles.

ios::uppercase

Écrit les lettres A à F en majuscules dans les chiffres hexadécimaux (minuscules sinon).

ios::showpos

Écrit le signe pour tous les entiers, même positifs.

ios::scientific

Pour les nombres à virgule flottante, écriture en notation scientifique (1.75e+01 par exemple).

ios::fixed

Pour les nombres à virgule flottante, écriture en notation à virgule flottante (17.5 avec le même exemple).

ios::unitbuf

Vide les tampons après écriture.

ios::stdio

Vide les tampons de sortie standard out et de sortie d’erreur err après insertion.

Le champ de format peut être lu par la méthode long flags(void) et modifié par long flags(long). Deux autres méthodes peuvent être utilisées aussi dans ce but. La méthode long unsetf(long) met à zéro les bits du champ de format qui valent 1 dans son argument. La méthode long setf(long) a l’effet contraire. Mais on utilisera surtout long setf(long, long) ; le premier argument indique la nouvelle valeur des bits à modifier ; le deuxième argument indique les bits à modifier effectivement (ceux à 1 ; ceux à 0 ne sont pas changés), et peut être pris dans la liste de constantes suivante :

ios::basefield

égal à

ios::dec | ios::oct | ios::hex

ios::adjustfield

égal à

ios::left|ios::right|ios::internal

ios::floatfield

égal à

ios::scientific | ios::fixed

Par exemple

f.setf(ios::uppercase|ios::hex, ios::uppercase|ios::basefield);

change les bits de base d’écriture et de uppercase afin d’écrire les entiers en hexadécimal avec des lettres majuscules pour les chiffres A à F.

Trois autres champs de formatage peuvent être utilisés. Le champ de largeur indique sur combien de caractères de large la donnée doit être écrite ; si la donnée est plus petite, la partie restante est remplie avec le caractère de remplissage ; si elle est trop grande elle n’est pas tronquée. Ce champ est remis à zéro après chaque opération formatée. Il peut être lu par la méthode int width(void) et modifié par int width(int) (qui renvoie la valeur précédente).

Le champ de remplissage indique quel caractère est utilisé pour le remplissage lorsqu’il y en a un. Par défaut, c’est l’espace blanc qui est utilisé. Ce champ peut être lu par char fill(void) et modifié par char fill(char).

Le champ de précision indique combien de décimales sont écrites au maximum dans les nombres à virgule flottante. Par défaut, le plus grand nombre de décimales significatives est écrit. Ce champ peut être lu par int precision(void) et modifié par int precision(int).

L’ensemble de ces champs de formats donne un nombre de possibilités très impressionnant, et qu’il est exclu de passer entièrement en revue. Voici simplement quelques exemples :

int i = 245; double d = 75.8901; cout.precision(2); cout.setf(ios::scientific, ios::floatfield); cout << d;    // écrit 7.59e+01 cout.width(7); cout.fill('$'); cout << i;    // écrit $$$$245 cout.width(9); cout.fill('#'); cout.setf(ios::left|ios::hex,          ios::adjustfield|ios::basefield); cout << i;    // écrit f5####### cout.fill(' '); cout.width(6); cout.setf(ios::internal|ios::showpos|ios::dec,      ios::adjustfield|ios::showpos|ios::basefield); cout << i;    // écrit + 245

Cette notation n’est pas très pratique, une meilleure sera indiquée plus loin (paragraphe sur les manipulateurs).

 

Par Missa Dioma - Publié dans : Informatique
Ecrire un commentaire - Voir les 0 commentaires
Retour à l'accueil

Présentation

Créer un Blog

Recherche

Calendrier

Mai 2012
L M M J V S D
  1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31      
<< < > >>
Créer un blog gratuit sur over-blog.com - Contact - C.G.U. - Rémunération en droits d'auteur - Signaler un abus