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).
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
-->
|
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
|
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
|
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.
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";
}
|
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.