C++ – Polymorphisme (dynamique)

Ce texte est un texte de surface; pour une discussion plus en profondeur sur les sujets présentés ici, couvrant entre autres le polymorphisme statique, voir mes notes de cours (je ne peux résumer des milliers de pages en moins d'une dizaine, malheureusement). Le texte pourrait aussi être mis à jour à la lueur des raffinements permis par C++ 11, comme les mots clés contextuels que sont final et override.

Tiré sur le plan étymologique du grec poly (plusieurs) et morphos (formes), le polymorphisme est l'un des points subtils mais cruciaux de la programmation orientée objet (POO). Au sens classique du terme, le polymorphisme est un mécanisme dynamique permettant, par voie d'héritage, de spécialiser dans des classes dérivées les comportements annoncés ou implémentés dans des classes de base, indirectes ou non.

Avec le passage du temps et le raffinement de nos pratiques, l'acception que nous nous faisons de ce concept s'est raffinée et s'est élargie, et la meilleure définition pour ce terme est probablement celle de Scott Meyers, soit « plusieurs implémentations pour une interface ».

Cette définition contemporaine recoupe maintenant la programmation générique, les concepts et bien d'autres. Ce qui suit n'examine que la question du polymorphisme dynamique, donc du polymorphisme au sens classique. Nous présenterons brièvement l'héritage, condition pour ce type de polymorphisme, et nous traiterons ensuite du sujet au coeur de nos préoccupations.

Héritage

L'héritage consiste à regrouper des méthodes et des propriétés communes à un ensemble de classes sous l'égide d'une classe ancêtre leur étant commune. Toutes les classes descendant (dérivant) d'un ancêtre commun hériteront des propriétés et des méthodes de cet ancêtre[1]. L'idée ici est de maximiser la réutilisation du code existant.

Ce principe est implanté en C++ de façon riche et complexe – trop, en fait, pour notre petite discussion du polymorphisme. Nous restreindrons, dans ce qui suit, notre approche à l'héritage dit public.

Exemple
class Base {
   // ...
};
// Derivee est un cas particulier de Base.
// Une instance de Derivee est tout ce qu'est une instance
// de Base, plus ce qui est specifique à Derivee
class Derivee : public Base {
   // ...
};
int main() {
   Base b;
   Derivee d;
}

Les instances d'une classe héritent des membres publics ou protégés de la classe de laquelle leur propre classe est dérivée. Par exemple :

class Base {
   int val_;
public:
   Base(int val = {}) : val_{ val } {
   }
   int valeur() const noexcept {
      return val_;
   }
   void valeur(int val) noexcept {
      val = val;
   }
};
struct Derivee : Base {
   Derivee(int val) : Base{val} {
   }
   // utilise les méthodes du parent
   void doubler() noexcept {
      valeur(valeur() * 2);
   }
};
#include <iostream>
int main() {
   using namespace std;
   int valeur;
   if (cin >> valeur) {
      Derivee d{ valeur };
      d.doubler();                // valide: d est une Derivee
      cout << d.valeur() << endl; // valide: d est aussi une Base 
   }
}

L'héritage est cumulatif. Chaque classe hérite de son[2] parent, et de tout ce dont son parent avait lui aussi hérité.

Notez qu'une classe n'hérite pas (du moins, pas visiblement) des membres privés de son parent. Ceci rejoint le principe d'encapsulation: cachez les propriétés d'une classe sous sa rubrique private, et construisez-lui des accesseurs et des mutateurs « propres » (dans les deux sens du terme), qui garantissent son intégrité. Ses descendants, en passant par l'interface publique de cet ancêtre, pourront tirer profit des atouts d'implantation de leur parent, mais sans avoir à se préoccuper des détails qui leur sont associés.

Polymorphisme

Arrivons maintenant au noeud de cet article. Bien qu'il ait aujourd'hui plusieurs acceptions techniques distinctes, le polymorphisme repose de prime abord sur l'inférence dynamique de type. Mettons-nous en situation avec un exemple de code ne faisant pas usage de polymorphisme.

Présumons une classe B dévoilant une méthode M(). Si on appelle la méthode M() d'une instance de B, on devrait voir afficher à la console le chiffre 2.

#include <iostream>
struct B {
   // attention: incomplet!
   void M() {
      std::cout << 2 << std::flush;
   }
};

Présumons des classes D1 et D2, toutes deux dérivées de B, et toutes deux avec leur propre version (spécialisée) de M().

On pourra traiter des instances de D1 et de D2 comme des instances de B (par héritage).

Notre exemple (à droite) déclare un tableau de pointeurs à des B, et y affecte l'adresse d'un B, celle d'un D1 et celle d'un D2, ce qui est correct car ce sont, au fond, toutes des B.

struct D1 : B {
   // même signature que B::M()
   void M() {
      std::cout << 4.75 << std::flush;
   }
};
struct D2 : B {
   // même signature que B::M()
   void M() {
      std::cout << "Yo!" << std::flush;
   }
};
int main() {
   B *tab[] = {
      new B,
      new D1,
      new D2
   };
   enum { N = sizeof(tab) / sizeof(tab[0]) };

Quel sera le fruit de l'exécution de la répétitive présentée à droite? Ne trichez pas – la réponse se trouve ci-dessous, mais vous auriez avantage à essayer de répondre par vous-mêmes d'abord.

   for (int i = 0; i < N; ++i) {
      tab[i]-> M();
      delete T[i];
   }
}

Le programme affichera 222. Voici pourquoi :

Une alternative aurait été d'insérer un pointeur nul à la toute fin du tableau en tant que marqueur de fin. Le code du programme deviendrait alors celui proposé à droite.

// ...
int main() {
   B *tab[] = {
      new B,  new D1,  new D2, nullptr
   };
   for (int i = 0; tab[i]; ++i) {
      tab[i]-> M();
      delete tab[i];
   }
}

Mais est-ce nécessairement ce qu'on aurait voulu? Réécrivons notre programme principal de façon légèrement différente :

int main() {
   // Remarquez la différence dans les déclarations...
   B b;
   D1 d1;
   D2 d2;
   // ...de même que dans les appels. Un peu moins agréable, non?
   b.M();
   d1.M();
   d2.M();
}

Celui-ci affichera plutôt 24.75Yo!. Cela vous semble-t-il étrange? C'est le genre de truc qui mérite réflexion.

Dans la premier cas, nous avions un tableau tab de pointeurs à des B qui pointaient effectivement sur un B, un D1 et un D2.

L'appel de leurs méthodes M() appelait toutefois B::M() dans chaque cas du fait que pour main(), tab est de type B*.

Dans le deuxième cas, nous avons une instance de B, une autre de D1 et une de D2.

Or, il se trouve que l'appel de leurs méthodes M() respectives dans la fonction main() appelle tour à tour B::M(), D1::M() et D2::M().

Cela se produit parce que main() connaît leur type effectif – ici, b est clairement de type B, d1 de type D1 et d2 de type D2.

On a donc affaire ici à deux segments de code fort similaires, qui traitent (au fond) des mêmes types d'objets et à peu près de la même manière, tout en obtenant des résultats différents. Ceci peut sembler un problème académique, mais c'est en fait un irritant très concret pour un(e) informaticien(ne).

Il peut pourtant arriver assez fréquemment qu'on veuille que la méthode la plus spécialisée soit appelée dans chaque cas. Appeler la méthode la plus spécialisée possible étant donné une indirection (pointeur ou référence) vers un objet est précisément ce que fait le mécanisme qu'on nomme polymorphisme.

Voici deux exemples où le polymorphisme serait souhaitable.

Une liste de formes, chacune d'entre elles ayant une méthode afficher() qui l'affichera correctement – on n'affichera pas un carré comme on affiche un cercle.

void afficher_formes(Forme *tete) {
   while(tete) {
      tete->afficher(); // magie?
      tete = tete->prochain();
   }
}

Des pièces dans un jeu d'échec, qui doivent se déplacer à l'aide d'une méthode deplacer().

On voudra qu'un cavalier et un pion puissent se déplacer différemment; il serait par ailleurs utile de garder un tableau de type Piece et d'utiliser, pour toute pièce, sa méthode deplacer() pour la déplacer sans avoir à se soucier explicitement du type de pièce.

int n;
Piece *p[NB_PIECES] = { nullptr };
// remplir le tableau
initialiser_pieces(p, NB_PIECES);
do {
   cout << "Bouger quelle pièce?";
   cin >> n; // à valider
} while (n < 0 || MAX_PIECES <= n);
p[n]->deplacer(); // magie?

Nous allons nous concentrer sur un cas simple: une classe Personne, écrite de façon à ce que chaque personne ait un nom et, sur demande, nous dise qui elle est.

Mise en application

On dérivera de Personne les classes Gars et Fille de façon à ce que, si on demande à un Gars qui il est, il réponde "Mr. " suivi de son nom, et de façon à ce que si on demande plutôt à une Fille qui elle est, elle réponde "Mme " suivi de son nom.

La classe Personne ira comme suit.

#include <string>
class Personne {
public:
   using str_type = std::string;
private:
   str_type nom_;
public:
   Personne(const str_type &nom = {}) : nom_{nom} {
   }
   virtual ~Personne() = default;
   str_type nom() const {
      return nom_; }
   }
   virtual str_type qui_suis_je() const {
      return nom();
   }
};

Remarquez que qui_suis_je() peut être est const car elle ne fait affaire qu'avec des méthodes const, assurant ainsi l'intégrité de l'instance active.

Remarquez aussi le précieux mot clé virtual : c'est sur lui que repose, en C++, le polymorphisme.

D'ailleurs, essayez à nouveau l'exemple plus haut (celui utilisant un tableau de B*) avec les classes B, D1 et D2 en n'apportant que la modification suivante.

struct B {
   virtual void M() {
      std::cout << 2 << std::flush;
   }
   virtual ~B() = default;
};

Vous verrez que le simple ajout de ce mot clé à la classe où est déclarée la méthode M() fait en sorte que le programme principal appelle la méthode M() la plus spécifique dans chaque cas. Aucun autre ajout n'est requis au code.

Le mot clé virtual utilisé comme préfixe à une méthode indique au compilateur que sur appel de cette méthode, il sera nécessaire d'inférer dynamiquement le type effectif de l'instance active et d'appeler la version la plus spécialisée de cette méthode, donc de ne pas se laisser prendre au jeu des apparences.

Que B::M() soit virtual fait en sorte que D1::M() le soit aussi, et qu'il en soit de même pour D2::M(). Le caractère polymorphique d'une méthode est implicitement hérité.

int main() {
   B *tab[] = {
      new B, new D1, new D2, nullptr
   };
   for(int i = 0; tab[i]; ++i) {
      tab[i]->M(); 
      delete tab[i];
   }
}

Poursuivant avec notre exemple de Gars et de Fille, on pourra écrire :

struct Gars : Personne {
   Gars(const str_type &nom) : Personne{nom} {
   }
   str_type qui_suis_je() const {
      static const str_type PREFIXE = "Mr. ";
      return PREFIXE + nom();
   }
};
struct Fille : Personne {
   Fille(const str_type &nom) : Personne{nom} {
   }
   str_type qui_suis_je() const {
      static const str_type PREFIXE = "Mme ";
      return PREFIXE + nom();
   }
};

À partir de ces classes, on pourra écrire le programme suivant, qui affichera sur deux lignes différentes "Mr. Guy" et "Mme Sylvie":

#include "Personne.h"
#include <iostream>
using namespace std;
void presenter(ostream &os, const Personne &p) {
   os << p.qui_suis_je() << endl;
}
int main () {
   Gars g = "Guy";     // Gars  : public Personne
   Fille f = "Sylvie"; // Fille : public Personne
   presenter(cout, g);
   presenter(cout, f);
}

Même si p dans presenter() est de type const Personne&, la mécanique de C++, constatant que qui_suis_je() est spécifiée virtual, identifiera à l'exécution que p pointe plus précisément vers un Gars ou plus précisément vers une Fille. Notant qu'il existe pour ces deux types des versions plus spécifiques de la méthode qui_suis_je(), cette version sera celle qui sera utilisée lors de l'appel.

Implantation avec classe abstraite

Il arrive des cas où une classe devrait servir de modèle à un ensemble d'autres classes, mais ne devrait jamais être directement instanciée. Par exemple, on peut imaginer afficher un carré, un cercle ou un losange, mais on ne peut s'imaginer afficher une forme; c'est là quelque chose de trop abstrait.

On aimerait toutefois traiter toutes les formes en fonction de certains points communs. Entre autres, on aimerait que toutes les formes puissent être affichées, et que si on manipule un tableau de formes, il soit possible d'écrire quelque chose comme ce qui suit.

void tres_cool() {
   Forme *tab[] = { new Carre, new Cercle, new Triangle };
   enum { N = sizeof(tab) / sizeof(tab[0]) };
   for (int i = 0; i < N; ++i) {
      tab[i]->afficher(); // vive le polymorphisme!
      delete tab[i];
   }
}

Pour les plus avancées ou les plus entreprenant(e)s parmi vous, comment écririez-vous ce petit programme si vous utilisiez des itérateurs et des algorithmes standards?

En revanche, on voudrait qu'il soit impossible de faire ce qui est proposé à droite...

void pas_raisonnable() {
   Forme f;       // ou Forme *f = new Forme;
   f.afficher(); // ou f->afficher()
}

...parce qu'on serait bien mal pris d'essayer d'exprimer l'affichage d'une forme abstraite. Dans ce genre de cas, on a recours à ce qu'on appelle une classe abstraite.

Une classe abstraite est une classe qui ne peut en aucun cas être instanciée directement, mais dont on peut dériver des sous-classes qui, elles, le seront. Une classe abstraite (p. ex. : Forme) regroupera les traits communs à toutes ses classes dérivées (p. ex. : Cercle, Carre) et permettra de traiter des instances de chacune de ces dérivées comme une instance de la classe abstraite.

Par exemple, on pourrait logiquement ne pas vouloir instancier directement la classe Forme, prétextant qu'une Forme abstraite n'a pas de sens en soi, mais on pourrait vouloir traiter (par polymorphisme, bien entendu) toutes les formes de la même façon, selon un même modèle.

Une classe abstraite, en C++, est une classe qui a au moins une méthode virtuelle dite « pure ». Une méthode virtuelle pure est une méthode virtuelle pour laquelle on n'indique pas de définition, et à laquelle on appose le suffixe = 0.

Exemple
 // cette classe est abstraite à cause de sa méthode f()
class Abstraite {
   // ...
public:
   // cette méthode est une méthode pure virtuelle, donc abstraite
   virtual int f() = 0;
   virtual ~Abstraite() = default;
};
class PasAbstraite { // cette classe n'est pas abstraite
   // ...
public:
   virtual int f(); // virtuelle, mais pas abstraite
   virtual ~PasAbstraite() = default;
};
Exemple plus complet
Classe Forme (abstraite) Classe Carre (pas abstraite)
#include <iosfwd>
struct Forme {
   virtual ~Forme() = default;
   // La classe est abstraite car elle
   // a au moins une méthode virtuelle
   // pure (= 0). Cette méthode ne sera
   // pas définie poour la classe Forme.
   virtual void afficher(std::ostream &) const = 0;
};
#include "Forme.h"
#include <iostream>
class Carre : public Forme {
   int hauteur_;
public:
   int hauteur() const noexcept {
      return hauteur_;
   }
   int largeur() const noexcept {
      return hauteur();
   }
   Carre(int hauteur) noexcept : hauteur_{hauteur} {
   }
   // Carre n'est pas abstraite car elle n'a
   // pas de méthode virtuelle pure – celle
   // qui suit remplace (surcharge) celle de
   // son parent (Forme)
   void afficher(std::ostream &os) const {
      using std::endl;
      for (int i = 0; i < hauteur(); ++i) {
         for (int j = 0; j < largeur(); ++j)
            os << "*";
         os << endl;
      }
   }
};

Implantée comme classe abstraite, la classe Personne aurait l'air de ceci :

#include <string>
class Personne {
public:
   using str_type = std::string;
private:
   str_type nom_;
public:
   Personne(const str_type &nom = {}) : nom_{nom} {
   }
   virtual ~Personne() = default;
   str_type nom() const {
      return nom_;
   }
   virtual str_type qui_suis_je() const = 0;
};

Remarquez qu'on peut avoir autant de méthodes « normales » qu'on le veut dans une classe abstraite (ou non); une classe devient abstraite si elle a au moins une méthode virtuelle pure – ici, la méthode qui_suis_je().

Les classes Gars et de Fille n'ont pas à changer suite à cet ajustement fait à leur parent (ce qui est une bonne chose – les changements locaux sont beaucoup moins coûteux en entretien que les changements dont les répercussions sont vastes et étendues). Du code dans lequel il est possible de localiser les changements est de facto plus rétilisable que du code ne respectant pas cette règle. L'encapsulation est un outil conceptuel précieux et une pratique essentielle au développement de logiciels de qualité.

// ...
#include "Personne.h"
struct Gars : Personne {
   Gars(const str_type &nom) : Personne{nom} {
   }
   str_type qui_suis_je() const {
      static const str_type PREFIXE = "Mr. ";
      return PREFIXE + nom();
   }
};
struct Fille : Personne {
   Fille(const str_type &nom) : Personne{nom} {
   }
   str_type qui_suis_je() const {
      static const str_type PREFIXE = "Mme ";
      return PREFIXE + nom();
   }
};

Le sous-programme suivant montre ce qu'il est permis – et ce qu'il est interdit – de faire si on manipule des classes abstraites.

#include "Personne.h"
#include "Gars.h"
#include "Fille.h"
#include <iostream>
int main() {
   using namespace std;
   // Personne p;   // Serait illégal: Personne est abstraite!
   Personne *pp; // Légal
   Gars g;  // Légal
   Fille f; // Légal
   Personne* tab[2] = { &g, &f }; // Légal
   // pp = new Personne; // Serait illégal: Personne est abstraite!
   // Polymorphisme!
   cout << tab[0]->qui_suis_je() << endl  // Gars  :: qui_suis_je()
        << tab[1]->qui_suis_je() << endl; // Fille :: qui_suis_je()
}

Parenthèse technique : la vtbl

Le polymorphisme dynamique en C++ est implémenté à partir d'une vtbl, c'est-à-dire une table contenant les pointeurs sur les méthodes virtuelles disponibles pour un type donné. Concrètement :

Mauvaise pratique : l'« anti-polymorphisme »

Le polymorphisme remplace une pratique du passé qu'il serait, désormais, de mise de qualifier d'« anti-polymorphisme ». Ce qui suit donne un exemple de cette mauvaise pratique, puisqu'elle est encore possible et peut être utile dans des cas très ciblés[3], mais sans plus.

Par anti-polymorphisme, on parle d'étiqueter un type (par un entier ou une valeur énumérée, par exemple) et de déterminer manuellement les opérations qui lui sont applicables sur la base de cette étiquette. Par exemple :

enum class Sorte { carre, cercle, triangle };
class Forme {
   Sorte sorte;
   // ...
public:
   Forme(Formes sorte) : sorte{sorte) {
   }
   // ...
   void dessiner() {
      switch(sorte) {
      case Sorte::carre:
         // dessiner un carré
         break;
      case Sorte::cercle:
         // dessiner un cercle
         break;
      case Sorte::triangle:
         // dessiner un triangle
         break;
      default:
         // erreur...
         ;
      };
   }
};

L'anti-polymorphisme mène à du code difficile à entretenir :

En bref, l'antipolymorphisme transforme l'évolution du code source en cauchemar. Par opposition, le polymorphisme simplifie drastiquement l'évolution d'un programme :

Lectures complémentaires

Quelques liens pour enrichir le tout.


[1] Ceci est une généralisation, car il y a des contraintes relatives au caractère privé, protégé ou public des membres.

[2] Ou de ses parents – en C++, l'héritage multiple est permis. Mais nous éviterons ici ce dossier.

[3] En fait, cette technique est parfois utile pour manipuler des union étiquetés comme on en trouve par exemple dans les trames UDP.


Valid XHTML 1.0 Transitional

CSS Valide !