Héritage

Jusqu'à présent, nous avons vu comment définir des classes, qui permettront de créer des objets dans notre programme. Or, pour l'instant, nous ne pouvons exprimer que certaines relations entre nos objets: les relations d'association, d'agrégation et de composition, qui s'expriment par le fait qu'une donnée membre d'un objet peut être elle aussi un objet. L'héritage est l'outil qui va nous permettre d'exprimer entre nos classes une relation de type "est une sorte de". Cet outil est très puissant, car grâce à lui nous pourrons déclarer des classes d'objets très généraux, puis progressivement "spécialiser" ces classes d'objets. Cette spécialisation est aussi une "extension" de la classe de base: si l'on reste dans les généralités, on n'a pas grand-chose à dire. Plus les choses se précisent, plus on doit détailler. Donc à chaque étape du processus, on rajoutera du code. Le fait de partir d'une classe de base générale permet d'exprimer directement dans le code des concepts abstraits

Les classes étudiées dans ce chapitre ont une sémantique d'entité et la règle du zéro s'applique normalement.

ATTENTIONVous pouvez télécharger ici un petit programme jouet qui "implémente" le programme des machines à café

Classes abstraites...

Reprenons l'exemple des machines à café abordé lors de l'introduction à la programmation objet pour les gens normaux avant. Posons-nous la question suivante: "qu'est-ce qu'un cafetière ?" On peut aussi poser la question autrement: "Quels sont les points communs à toutes les cafetières ?".

Une petite réflexion montre qu'une cafetière est une machine qui est capable de:

Nous devrons donc avoir deux fonctions: faire_le_cafe d'une part, lire_etat d'autre part. Mais quel code mettrons-nous dans ces fonctions, puisque à ce stade nous n'avons aucune idée de la manière dont le café serait fait ? La réponse est simple: nous ne mettrons... rien ! Par contre, nous pouvons d'ores et déjà implémenter les mécanismes concernant les réservoirs cités ci-dessus. D'où la modélisation UML suivante:

cafetiere en UML

et le code suivant:

class Cafetiere {
   public:
      virtual void faire_le_cafe() = 0;
      virtual int lire_etat() = 0;
      void encaisser_monnaie();
  
   protected:
      void verser_eau();
      void sucre();
      void donner_gobelet();
      void donner_cuiller();
      
   private:
      ReservoirEau eau;
      ReservoirSucre sucre;
      ReservoirGobelets gobelets;
      ReservoirCuillers cuillers;
}

La syntaxe virtual void faire_le_cafe() = 0 permet de définir une fonction virtuelle pure: on se contente ici de dire au compilateur que les objets Cafetiere seront dotés de cette fonction, mais nous lui dirons ultérieurement ce que ces fonctions devront faire concrètement. Le diagramme UML met ces fonctions en italique.

Le fait que la classe Cafetiere contienne dans sa définition au moins une fonction virtuelle pure entraine que cette classe est abstraite. D'où le nom de la classe en italiques dans le diagramme UML.

...Classes concrètes

Notre cafetière peut se décliner en plusieurs modèles:

Nous savons comment faire le café pour du café soluble ou pour du café en poudre. Nous pouvons donc redéfinir nos fonctions virtuelles, comme on le voit sur le diagramme et le code ci-dessous:

cafetiere soluble en poudre en UML

et le code suivant:

class CafetiereSoluble: public Cafetiere {

public:
  void faire_le_cafe() override;
  int lire_etat() override;

protected:
  void verser_cafe();
  
private:
  ReservoirCafeSoluble cafe;
}

class CafetierePoudre: public Cafetiere {

public:
  void faire_le_cafe() override;
  int lire_etat() override;

protected:
  void verser_cafe_dans_filtre();
  
private:
  ReservoirCafePoudre cafe;
}

On a ainsi défini une relation d'héritage entre nos objets. On dit que CafetierePoudre hérite de Cafetiere, ou encore que Cafetiere est une super-classe de CafetierePoudre

On dit que la classe CafetiereSoluble est dérivée de Cafetiere, Cafetiere étant une classe de base par rapport à CafetiereSoluble. et CafetierePoudre. CafetiereSoluble et CafetierePoudre sont en quelque sorte des "sous-types" de Cafetiere, de sorte que les variables de type CafetiereSoluble sont aussi des variables de type Cafetiere. Les CafetiereSoluble et CafetierePoudre sont donc des sortes de Cafetiere, ils constituent une extension de Cafetiere

Le mot-clé override n'est pas obligatoire, mais il est fortement recommandé: en effet il informe le compilateur qu'on redéfinit une fonction virtuelle, cela permet au compilateur de faire des vérifications.

Accès aux données: la section protected

Puisque CafetiereSoluble et CafetierePoudre étendent le type Cafetiere, ils encapsulent des objets tels que eau, qui représente le réservoir d'eau, etc. Mais parce que ces objets sont déclarés dans la section private de Cafetiere, CafetiereSoluble ni CafetierePoudre n'auront accès à ces variables.

ATTENTION On peut être surpris, à première vue, que les classes dérivées n'aient pas accès à la section private de leurs classes de bases... et pourtant, c'est la moindre des choses:

L'idéal pour une classe dérivée est donc de n'utiliser que les fonctions-membres publiques de la classe de base, au même titre que n'importe quelle autre fonction. Cependant, certaines fonctionnalités ou certaines données n'ont pas à être utilisées par tout le monde, mais seulement par les classes dérivées: ainsi dans notre cafetière, tous les modèles de cafetière doivent avoir accès au réservoir d'eau, mais les utilisateurs de la machine à café n'ont pas de raison, eux, d'y avoir accès. D'où la nécessité de créer une nouvelle section: protected; tout ce qui sera déclaré dans cette section sera utilisable par les classes dérivées, et uniquement par elles. Ainsi, dans notre exemple, on mettra dans protected des fonctions d'accès aux éléments constitutifs de la machine à café (verser_eau()). On considère qu'une bonne conception doit respecter les règles suivantes:

REGLE  D'OR

Ainsi, il est toujours possible de dériver une classe à partir d'une autre. La classe dérivée aura toujours accès aux fonctions publiques de sa classe de base. Mais il est souhaitable de prévoir la dérivation, en définissant une section protected "réservée" aux classes dérivées.

Constructeurs...

En reprenant les classes définies ci-dessus, que se passe-t-il lorsqu'on écrit:

CafetiereSoluble ma_cafetiere;

Il se passe les choses suivantes:

  1. Allocation de mémoire.
    Autant d'octets que nécessaire compte-tenu des champs de CafetiereSoluble et de a (ou ses) classes de base.
  2. Appel du constructeur par défaut de Cafetiere
  3. Appel du constructeur par défaut de CafetiereSoluble

Autrement dit, tant qu'on travaille avec les constructeurs par défaut, tout se passe bien: le système se charge d'appeler les constructeur par défaut des classes de base, dans le bon ordre.

Constructeur de copie:

Nous sommes en présence d'objets ayant une sémantique d'identité, en conséquence il n'y a pas lieu de définir le constructeur de copie. On pourra éventuellement définir un clônage polymorphique (voir plus bas).

Autres constructeurs

rien n'empêche de définir sur Cafetiere un constructeur à qui on passe des paramètres, par exemple les quantités initiales d'eau, de sucre, ainsi que le nombre de gobelets et de cuillers:

class Cafetiere {
public:
   Cafetiere(float e, int g, int c,float s) : eau(e), gobelets(g),
cuillers(c), sucre(s) {};
... }

De même, rien n'empêche de définir un constructeur pour CafetiereSoluble, à qui on donnera en plus la quantité de café soluble à incorporer dans le réservoir adhoc. Là encore, le système ne peut pas savoir quel constructeur de la classe de base doit être appelé: c'est donc de la responsabilité du constructeur de la classe dérivée d'appeler le constructeur de sa classe de base, en utilisant la liste d'initialisation:

class CafetiereSoluble: public Cafetiere {
public:
   CafetiereSoluble(float e, int g, int c,float s, float f) : 
   Cafetiere(e,g,c,s), cafe(f) {};
...
}
class CafetierePoudre: public Cafetiere {
public:
   CafetierePoudre(float e, int g, int c,float s, float f) : 
   Cafetiere(e,g,c,s), cafe(f) {};
...
}

ATTENTION On ne peut pas initialiser directement les membres de la classe de base dans la classe dérivée: le code suivant:

 
CafetierePoudre(float e, int g, int c, float s, float f) : 
eau(e),gobelets(g),cuillers(c),sucre(s),
cafe(f){};

ne compilera pas, car eau, gobelets, cuillers, sucre sont des membres de la classe Cafetiere, pas de CafetierePoudre. Il faut obligatoirement passer par un constructeur de Cafetiere.

.

...et destructeurs

Lorsqu'un objet de type CafetiereSoluble est détruit, il se passe la séquence suivante:

  1. Le destructeur de CafetiereSoluble est appelé
  2. Le destructeur de Cafetiere est appelé
  3. La mémoire est rendue au système

ATTENTION Il sera la plupart du temps nécessaire de définir un destructeur virtuel au niveau de la classe de base.

Le polymorphisme

Observons le code ci-dessous, dans lequel on gère trois machines à café, de deux types différents, par l'intermédiaire d'un tableau de pointeurs:

vector<shared_ptr<Cafetiere>> machines;
machines.push_back(make_shared<CafetiereSoluble>(1.5,500,500,2,0.5));
machines.push_back(make_shared<CafetiereSoluble>(1.5,500,500,2,0.5));
machines.push_back(make_shared<CafetierePoudre>(1.5,500,500,2,0.5));

for (int i=0; i<3; ++i) {
    if (machines[i]->lire_etat() == 1) {
       machines[i]->faire_le_cafe();
       machines[i]->encaisser_monnaie();
    };
};

Le code ci-dessus déclare un vecteur de type pointeur sur Cafetiere, puis le remplit avec deux machines à café soluble et une en poudre. Souvenez-vous que les fonctions lire_etat et faire_le_cafe ont été délarées avec le mot-clé virtual: ce mot signifie que le compilateur ne cherche pas à savoir exactement quelle fonction lire_etat sera appelée, ni quelle fonction faire_le_cafe() sera appelée, puisque l'allocation mémoire se fait de manière dynamique: il retient donc simplement qu'il devra appeler la version de la fonction faire_le_cafe qui va bien, en fonction du type d'objet qui sera appelé à l'exécution du programme. Le mot polymorphisme décrit la propriété de ces deux fonctions d'adopter "plusieurs formes", suivant le contexte du programme. On parle aussi d'édition de liens dynamique. Remarquons qu'une fois de plus, on retrouve une manière de penser parfaitement naturelle: si je vous passe une casserole (classe de base) en vous demandant de la laver (fonction qui opère sur le type générique casserole), je vous passe en réalité une casserole bien particulière, et pas toujours la même (hier c'était le vieux chaudron à confiture qui me vient de ma grand-mère, aujourd'hui c'est une casserole en aluminium, dans les deux cas il s'agit d'un type de casserole particulier). Dans les deux cas vous allez la laver... mais suivant le type de casserole, vous vous y prendrez différemment. Par contre, la fonction encaisser_monnaie fait l'objet d'une édition de liens statiques (il n'y a pas le mot virtual devant, donc il n'y a pas ici de polymorphisme).

ATTENTION Lorsque vous redéfinissez une fonction virtuelle, la nouvelle définition doit avoir soit le même type de retour que la fonction originale, soit un type dérivé (on parle dans ce cas de "types de retour covariants"). Sinon, le compilateur refusera votre code. Bien entendu, elles devront avoir également les mêmes signatures, sinon il s'agit de deux fonctions différentes, et le mécanisme d'édition de liens dynamiques ne s'applique pas.

ATTENTION Pourquoi les fonctions ne sont-elles pas automatiquement virtuelles ? C'est le cas dans d'autres langages orientés objets, java par exemple. Le problème avec les fonctions virtuelles, c'est qu'elles sont moins performantes que les fonctions classiques. C'est normal: le mécanisme d'édition de liens dynamiques est très puissant, mais il a un coût. Donc, il est recommandé de ne les utiliser que lorsque c'est nécessaire, et pas lorsque la performance est rédhibitoire.

REGLE  D'OR Si vous surchargez une fonction virtuelle, la fonction surchargée ne sera pas virtuelle automatiquement: il vous faut le spécifier explicitement.

Classes de bases abstraites

Le code suivant ne sera jamais compilé:

  Cafetiere C1;

En effet, la présence des fonctions virtuelles pures faire_le_cafe et lire_etat empêche le compilateur de générer un objet de type Cafetiere. En d'autres termes, on ne peut pas dire au compilateur "donne-moi de la mémoire et initialise cette cafetière". Il veut savoir précisément de quoi il s'agit. La classe Cafetiere est une classe de base abstraite, (c'est-à-dire une classe qui a au moins une fonction virtuelle pure). En tant que telle, on peut la passer en paramètres (par référence ou par pointeur),mais pas par valeur à une fonction, mais on ne peut pas déclarer d'objet de cette classe.

Ainsi, le code suivant ne pose aucun problème:

  Cafetiere* C1;

En effet, il est toujours possible de déclarer un pointeur sur une classe abstraite. Le type réel de la classe est précisé lorsque le pointeur sera initialisé, le plus souvent en utilisant l'opérateur new.apres.

Le code suivant est utilisable également:

   void une_fonction(Cafetiere& c) {...};
   
   CafetiereSoluble cs(1.5,500,500,2,0.5);
   CafetierePoudre cp(1.5,500,500,2,0.9);
   
   une_fonction(cs);
   une_fonction(cp);

Parce qu'on passe la variable par référence et pas par valeur, il est possible d'utiliser le polymorphisme, de même qu'avec des pointeurs.

Constructeurs virtuels...

ça n'existe pas: le principe d'une fonction virtuelle est que la fonction réellement appelée à l'exécution est la fonction correspondant au type de l'objet existant. Encore faut-il que l'objet soit existant, ce qui n'est pas le cas lors de l'appel d'un constructeur.

...et destructeurs virtuels

là, ça existe: et c'est même fort utile. En effet, le code suivant risque d'entraîner des résultats catastrophiques:

    for (auto m: machines) {
        m.reset();
    }

Le problème ici est que seul le destructeur de la classe de base a été appelé. Si le destructeur de la classe modele_poudre devait faire quelque chose de particulier (rendre la mémoire allouée pour le reservoir_cafe_en_poudre, par exemple), c'est raté. Le destructeur n'a pas été appelé, et il y a un réservoir à café qui traîne au fond de la mémoire... Il est donc obligatoire de déclarer pour la classe Cafetiere un destructeur virtuel... quitte à ce que celui-ci renoie sur le destructeur par défaut (règle du zéro):

class Cafetiere {
  virtual ~Cafetiere() = default;
};

REGLE  D'OR Toute classe de base contenant au moins une fonction virtuelle (et a fortiori une classe de base abstraite) doit proposer un destructeur virtuel.

Le clônage polymorphique

Dans certains cas, on veut bénéficier d'une opération de copie d'un objet dans l'autre, en s'arrangeant pour que l'objet de destination prenne le type de l'objet copié. Il s'agit d'un clônage polymorphique, il ne s'obtient pas avec l'operateur=, mais avec une fonction clone, définie dans chaque classe de la hiérarchie de la manière suivante:

class Cafetiere {
   ...
   virtual Cafetiere * Cafetiere clone() = 0;
}

class CafetiereSoluble {
   ...
   CafetiereSoluble * Cafetiere clone() override
   {
       return new CafetiereSoluble(*this);
   }
   
};

void f(const Cafetiere& c2) {
    Cafetiere * = c2.Clone();
   ...
};

main() {
  modele_grain G;
  f (G);
}

ATTENTION La fonction clone doit être redéfinie dans toutes les classes dérivées situées tout en bas de la hiérarchie (les "feuilles"). A chaque fois, la définition sera la même, mais on doit la réécrire.

ATTENTION Cela marche grâce à faculté du C++ de définir des types de retour covariants.

ATTENTION Pour une mise en œuvre du clônage polymorphique ainsi que des shared_ptr, vous pouvez télécharger ce code jouet

top


L'héritage multiple

Il est possible, bien que d'une utilisation délicate, de déclarer qu'une classe dérive de plusieurs superclasses. L'utilisation la plus intéressante de l'héritage multiple est la définition d'interfaces:

Implémentation du "pattern" observateur en utilisant l'héritage multiple

Imaginons la situation suivante, extrêmement courante:

Le pattern observateur permet de répondre à ce cahier des charges:

diagramme UML

class Observateur {
    public:
    virtual void update(float r) = 0;
};
class Diffuseur {
   public:
   void notifier(float r) {
        for(int i=0;i<abonnes.length();i++) {
            abonnes[i]->update(r);
        };
   };
   void abonne(Observateur * o) {
       if (o!=NULL)
          abonnes.push_back(o);
   };
   private:
      vector<Observateur*>abonnes;
};

class Traitement: public BaseTraitement, public Diffuseur {
   public:
      void run() {
          while(...) {
               ...
               notifier(x);
          };
      };
}

class ProgressionBar: public ProgressionBarWidget, public Observateur {
    public:
        virtual void update(float r) {
           ...
        };
};

Une fois n'est pas coutune, vous pouvez télécharger ici un petit programme que vous devriez pouvoir compiler. On voit que:

top


xhtml Licence Creative Commons Emmanuel Courcelle <emmanuel.courcelle@inp-toulouse.fr>