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

Opérateurs new et delete :

Nous avons dit au chapitre 3 que new et delete étaient des opérateurs unaires. Cependant, étant donné leur usage un peu particulier, ils possèdent leurs règles propres pour la redéfinition.

Commençons par expliquer à quoi peut servir la redéfinition de tels opérateurs. Imaginons un programme dans lequel on souhaite gérer des structures dynamiques, du type liste chaînée, arbre, etc., contenant de nombreux pointeurs sur des éléments nombreux mais de petite taille (par exemple des entiers int). La gestion de la mémoire va alors poser problème, car l’allocateur standard de mémoire malloc est peu adapté aux petits blocs : en effet, il conserve plusieurs informations sur chaque bloc, comme sa taille, etc., qui ne sont pas forcément utiles et surtout prennent beaucoup de place par comparaison à la taille d’un entier. Il est facile de le vérifier, en utilisant la fonction coreleft() qui indique la place mémoire disponible. En faisant 1 000 appels à malloc(2), celle-ci diminue de 8 000 (et non 2 000).

L’idée est alors de ranger tous ces petits entiers dans une même table, afin qu’ils soient compactés au maximum. Une table de bits auxiliaire indiquera simplement l’emplacement des éléments libres de la table. Pour cela, nous allons redéfinir les opérateurs new et delete sur un type element formellement identique à un entier. Pour ne pas avoir à réécrire tous les opérateurs sur les entiers, nous définissons aussi un opérateur de changement de type de element vers int et un constructeur de int vers element. Voici la classe obtenue :

#include <alloc.h>     #include <mem.h>                                      class element {         int i;         static element* table;    // table d'alloc. mémoire         static char* libres;     // indicat. de blocs libres      public :         static unsigned tailletable;    // taille de table         element() { i = 0; }         element(int j) { i = j; }         operator int() { return i; }         void* operator new(unsigned);         void  operator delete(void *, unsigned);         };                          void* element::operator new(unsigned taille) {      if (taille != sizeof(element))          return malloc(taille);      if (!table) {    // table à allouer             unsigned max = 65535/(8*taille+1);    // taille max.             if (!tailletable) tailletable = 100;             if (tailletable > max) tailletable = max;             table = (element*) calloc(tailletable, 8*taille+1);             if (!table) return 0;             libres = (char*)(table + 8*tailletable);             memset(libres, ~0, tailletable);             }      unsigned numero      // chercher le premier bloc libre      char *tablefin = libres + tailletable;      for (char *p = libres;           (*p == 0) && (p < tablefin); p++);      if (p >= tablefin) return ;0  // table pleine      numero = 8*(p -libres);      unsigned char octet = *p, masque = 1;      while ( (octet & masque) == 0)          { masque <<= 1; numero++; }      *p -= masque;       // mettre bit à 0 : occupé      return table + numero; }                          void element::operator delete(void* pe, unsigned  taille) {      if (!pe) return;      // pe est nul      if (taille != sizeof(element))   // pas alloué dans table          delete pe; return;          }      unsigned numero = (element*)pe -table;      unsigned char masque = 1 << (numero%8);      char *p = libres + numero/8;      *p |= masque;      // mettre bit à 1 pour libérer }                          main() {      element *tab[10];      for (int i = 0; i < 10; i++)          tab[i] = new element(i+10);  // 10 allocations      *tab[1] = *tab[5] + (*tab[6])/(*tab[0]);  // par exemple      for (i = 0; i < 10; i++)          delete tab[i];    // autant de libérations                                       return 0; }

Noter la syntaxe de déclaration des deux fonctions opérateurs. Dans le cas de new, il s’agit d’une fonction renvoyant un pointeur void* (adresse de l’élément alloué), et ayant au moins un paramètre de type unsigned (ou size_t ce qui est équivalent, ce dernier type étant défini dans <alloc.h>) qui indique la taille de l’élément à allouer. On pourrait penser ici que cette taille est forcément égale à 2 (taille de element), mais il n’en est rien car, comme on le verra au chapitre 8, des classes peuvent avoir hérité de element (avec l’opérateur new associé), et être plus grandes. Dans ce cas, on ne peut les mettre dans notre table, c’est pourquoi lorsque taille est plus grand que sizeof(element), l’opérateur renvoie un bloc normal de la mémoire dynamique dans notre exemple.

L’opérateur delete est une fonction sans résultat, avec un paramètre pointeur void* (l’élément à supprimer) et un second paramètre optionnel indiquant lui aussi la taille.

La syntaxe de ces fonctions est particulière à plus d’un titre. D’abord, bien qu’il s’agisse ici de fonctions membres, il n’est pas permis de faire référence à des champs non statiques, ni à this. En effet, ces fonctions sont appelées pour des objets en cours de création, avant le constructeur, ou en cours de destruction, après le destructeur. Précisons cependant que les deux fonctions connaissent l’adresse correspondant à this : dans le cas de delete, c’est le premier argument, dans le cas de new, c’est la valeur à calculer. Par conséquent, l’opérateur new par exemple peut placer des valeurs dans ce qui deviendra l’objet, et notamment des zéros.

D’autre part, la syntaxe d’appel est assez curieuse, comme on le sait déjà, puisqu’elle reste identique à celle de l’opérateur prédéfini. Noter en particulier que bien que new renvoie un pointeur void*, c’est un element* qui est reçu en fait par tab[i] dans notre exemple, ainsi qu’on peut légitimement l’espérer.

Donnons quelques explications sur notre programme exemple, qui est très typique de ce genre de manipulation. On a placé en membre statique de element un pointeur table qui donne le début de la table où sont placés les éléments, et un pointeur d’octets libres, qui désigne une liste de bits mis à 1 si l’emplacement correspondant est libre, à 0 sinon. Ces deux membres sont privés et ne sont utilisés que par new et delete. Le troisième membre statique est un entier qui indique la taille de la table à allouer, divisée par huit. La valeur par défaut est 100 (ce qui permet de placer 800 entiers dans la table), mais on peut la changer avant le premier appel à new (qui initialise la table) afin d’avoir plus ou moins de place. En effet, quand la table est pleine, new renvoie zéro ; il ne peut pas augmenter la taille de la table, car un appel de realloc changerait l’adresse de celle-ci, et par conséquent ferait perdre toutes les données de la table pointées par des pointeurs extérieurs (comme tab[i] dans main).

Chaque élément va occuper 17 bits dans la table : seize à un emplacement pointé par le résultat de new, plus un dix-septième au même emplacement relatif par rapport au début de la table de bits pointés par liste. La taille totale occupée par l’ensemble des tables est donc (8*2+1)*tailletable, puisqu’il y a 8*tailletable éléments. Lorsque new trouve une valeur de table nulle au départ (table non encore allouée), il alloue un bloc de cette dimension en mémoire par un appel à calloc (variante de malloc). La table proprement dite occupe les 8*2*tailletable premiers octets, tandis que la table de bits pointée par libres occupe les tailletable octets restants.

Pour calculer son résultat, new cherche le premier bit à 1 dans la table de bits ; pour cela il cherche le premier octet non nul, et le décale autant de fois que nécessaire pour obtenir un bit à 1 ; on utilise pour cela un masque du type 0..010..0 (binaire). On met le bit à 0 en soustrayant le masque, et l’on renvoie table + numero, où numero est l’index de la position nouvellement prise dans la table.

L’opérateur delete se contente quant à lui de mettre à 1 le même bit à l’aide d’un « ou » logique (il ne faut pas employer une addition, car le bit peut être déjà à 1 : dans notre exemple, il n’y a aucune erreur si l’on désalloue deux fois le même bloc).

Ce système est très utile et fait gagner beaucoup de place, puisque chaque entier occupe à présent 17 bits de mémoire, contre 64 avec l’allocateur standard. L’allocation de 1 000 éléments occupe au total 2 125 octets.

Noter que de tels opérateurs ne s’appliquent pas sur les tableaux. Ainsi, si l’on écrit :

element *tab = new element[5];

c’est l’opérateur standard qui est utilisé, puisqu’on ne peut redéfinir les opérateurs sur les tableaux. Par contre, rien n’empêche d’écrire une classe simulant un tableau avec un opérateur new pour avoir le même effet.

L’opérateur new peut avoir des arguments supplémentaires. Dans ce cas, ceux-ci doivent être écrits entre parenthèses derrière le mot new lors de son appel. Voici un exemple :

class exemple {      // ...      void* operator new(unsigned, void* adresse = 0)          {    if (adresse) return adresse;               else return malloc(taille); }      void operator delete(void *p)          {    free(p); }      }                                  // .....  exemple *exp = new exemple; char tampon[sizeof(exemple)]; exemple *exp2 = new(&tampon) exemple;

Ici nous avons ajouté un second paramètre adresse, qui indique une adresse où stocker l’objet. Ainsi, si le pointeur exp pointe sur un bloc normal de la mémoire, puisque dans ce cas on n’a pas précisé adresse (qui a donc la valeur par défaut 0, d’où appel de malloc), par contre exp2 pointe sur le tableau tampon. Quel est l’intérêt d’une telle manoeuvre ? Elle permet d’appeler un constructeur pour un objet placé dans le tableau tampon, ce qui n’est pas possible autrement. Quel en est le danger ? C’est que le programmeur appelle delete avec exp2, alors qu’aucun bloc n’a été alloué. Il faut ici appeler explicitement le destructeur, comme on l’a dit au chapitre 6 :

exp2->exemple::~exemple();

et non delete. Une méthode plus sûre consisterait à placer un champ dans la classe indiquant si l’objet a été alloué en mémoire dynamique ou dans un tampon ; ce champ peut être retrouvé par delete qui connaît l’adresse de l’objet à détruire.

Il n’est pas possible de passer un paramètre supplémentaire à delete (Error : 'operator delete' must be declared with one argument, l’opérateur delete doit être déclaré avec un argument, ce qui est d’ailleurs faux puisqu’on peut en placer un second pour la taille).

Opérateurs new et delete globaux :

Nous avons vu comment redéfinir les opérateurs d’allocation et de désallocation pour une classe particulière. Il est possible de redéfinir globalement ces opérateurs, de sorte qu’ils agissent sur toutes les classes, même les prédéfinies. Voici un exemple (assez stupide) :

const MAXINT = 32767; unsigned* bloc;  void* operator new(unsigned taille) {      if (!bloc) {          bloc = new unsigned[MAXINT];          if (!bloc) return 0;          bloc[MAXINT] = 0;          }       unsigned occupes = bloc[MAXINT];      if (occupes + taille >= MAXINT) return 0;      bloc[occupes++] = taille;      bloc[MAXINT] += (3+taille)/2;      return bloc + occupes; }  void operator delete(void *p) {      if (!p) return;      unsigned position = (unsigned*)p -bloc -1;      unsigned taille = (3 + bloc[position])/2;      if (bloc[MAXINT]-position == taille)          bloc[MAXINT] = position; }

Dans notre exemple, ces opérateurs utilisent un bloc fixe en mémoire, et placent les objets dans ce bloc, les uns derrière les autres, précédés par leur taille. Les objets ne sont détruits que lorsqu’ils sont les derniers insérés.

Opérateurs membres ou amis :

Lorsqu’on définit un opérateur pour une classe, on ne sait pas forcément très bien comment le déclarer. En particulier, faut-il en faire une fonction membre, ou une fonction amie ? Et quels arguments doivent être passés en référence ?

Il n’y a pas de réponse générale à ce problème, mais un certain nombre de règles simples que l’on peut suivre, quoiqu’elles n’aient rien d’obligatoire.

Si l’opérateur demande parmi ses arguments une valeur modifiable (lvalue), il est préférable d’en faire une méthode, afin d’éviter des écritures étranges. C’est ce que nous avons fait pour l’opérateur d’affectation, dont le premier argument est une valeur modifiable. En effet, si l’on écrivait :

fraction& operator=(fraction& f1,  fraction f2) // bizarre... {      f1.num = f2.num;      f1.den = f2.den;      return f1; }

alors l’écriture suivante :

fraction f(2/5); 4 = f;

serait parfaitement licite : elle équivaudrait à créer un objet temporaire de valeur 4/1, y recopier 2/5, puis à le détruire : il n’y aurait donc aucun effet. Le moins que l’on en puisse dire c’est que ce n’est guère naturel. Si l’on a par contre défini un tel opérateur comme un membre (comme nous l’avons fait pour la classe matrice précédemment), cette écriture devient interdite parce que le compilateur ne fait pas de conversion de type pour les instances qui appellent un membre.

Inversement, si l’on avait écrit l’opérateur d’addition ainsi :

class fraction {      // ......      fraction operator+(fraction f)          {              f.num = num*f.den + den*f.num;              f.den *= den;              return f;          }      }

on pourrait ajouter 1 à 2/5 mais pas 2/5 à 1.

Entre ces deux comportements, il faut donc choisir. Dans certains cas, les deux semblent équivalents. À ce moment il est préférable en général d’utiliser des membres, qui sont plus faciles à écrire, puisqu’on a accès directement aux champs.

Pour les fonctions qui ne sont pas des opérateurs, on choisit selon la syntaxe souhaitée. Par exemple, l’inversion d’une matrice est plus agréable écrite inv(M) que M.inv() : on en fera plutôt une amie. Par contre, l’élévation à une puissance entière est peut-être plus claire sous la forme M.pow(i) que pow(M, i) : on en fera un membre.

Pour ce qui est du type des arguments et du résultat, il faut choisir entre une référence et un élément normal. Pour les arguments, il suffit de faire comme pour toute fonction : si l’argument est petit et a des constructeurs simples, on peut le passer par valeur. Si par contre la fonction ne modifie pas l’argument et que celui-ci est gros, ou a des constructeurs compliqués (exigeant par exemple une allocation de bloc mémoire), utiliser une référence. Quant au résultat, il est préférable en général de le passer par valeur. Un résultat référence est en effet dangereux. Cependant, on peut passer un tel résultat référence lorsque la référence est en fait un des arguments référence ou pointeur (y compris this s’il y a lieu) : c’est le cas des affectations, et aussi de << et >> pour les fichiers de sortie et d’entrée (voir chapitre 9).

On peut aussi renvoyer une référence sur un argument passé par valeur, parce que le destructeur afférent n’est appelé qu’après la fin complète du calcul de l’expression courante. Par exemple, si l’on écrit :

class exemple {      // .....      exemple(exemple&);     // constructeur de copie      ~exemple();            // destructeur      exemple& operator+=(exemple ex)              {                  // affectation-addition              // ... additionner...               return *this;           }      };  exemple& operator+(exemple ex1, exemple&  ex2)          {    return ex1+= ex2; }    // addition           main() {      exemple exmpl1, exmpl2;      exemple exmpl3 = exmpl1 + exmpl2;      // .... }

alors le programme sera développé comme ceci :

main()           // écriture  développée {      exmpl1.exemple::exemple();       // constr. par défaut      exmpl2.exemple::exemple();       // idem      // début de l’addition : création de ex1      ex1.exemple::exemple(exmpl1);    // constr. de copie      // passage dans la fonction en ligne operator+      ex1.exemple::operator+=(exmpl2); // addition      // retour du résultat ex1 dans exmpl3      exmpl3.exemple::exemple(ex1);    // constr. de copie      // addition terminée      ex1.exemple::~exemple();         // appel du destructeur      // ...... }

On voit que le destructeur pour l’argument provisoire ex1 est appelé après que celui-ci ait été copié dans le résultat de l’addition exmpl3. De ce fait l’opération se déroule correctement, ce qui n’aurait pas été le cas autrement. L’ordre des appels peut être vérifié en regardant les imbrications explicites dans les opérateurs de fonction. Ainsi l’addition équivaut à :

exmpl3.exemple::exemple(operator+(exmpl1,  exempl2));

ce qui explique pourquoi le destructeur est appelé en dernier.

De telles considérations sont complexes, et pour un gain parfois faible. Dans le doute, n’utilisez pas de références.

8/ HERITAGES :

Nous avons vu comment les classes permettaient la protection des données en C++. Cette protection peut cependant paraître gênante : si l’on souhaite faire une modification mineure d’une classe, sans avoir accès au code de celle-ci, il semble qu’il faille tout réécrire. Il n’en est heureusement pas ainsi, grâce au mécanisme de l’héritage, dont les applications sont extrêmement étendues, comme nous allons le voir à présent.

Réutilisation du code :

Imaginons qu’on vous a fourni une bibliothèque de formes graphiques contenant par exemple une classe rectangle ayant l’allure suivante :

class rectangle {      // membres privés      public :      rectangle();      rectangle(int gche, int haut, int drte, int bas);      ~rectangle();      void trace();      void efface();      void valeur(int& gche, int& haut,                  int& drte, int& bas);      void change(int gche, int haut,                  int drte, int bas);      };

Vous ne connaissez pas les membres privés (même si vous les connaissiez vous ne pourriez pas les changer, ils sont définitivement hors de portée), ni le code des méthodes dont vous connaissez simplement le nom et l’usage : valeur donne les coordonnées des bords du rectangle, change les modifie, trace dessine le rectangle à l’écran tandis que efface le supprime ; le constructeur par défaut crée un rectangle vide, l’autre crée un rectangle dont on fournit les coordonnées des bords.

A présent, vous souhaitez créer une classe qui ne se trouve pas dans la bibliothèque, et représente un rectangle plein (avec une couleur de remplissage). Il est clair que la plupart des méthodes de rectangle s’appliquent à notre nouvelle classe, et qu’il faut simplement ajouter un champ indiquant la couleur de remplissage, plus deux méthodes couleur qui permettent de connaître cette couleur et de la modifier ; il faut aussi changer trace et efface.

Pour cela, nous allons écrire que notre nouvelle classe rectplein est en fait un rectangle, plus quelque chose. Cela s’écrit ainsi :

class rectplein : rectangle {      int coul;      public :      rectplein();      rectplein(int gche, int haut,              int drte, int bas, int couleur = 0);      ~rectplein();      void trace(void);      void efface();      int couleur() { return coul; }    // donne couleur      int couleur(int nouvelle)          {        // donne la couleur et la change              int ancienne = coul;              coul = nouvelle;              trace();              return ancienne;          }      };

On dit que l’on a dérivé la classe rectplein de rectangle. Dans ce cas, la classe dérivée hérite des caractéristiques de la classe de base, et en particulier de ses membres. Dans certains cas, les membres de la classe de base doivent être redéfinis (cas de trace et efface notamment), dans d’autres les méthodes de la classe de base conviennent aussi (cas de change et valeur dans notre exemple).

La classe dérivée peut utiliser les membres publics de la classe de base, même si elle les redéfinit. Par exemple, la fonction trace de rectplein se réduit à deux opérations : remplir le rectangle avec la couleur de remplissage, puis dessiner le bord de ce rectangle. Si l’on suppose qu’on dispose d’une fonction remplirrect réalisant le premier travail, il suffit d’écrire :

void rectplein::trace(void) {      if (coul) {          int gche, drte, haut bas;          valeur(gche, haut, drte, bas);          remplirrect(gche, haut, drte, bas, coul);          }      rectangle::trace(); }

On a appelé la méthode valeur héritée de rectangle (puisqu’on ne connaît pas les coordonnées du rectangle qui sont des membres privés de la classe de base) ainsi que la méthode trace de rectangle ; dans ce dernier cas, il faut absolument écrire rectangle::trace() et non trace() qui ferait un appel récursif infini.

Méthodes héritées :

Tous les membres d’une classe, et notamment les méthodes, sont hérités par la classe dérivée. Cependant, il y a quelques exceptions. D’abord les constructeurs et destructeurs ne sont pas hérités, ils ont leur propres règles (voir ci-après).

Les opérateurs sont hérités normalement, comme d’autres fonctions. Cependant, aucune opération n’est réalisée sur les nouveaux membres, c’est pourquoi il est généralement préférable de redéfinir ces opérateurs.

Enfin l’opérateur d’affectation est un cas particulier, car il n’est pas à proprement parler hérité non plus. Lorsqu’il n’est pas redéfini explicitement dans une classe dérivée, il recopie membre à membre les nouveaux membres de cette classe dérivée, et appelle l’opérateur d’affectation de la classe de base pour la copie de la partie héritée. Lorsqu’on le redéfinit pour la classe dérivée, l’opérateur pour la classe de base n’est pas appelé, il faut donc le faire explicitement, comme ceci :

rectplein& rectplein::operator=(rectplein  rp) {      *(rectangle*)this = rp;      coul = rp.coul; }

Le changement de type sur this permet la recopie sous la forme d’une instance rectangle, soit par l’opérateur d’affectation de celle-ci, s’il existe, soit par la copie membre à membre par défaut (voir le paragraphe sur le polymorphisme). On aurait pu aussi écrire un appel explicite à rectangle::operator=, si l’on savait que celui-ci était défini (le compilateur refuse en effet cet appel lorsque seule l’affectation par défaut est définie).

Notons que notre exemple est assez inutile, puisqu’il fait exactement ce que ferait l’opérateur d’affectation par défaut (il n’y a aucune opération particulière de réalisée).

Constructeurs et destructeurs :

Lorsque la classe de base possède un constructeur par défaut, celui-ci est appelé automatiquement avant l’appel du constructeur de la classe dérivée, pour initialiser les données membres de base. Il est cependant permis à un constructeur de la classe dérivée de faire un appel explicite à un constructeur de la classe de base, afin d’initialiser les membres hérités ; cet appel se fait de la même façon que pour les membres qui sont des classes (chapitre 5), c’est-à-dire en plaçant derrière la liste des arguments le symbole : puis le nom de la classe de base (qui est aussi celui de son constructeur) avec ses arguments. Voici donc comment définir de manière naturelle les deux constructeurs de la classe rectplein :

rectplein::rectplein() {      // appel implicite de rectangle::rectangle();      couleur = 0; }  rectplein::rectplein(int gche, int haut,              int drte, int bas, int couleur)               : rectangle(gche, haut, drte, bas)    // explicite {      coul = couleur;      trace(); }

Le premier constructeur appelle en fait le constructeur par défaut de rectangle (qui crée un rectangle vide), ce qu’il n’est pas nécessaire de préciser. Par contre, dans le second, on souhaite utiliser l’autre constructeur (qui crée un rectangle à partir de ses coordonnées), et il faut alors le mentionner explicitement.

Il résulte de ces règles que lorsqu’une classe n’a pas de constructeur par défaut, les classes dérivées doivent obligatoirement appeler un constructeur de la classe de base.

Le destructeur d’une classe dérivée appelle le destructeur de la classe de base après l’exécution de ses tâches explicites. Ainsi on peut écrire :

rectplein::~rectplein() {      efface();      // appel implicite de rectangle::~rectangle(); }

Comme il n’y a qu’un destructeur par classe, il n’y a pas à choisir.

On retiendra que les constructeurs sont appelés dans l’ordre ascendant des classes (de base vers dérivées), tandis que les destructeurs le sont dans l’ordre inverse. Il s’agit bien là d’un ordre conforme à la logique. En effet, le constructeur d’une classe dérivée peut avoir besoin des membres de la classe de base (c’est le cas dans notre exemple, puisque la fonction trace utilise les coordonnées du rectangle) : il en résulte que la partie de base de l’objet doit être initialisée avant qu’on ne commence la construction explicite. Inversement, le destructeur aussi peut avoir besoin des membres hérités : il ne faut donc pas les détruire en premier, mais seulement après.

Membres privés, publics, protégés :

Nous avons vu au chapitre 6 que certains membres d’une classe pouvaient être publics (les méthodes en général), mais que par défaut ils étaient privés. Pour les structures c’est le contraire.

Il existe une troisième catégorie de membres, les membres protégés (protected). Du point de vue de la classe qui les déclare, ils sont identiques à des membres privés : on ne peut pas y accéder de l’extérieur. Par contre, une classe dérivée peut accéder aux membres protégés de sa classe de base (alors qu’elle ne le peut pas pour les membres privés).

Une classe peut dériver de manière publique d’une classe de base, ou de manière privée. Par défaut, une classe dérive de manière privée, et une structure de manière publique. Voici comment les deux types d’héritages influent sur la nature des membres hérités :

  • membres publics : ils restent publics dans une classe dérivée de manière publique, mais deviennent privés dans une classe dérivée de manière privée.

  • membres protégés : ils restent protégés dans une classe dérivée de manière publique, mais deviennent privés dans une classe dérivée de manière privée.

  • membres privés : ils ne sont jamais accessibles dans une classe dérivée.

Pour dériver une classe de manière publique, comme ce n’est pas la valeur par défaut, il faut placer le mot public devant le nom de la classe de base. Voici quelques exemples :

class A {      int a1;      protected :      int a2;      public :      int a3;      };  class B : A {         // héritage  privé      int b1;      protected :      int b2;      public :      int b3;      };  class C : public A { // héritage  public      int c1;      protected :      int c2;      public :      int c3;      };

La classe A possède trois membres, un privé a1, un protégé a2, un public a3 ; de l’extérieur seul a3 est accessible. La classe B possède six membres : un indisponible directement a1, trois privés a2, a3 et b1, un protégé b2 et un public b3 ; de l’extérieur, seul b3 est accessible. Enfin la classe C possède aussi six membres : un indisponible a1, un privé c1, deux protégés a2 et c2 et deux publics a3 et c3 ; de l’extérieur, seuls a3 et c3 sont accessibles.

Pour les structures c’est le contraire, puisque l’héritage est par défaut public. Pour le rendre privé, il suffit de placer le mot private devant le nom de la classe de base. Dans les deux cas, il n’existe pas de dérivation « protégée » .

Il arrive que l’on souhaite modifier ces états par défaut pour un membre ou deux seulement. Dans ce cas, il suffit de renommer les membres hérités en les plaçant au bon endroit. Voici un exemple :

class D : private A {        // héritage  privé      int d1;      protected :      int d2;      A::a2;      public :      int d3;      };  class E : public A {        // héritage  public      int e1;      A::a2;      protected :      int e2;      A::a3;      public :      int e3;      };

La classe D, pour laquelle on a précisé un héritage privé (inutilement, c’est la valeur par défaut), diffère de B en ce que le membre hérité a2 est protégé dans D, alors qu’il était privé dans B. La classe E diffère de C en ce que le membre hérité a2 est privé pour elle (protégé pour C) et a3 est protégé pour elle (public pour C).

On ne peut pas diminuer la protection d’un membre par héritage. Si l’on essaie dans une classe dérivée de déclarer public un membre protégé de la classe de base, ou si l’on essaie de redéclarer un membre privé de la classe de base, on obtient une erreur (Error : Access declarations cannot grant or reduce access, les déclarations d’accès ne peuvent pas octroyer ou réduire le niveau d’accès).

Bien que l’héritage des classes soit privé par défaut, il est en général préférable de le déclarer public. Par exemple, notre classe rectplein est mal déclarée à la section précédente, il faut un héritage public :

class rectplein : public rectangle {      // ...      };

Dans le cas contraire, il ne serait pas possible d’appeler les méthodes héritées valeur et change à partir d’une variable de type rectplein.

Pour ce qui est des membres, le choix entre les différents accès n’est pas toujours évident. En effet, s’il est clair que l’on doit déclarer publics les membres (en général seulement des méthodes) que l’on souhaite accessibles de l’extérieur, il n’est pas forcément facile de choisir entre privés et protégés pour les autres, puisque cela exige de réfléchir à ce que pourraient être d’éventuelles classes dérivées de la classe courante. Dans la suite de ce chapitre, nous nous efforcerons de donner quelques indications sur des exemples, car il n’y a pas réellement de règle générale en la matière : cela dépend si l’on souhaite que les classes dérivées connaissent bien le contenu de leur base ou non.

 

Méthodes virtuelles :

Revenons à notre exemple rectangle et rectplein pour mettre en lumière un problème inhérent à l’héritage. Nous ne connaissons pas le contenu de la classe rectangle, mais imaginons qu’il s’agisse simplement des quatre coordonnées h, b, g, d du rectangle. Dans ce cas, la méthode change a probablement l’allure suivante :

void rectangle::change(int gche, int haut,                      int drte, int bas) {      efface();      if ( (gche >= drte) || (haut >= bas) )          { g = d; return; }    // rectangle vide      g = gche; d = drte;      h = haut; b = bas;      trace(); }

A priori, il n’y a aucune raison de changer cette méthode pour notre classe rectplein. Pourtant, si l’on fait un essai, un appel de change avec une instance de rectplein ne donnera pas le bon résultat. Pourquoi ?

Rappelons-nous que la classe rectangle a été compilée avant la classe rectplein. Dès lors, lorsque le compilateur, agissant sur le code source de la méthode change ci-dessus, rencontre un appel à efface et un autre à trace, il cherche les méthodes de ce nom ; il n’en connaît alors que deux, celles de la classe rectangle. En mettant les points sur les i, le compilateur « voit » donc ceci :

void rectangle::change(//...) {      rectangle::efface();      // ...      rectangle::trace(); }

On voit dès lors pourquoi cette méthode ne fonctionnera pas correctement avec rectplein : le rectangle ne sera ni correctement effacé, ni correctement retracé, parce que l’on a modifié les méthodes correspondantes dans notre nouvelle classe.

Une solution simple et expéditive consisterait alors à réécrire la méthode change ; seulement voilà, on ne peut pas : nous ne savons pas comment les coordonnées du rectangle sont stockées dans la classe rectangle, et le saurions-nous, nous ne pourrions pas les modifier puisque les membres correspondants sont inaccessibles dans la classe rectplein.

Ce problème est classique en programmation orientée objet, et résulte du principe même de compilation. Les langages de POO interprétés comme SmallTalk n’ont pas de difficultés de cet ordre.

La solution réside dans la déclaration de méthodes virtuelles. Pour les déclarer telles, il suffit de placer le mot réservé virtual devant le nom de la méthode. Si le programmeur qui a conçu la classe rectangle était prévoyant, il a compris que certaines des fonctions membres de la classe auraient à être modifiées dans des classes descendantes :

class rectangle {      // membres privés      public :      rectangle();      rectangle(int gche, int haut, int drte, int bas);       virtual ~rectangle();      virtual void trace();      virtual void efface();       void valeur(int& gche, int&  haut,                  int& drte, int& bas);      void change(int gche, int haut,                  int drte, int bas);      };

Lorsque le compilateur sait qu’une méthode est virtuelle, il ne place pas un appel direct à cette méthode, mais recherche la dernière méthode redéfinie dans la classe de this ayant le même nom et les mêmes types d’arguments, et appelle celle-ci. Il en résulte que la méthode change aura cette fois-ci le bon comportement : si l’on appelle r.changer est de type rectangle, la méthode appellera les fonctions rectangle::efface et rectangle::trace ; si l’on appelle rp.change, où rp est de type rectplein, la méthode appellera cette fois rectplein::efface et rectplein::trace.

On note que les méthodes change et valeur n’ont pas besoin d’être définies comme virtuelles, parce que leur code n’a aucune raison d’être modifié par les classes dérivées de rectangle (en fait, elles ne peuvent pas le modifier, puisqu’elles n’ont pas accès aux coordonnées du rectangle).

Lorsqu’une méthode est déclarée virtuelle dans une classe, elle l’est automatiquement pour toutes les classes dérivées ; il n’est donc pas nécessaire de réécrire virtual devant leur déclaration.

On prendra garde que les méthodes ne sont pas seulement caractérisées par leur nom, mais aussi par la liste de leurs arguments. En conséquence, si l’on définit par exemple une méthode rectplein::trace(int couleur), celle-ci ne sera pas virtuelle (sauf déclaration explicite) car il s’agit d’une méthode différente de rectplein::trace() (en vertu des règles de recouvrement de fonctions vues au chapitre 5) ; de toute façon, ce ne sera pas elle qui sera appelée par rectangle::change, ne serait-ce que parce que les arguments ne correspondent pas. Précisons qu’en parlant de la liste des arguments, nous parlons aussi des arguments par défaut : en définissant une unique méthode rectplein::trace(int couleur = -1) par exemple, on s’expose à des ennuis car la méthode change appellera alors rectangle::trace(), seule méthode de ce nom ayant zéro argument dans la classe rectplein.

Pour éviter tout ennui, on redéfinira une méthode virtuelle avec exactement la même liste d’arguments, quitte à fournir aussi des homonymes ayant des arguments supplémentaires (ou en moins). On évitera aussi les arguments par défaut dans les méthodes virtuelles, pour la même raison.

Destructeurs virtuels :

Les constructeurs ne peuvent jamais être déclarés virtuels, pour des raisons évidentes : ils sont spécifiques à une classe et doivent être redéfinis dans les classes descendantes pour une initialisation correcte. De la même façon, les opérateurs new et delete, lorsqu’ils sont redéfinis pour une classe, ne sont pas virtuels.

Les fonctions membres statiques ne peuvent pas être virtuelles non plus, puisqu’elles peuvent être appelées « hors contexte ». C’est là une différence importante avec les fonctions membres normales.

Les méthodes en ligne peuvent être virtuelles, mais le compilateur ne les placera pas en ligne dans ce cas.

Les destructeurs cependant peuvent être déclarés virtuels, et c’est même préférable en général. En effet, nous verrons plus loin qu’un pointeur pr sur une classe de base (rectangle*) peut en fait pointer sur un objet d’une classe dérivée (rectplein*) ; dès lors, si l’on écrit delete pr, le mauvais destructeur sera appelé, à moins que l’on ait pris la précaution de le déclarer virtuel. Nous recommandons de toujours le faire si l’on compte dériver des classes à partir de la classe courante, même si le destructeur ne fait rien.

Polymorphisme et classes abstraites :

Nous avons vu au chapitre 6 que la protection des données particulière à la programmation objets permet une certaine forme de polymorphisme : une classe peut être implantée de différentes façons.

L’héritage permet de perfectionner ce processus, en faisant cohabiter deux implantations différentes (ou plus) d’une même classe, sous une forme homogène. Pour cela, il existe des règles de compatibilité particulières à l’héritage.

Compatibilité :

Lorsqu’on utilise une variable d’une classe, il est possible de lui affecter une variable de la classe dérivée :

rectangle r; rectplein rp;     // rectplein dérive de rectangle  r = rp            // parfaitement correct

Dans ce cas, seuls les champs de la classe rectangle, hérités par rp, sont recopiés dans r. Plus généralement, une variable d’une classe dérivée peut être utilisée partout où cela est possible pour la classe de base.

Le contraire n’est naturellement pas vrai : la classe rectangle n’a pas de champ couleur, il lui est impossible de se comporter de la même façon que rectplein. L’affectation inverse rp = r est donc impossible, sauf si l’on définit un opérateur d’affectation adéquat, et un constructeur pour les initialisations. On pourrait le faire ainsi par exemple :

class rectplein : public rectangle {      // ...      public :      rectplein(rectangle& r, couleur = 0)              : rectangle(r) {           coul = couleur; trace();          }      // ...      rectplein& operator=(rectangle& r) {          efface;          *(rectangle*)this = r;          trace();          }      }

Noter toutefois que l’affectation explicite par changement de type dans operator= est un appel à l’affectation rectangle::operator=, qui provoque a priori aussi un effacement et un appel à rectangle::trace() indésirables (quoique sans gravité ici). Il est donc préférable d’éviter les affectations ayant de tels effets de bord, dans la mesure du possible.


 

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