C# – Polymorphisme (dynamique)

Ce texte est un texte de surface; pour une discussion plus en profondeur sur les sujets présentés ici, vous voudrez peut-être examiner ce texte sur le polymorphisme dynamique en C++ : ../Divers--cplusplus/Polymorphisme.html

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 ».

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 sommaire, en ne permettant que l'héritage d'implémentation simple et public (mais en acceptant l'héritage multiple d'interfaces).

Exemple
Base b = new ();
Derivee d = new ();

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 : Base
{
   // ...
}

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 :

using System;

int valeur = int.Parse(Console.ReadLine());
Derivee d = new (valeur);
d.Doubler(); // valide: d est une Derivee
Console.WriteLine(d.Valeur); // valide: d est aussi une Base 

class Base
{
   public int Valeur { get; protected set; } = 0;
   public Base()
   {
   }
   public Base(int valeur)
   {
      Valeur = valeur;
   }
}
class Derivee : Base
{
   public Derivee(int valeur) : base(valeur)
   {
   }
   // utilise les propriétés du parent
   public void Doubler()
   {
      Valeur = Valeur * 2;
   }
}

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 : qualifiez les propriétés d'une classe de manière private dans la mesure du possible, 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.

using System;

// le programme principal (plus bas) va ici!

class B
{
   // attention: incomplet!
   public void M()
   {
      Console.Write(2);
   }
}

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 références sur des B, et y affecte une référence sur un B, une référence sur un D1 et une référence sur un D2, ce qui est correct car ce sont, au fond, toutes des B. Votre compilateur acceptera ce code, mais probablement avec des avertissements...

class D1 : B
{
   // même signature que B.M
   public void M()
   {
      Console.Write(4.75);
   }
}
class D2 : B
{
   // même signature que B.M
   pulic void M()
   {
      Console.Write("Yo!");
   }
}

// programme principal (va en haut!)
B [] tab = new B[]{ new B(), new D1(), new D2() };

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.

foreach(B b in tab)
   tab[i].M();

Le programme affichera 222. Voici pourquoi :

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

// Remarquez la différence dans les déclarations...
B b = new B();
D1 d1 = new D1();
D2 d2 = new 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 références sur des B qui référaient 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 le programme principal, 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 le programme principal appelle tour à tour B.M, D1.M et D2.M. Cela se produit parce que le programme principal connaît leur type effectif – ici, b réfère clairement de type B, d1 réfère clairement à un D1 et d2 réfère clairement à un 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(Liste<Forme> formes)
{
   foreach(Forme f in formes)
      f.Afficher();
}

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

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 Pièce et d'utiliser, pour toute pièce, sa méthode Déplacer pour la déplacer sans avoir à se soucier explicitement du type de pièce.

List<Pièce> pièces = CréerPièces();
int n;
do
{
   Console.Write("Bouger quelle pièce? ");
   n = int.Parse(Console.ReadLine());
}
while (n < 0 || pièces.Count <= n);
pièces[n].Déplacer(); // 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.

class Personne
{
   public string Nom{ get; }
   public Personne(string nom)
   {
      Nom = nom;
   }
   virtual string QuiSuisJe() => Nom;
}

Remarquez le précieux mot clé virtual : c'est sur lui (et sur un autre mot, override) que repose, en C#, le polymorphisme.

D'ailleurs, essayez à nouveau l'exemple plus haut (celui utilisant un tableau de références sur des B) avec les classes B, D1 et D2 en n'apportant que les modifications suivantes (ajout des mots virtual et override aux méthodes M) :

class B
{
   public virtual void M()
   {
      Console.Write(2);
   }
}
class D1 : B
{
   // même signature que B.M
   public override void M()
   {
      Console.Write(4.75);
   }
}
class D2 : B
{
   // même signature que B.M
   pulic override void M()
   {
      Console.Write("Yo!");
   }
}

Vous verrez que le simple ajout de ces mots clés à aux classes 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 (en plus, les avertissements à la compilation disparaissent!)

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. Dans les classes dérivées, le mot override informe le compilateur que la spécialisation est intentionnelle (C# est un langage où il faut souvent confirmer plus d'une fois ses intentions).

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é. Cela signifie que les enfants de D1 ou de D2 pourraient spécialiser M à nouveau.

B [] tab = new B[]{ new B(), new D1(), new D2() };
foreach(B b in tab)
   b.M();

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

class Gars : Personne
{
   public Gars(string nom) : base(nom)
   {
   }
   public override string QuiSuisJe() => "Mr. " + base.Nom;
}
class Fille : Personne
{
   Fille(string nom) : base(nom)
   {
   }
   public override string QuiSuisJe() => "Mme. " + base.Nom;
}

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

using System;

Gars g = new Gars("Guy");      // Gars  : Personne
Fille f = new Fille("Sylvie"); // Fille : Personne
Présenter(g);
Présenter(f);

static void Présenter(Personne p)
{
   Console.WriteLine(p.QuiSuisJe());
}

Même si p dans Présenter est une référence de Personne, la mécanique de C#, constatant que QuiSuisJe est spécifiée virtual, identifiera à l'exécution que p réfère plus précisément à un Gars ou plus précisément à une Fille. Notant qu'il existe pour ces deux types des versions plus spécifiques de la méthode QuiSuisJe, cette version sera celle qui sera utilisée lors de l'appel.

Propriétés polymorphiques

Il est possible d'offrir des propriétés polymorphiques en C#. Pour un exemple naïf :

class Forme
{
   public int Hauteur { get; }
   // en général, hauteur et largeur sont la même chose (supposons...)
   public virtual int Largeur { get => Hauteur; }
   public Forme(int hauteur)
   {
      Hauteur = hauteur;
   }
   // ...
}
class Rectangle : Forme
{
   // en général, hauteur et largeur sont la même chose (supposons...)
   int largeur;
   public override int Largeur { get => largeur; }
   public Rectangle(int hauteur, int largeur) : base(hauteur)
   {
      this.largeur = largeur;
   }
   // ...
}

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 :

static void TrèsCool()
{
   Forme [] tab = new Forme[]{ new Carré(), new Cercle(), new Triangle() };
   foreach(Forme f in tab)
      f.Afficher(); // vive le polymorphisme!
}

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

static void PasRaisonnable()
{
   Forme f = new Forme();
   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, Carré) 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 abstract. Une méthode abstract est comme une méthode virtual, mais pour laquelle on n'indique pas de définition.

Exemple
// cette classe est abstraite à cause de sa méthode F()
abstract class Abstraite
{
   // ...
   public abstract int F(); // abstraite (pas de définition)
}
// cette classe n'est pas abstraite
class PasAbstraite
{
   // ...
   public virtual int F()  // virtuelle, mais pas abstraite
   {
      // ...
   }
};
Exemple plus complet
Classe Forme (abstraite) Classe Carre (pas abstraite)
abstract class Forme
{
   // La classe est abstraite car elle
   // a au moins une méthode abstraite.
   // Cette méthode ne sera pas définie
   // pour la classe Forme.
   public abstract void Afficher();
}
class Carré : Forme
{
   public int Hauteur{ get; }
   public int Largeur {get => Hauteur; }
   public Carré(int hauteur)
   {
      Hauteur = hauteur;
   }
   // Carré n'est pas abstraite car elle n'a
   // pas de méthode abstract – celle
   // qui suit remplace (surcharge) celle de
   // son parent (Forme)
   public override void Afficher()
   {
      for (int i = 0; i < Hauteur; ++i)
      {
         for (int j = 0; j < Largeur; ++j)
            Console.Write("*");
         Console.WriteLine();
      }
   }
}

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

abstract class Personne
{
   public string Nom { get; }
   public Personne(string nom)
   {
      Nom = nom;
   }
   public abstract string QuiSuisJe();
};

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

Propriétés abstraites

Notez qu'une propriété peut aussi être abstract en C#, bien que ce soit inhabituel. Pour un exemple totalement artificiel :

abstract class Entier
{
   public abstract int Valeur { get; }
}
class Trois : Entier
{
   public override int Valeur { get => 3; }
}

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é.

// ...
class Gars : Personne
{
   public Gars(string nom) : base(nom)
   {
   }
   public override string QuiSuisJe() => "Mr. " + Nom;
}
class Fille : Personne
{
   public Fille(string nom) : base(nom)
   {
   }
   public override QuiSuisJe() => "Mme " + 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.

// Personne p = new ();   // Serait illégal: Personne est abstraite!
Personne pp; // Légal, référence non-initialisée (y affecter null serait gentil)
Gars g = new ("Guy");  // Légal
Fille f = new ("Sylvie"); // Légal
Personne [] tab = new Personne[]{ g, f }; // Légal
// Polymorphisme!
Console.WriteLine(tab[0].QuiSuisJe()); // Gars.QuiSuisJe
Console.WriteLine(tab[1].QuiSuisJe()); // Fille.QuiSuisJe

Mauvaise pratique : l'« antipolymorphisme »

Le polymorphisme remplace une pratique du passé qu'il serait, désormais, de mise de qualifier d'« antipolymorphisme ». 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, mais sans plus.

Par antipolymorphisme, 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 SorteForme { carré, cercle, triangle }
class Forme
{
   public SorteForme Sorte { get; }
   // ...
   public Forme(SorteForme sorte)
   {
      Sorte = sorte;
   }
   public void Dessiner()
   {
      switch(Sorte)
      {
      case SorteForme.carré:
         // dessiner un carré
         break;
      case SorteForme.cercle:
         // dessiner un cercle
         break;
      case SorteForme.triangle:
         // dessiner un triangle
         break;
      default:
         // erreur...
         break; // obligatoire en C#... sans commentaire
      }
   }
};

Autre signe d'antipolymorphisme : opérer au mauvais niveau d'abstraction. Par exemple :

class Volume
{
   // trucs propres à un volume ...
}
class Sphère : Volume
{
   // trucs propres à une sphère
}
class Boîte : Volume
{
   // trucs propres à une boîte
}
//
// quelle horreur! Lourd, lent, complexe, et un cauchemar à entretenir lors de l'ajout de types de volumes!
//
bool Collisionner(Volume v0, Volume v1)
{
   if (v0 is Sphère a)
   {
      if (v1 is Sphère b)
      {
         // tester une collision Sphère / Sphère entre a et b
      }
      else if (v1 is Boîte c)
      {
         // tester une collision Sphère / Boîte entre a et c
      }
      else
      {
         // type de volume imprévu... lever une exception?
      }
   }
   else if(v0 is Boîte aa)
   {
      if (v1 is Sphère b)
      {
         // tester une collision Boîte / Sphère entre aa et b
      }
      else if (v1 is Boîte c)
      {
         // tester une collision Boîte / Boîte entre aa et c
      }
      else
      {
         // type de volume imprévu... lever une exception?
      }
   }
   else
   {
      // type de volume imprévu... lever une exception?
   }
}

Notez que C# supporte maintenant le Pattern Matching, ce qui allège l'écriture de ce genre de code, mais ne facilite pas plus son entretien... Ne faites pas ça si le nombre de cas à couvrir peut changer un jour dans le futur!

L'antipolymorphisme 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, pour les langages qui supportent cette idée. Ce n'est pas le cas de C#.


Valid XHTML 1.0 Transitional

CSS Valide !