Le polymorphisme

Introduction

Le polymorphisme correspond à la possibilité pour un opérateur ou une fonction d'être utilisable dans des contextes différents (différenciables par le nombre et le types des paramètres) et d'avoir un comportement adapté à ces paramètres.
Il s'agit d'un élément essentiel qui permet la réutilisation de programmes existants et l'extensibilité des applications.
C'est grâce au polymorphisme que des types définis comme spécialisation d'un même ancêtre vont pouvoir, si besoin est, exprimer leurs différences avec cet ancêtre et entre eux.

On peut distinguer différents types (niveaux) de polymorphisme :: le polymorphisme par sous-typage, le polymorphisme ad-hoc et le polymorphisme généralisé (ou universel).

Le polymorphisme par sous-typage

Il est la forme la plus simple et la plus naturelle de polymorphisme. Il correspond à la possibilité d'invoquer une fonction définie pour un paramètre de type X avec un paramètre de type Y défini comme un sous-type du type X.
Il étend ce qui se passe dans un langage classique tel que C, dans lequel une fonction définie pour un paramètre de type float sera appelable par exemple avec un argument de type int.


   --> cat sousTypage.c
   void f(float x) {
      printf("dans f --> %f\n", x);       
   }
   main() {
      int n = 10;
      char c = 'A';
      f( n );
      f( c );
   }
   --> sousTypage
   dans f --> 10.000000
   dans f --> 65.000000
   -->

Cela est rendu possible par le fait que toute valeur du type int peut être vue comme une valeur du type float (au prix d'un transtypage donnant lieu dans le cas présent à une conversion n'entraînant pas de perte d'information).

Avec l'approche objet, ce qui est fait implicitement sur les types de base (en particulier numériques) doit l'être pour des nouveaux types définis par l'utilisateur. Ainsi un type Y peut être défini comme sous-type d'un type X défini précédemment.
D'un point de vue pratique, définir le sous type Y consiste par exemple à ajouter de nouvelles variables d'instance à celles définies dans le type X, imposer des contraintes sur les valeurs acceptables des variables d'instance ou définir de nouvelles méthodes applicables à l'objet. Ce qui est important dans le mécanisme, c'est qu'un objet de type Y pourra toujours être regardé comme un objet de type X : il suffira d'oublier toutes les spécificités que lui procure son appartenance au type Y.
Ainsi, supposons que nous ayons défini un type Matrice, correspondant à des matrices carrées, on peut concevoir un nouveau type MatriceSymetrique comme sous-type du précédent. Toute matrice symétrique est de fait une matrice ordinaire et toute fonction définie pour une matrice quelconque (par exemple l'ajouter à une autre) devra être applicable à une matrice symétrique.

L'exemple suivant illustre ce que nous avons dit au travers des types Doublet et Triplet défini comme sous-type (ou extension) du précédent :



   --> cat Doublet.java
   public class Doublet{
       int x, y;
       Doublet(int x, int y){
          this.x = x;
          this.y = y;
          }
       int somme(){
          return x+y;
          }
   }
   --> cat Triplet.java
   public class Triplet extends Doublet{
       int y;
       Triplet(int x, int y, int z){
          super(x, y);
          this.y = z;
          }
       void voir(){
          System.out.println(x   + " " + y);
          }
   }               
   --> cat UseUplets.java
   public class UseUplets{
      public static void main(String args[]){
         Doublet x = new Doublet(3, 5);
         Triplet y = new Triplet(7, 8, 19);
         Doublet z = new Triplet(7, 8, 19);
         System.out.println(x.somme());
         System.out.println(y.somme());
         y.voir();
         System.out.println(z.somme());
         // z.voir();   <-- erreur à la compilation    
         ((Triplet)z).voir();
      }
   }
   --> java UseUplets
   8
   15
   7 19
   15
   7 19   

Le polymorphisme ad-hoc

Il s'agit essentiellement de la surcharge (<«overload»).
Plusieurs définitions d'un même nom sont fournies qui se différencient par la liste et le type de leurs paramètres (on parle alors de profils différents)



   --> cat UseC.java
   class C0 { }
   class C1 extends C0{ }
   class C2 extends C1{ }
   class C3 extends C2{ }
   class C{
      static void f(C0 x){ System.out.print("C0");}
      static void f(C1 x){ System.out.print("C1");}    
      static void f(C2 x){ System.out.print("C2");}
      static void f(C3 x){ System.out.print("C3");}
      }
   public class UseC{
      public static void main(String[] arg){
         C0 a = new C0( );
         C1 b = new C1( );
         C2 c = new C2( );
         C3 d = new C3( );
         C.f(a);
         C.f(b);
         C.f(c);
         C.f(d);
         a = new C2();
         C.f(a);
         C.f((C1)a);
         C.f(c);
         }
   }
   --> java UseC
   C0 C1 C2 C3 C0 C1 C2

Si dans la classe C précédente, la seconde définition de la fonction f (celle de signature static void f (C2 x)) est supprimée, on obtient les résultats suivants (la définition de C étant inchangée) :


   --> java UseC    
   C0 C1 C1 C3 C0 C1 C1    

De fait, tout ce qui entraînait l'invocation de la fonction que nous avons supprimée, c'est-à-dire pour un appel avec un paramètre de la classe C2, la fonction invoquée est celle pour laquelle une fonction est accessible avec un paramètre d'une classe en laquelle il est possible de réaliser un transtypage (ici de C2) : si il en existe plusieurs, c'est la plus proche qui est sélectionnée (sur l'exemple il s'agit de C1).

Les choses ne sont cependant pas tout à fait aussi simples comme nous le verrons lorque nous nous intéresserons au contrôle statique des types réalisé par le compilateur.

Le polymorphisme généralisé ou universel

Il correspond à la possibilité de définir des fonctions génériques et suppose la possibilité d'abstraire les types et peut être vu comme la forme ultime du polymorphisme. Un exemple en est une fonction (procédure) permettant l'échange du contenu de deux variables, quel que le soit le type des deux variables (supposées néanmoins de même type). Une invocation d'une telle fonction suppose alors l'instanciation du type.

En Java seules les deux premières formes sont actuellement possibles.

En C++ les «templates» permettent la définition de fonctions génériques comme dans l'exemple suivant qui est une fonction générique d'échange du contenu de deux variables et utilisant une variable de classes :



     #include <iostream.h>
     template <class C> void echanger(C &a, C &b){          
        C x;
        x = a;
        a = b;
        b = x;
     }
     
     struct cpl{ int a, b;};
     typedef cpl couple;
     
     main(){
     int i1 = 12, i2 = 25;
     couple c1, c2;
     c1.a = 7; c1.b = 18;
     c2.a = 20; c2.b = 30;
     echanger(i1, i2);
     cout << "Après échange : i1 = " << i1 << " i2 = " << i2 << endl;      
     echanger(c1, c2);
     cout << "Après échange : c1 = (" << c1.a << ", " << c1.b <<")  ";
     cout << "c2 = (" << c2.a << ", " << c2.b <<")\n";
     }

dont l'éxécution conduit au résultat escompté :


   Après échange : i1=25  i2=12
   Après échange : c1=(20 , 30)   c2=(7 , 18)    

Dans un langage classique tel que C, si on souhaite par exemple écrire une fonction (procédure) simple échangeant les valeurs de deux variables, il est quasiment nécessaire d'écrire autant de fonctions qu'il existe de types auxquels on souhaite pouvoir l'appliquer. Qui plus est, chacune des fonctions doit porter un nom différent : cela conduit à la définition de fonctions echangerInt, echangeFloat, echangeTypeMachine, ....
Néanmoins, pour les inconditionnels du langage, il est possible d'arriver à une solution en jonglant avec les pointeurs et en masquant des détails d'implantation au travers d'une macro-définition (exploitéee par le préprocesseur de C) une fonction transmettant la taille des zones à échanger, comme dans la solution suivante :


    #include <stdio.h>
    #define ECHANGE(x, y) echange(&x, &y, sizeof(x))
    struct cpl {
         int a, b; };
    typedef struct cpl couple;

    void echange(void *ptr1, void *ptr2, int taille){          
    char c, *p1 = (char *) ptr1, *p2 = (char *)ptr2;
    int i;
    for(i = 0; i < taille; i++){
       c = p1[i];
       p1[i] = p2[i];
       p2[i] = c;
       }
    }
    
    main(){
    int i1 = 12, i2 = 25;
    couple c1, c2;
    c1.a = 7; c1.b = 18;
    c2.a = 20; c2.b = 30;
    ECHANGE(i1, i2);
    printf("Après échange : i1=%d  i2=%d\n", i1, i2);
    ECHANGE(c1, c2);
    printf("Après échange : c1=(%d , %d)   c2=(%d , %d%)\n",     
                    c1.a, c1.b, c2.a, c2.b);       
    }

dont l'éxécution conduit au résultat escompté :



   Après échange : i1=25  i2=12
   Après échange : c1=(20 , 30)   c2=(7 , 18)   

Cependant l'effort de programmation est, on en conviendra, non nul.


Dernière mise à jour : 16 juin 2005