IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Penser en Java 2nde édition - Sommaire |  Préface |  Avant-propos | Chapitre : 1  2  3  4  5  6  7  8  9  10  11  12  13  14  15 |  Annexe : A B C D  | Tables des matières - Thinking in Java

  Chapitre 7 - Polymorphisme

pages : 1 2 3 4 

Le polymorphisme est la troisième caractéristique essentielle d'un langage de programmation orienté objet, après l'abstraction et l'héritage.

Le polymorphisme fournit une autre dimension séparant la partie interface de l'implémentation qui permet de découpler le quoi du comment. Le polymorphisme améliore l'organisation du code et sa lisibilité de même qu'il permet la création de programmes extensible qui peuvent évoluer non seulement pendant la création initiale du projet mais également quand des fonctions nouvelles sont désirées.

L'encapsulation crée de nouveaux types de données en combinant les caractéristiques et les comportements. Cacher la mise en œuvre permet de séparer l'interface de l'implémentation en mettant les détails privés [private]. Cette sorte d'organisation mécanique est bien comprise par ceux viennent de la programmation procédurale. Mais le polymorphisme s'occupe de découpler au niveau des types. Dans le chapitre précédant, nous avons vu comment l'héritage permet le traitement d'un objet comme son propre type ou son type de base. Cette capacité est critique car elle permet à beaucoup de types (dérivé d'un même type de base) d'être traités comme s'ils n'étaient qu'un type, et permet a un seul morceau de code de traiter sans distinction tous ces types différents. L'appel de méthode polymorphe permet à un type d'exprimer sa distinction par rapport à un autre, un type semblable, tant qu'ils dérivent tous les deux d'un même type de base. Cette distinction est exprimée à travers des différences de comportement des méthodes que vous pouvez appeler par la classe de base.

Dans ce chapitre, vous allez comprendre le polymorphisme [également appelé en anglais dynamic binding ou late binding ou encore run-time binding] en commençant par les notions de base, avec des exemples simples qui évacuent tout ce qui ne concerne pas le comportement polymorphe du programme.

Upcasting

Dans le chapitre 6 nous avons vu qu'un objet peut être manipulé avec son propre type ou bien comme un objet de son type de base. Prendre la référence d'un objet et l'utiliser comme une référence sur le type de base est appelé upcasting (transtypage en français) , en raison du mode de représentation des arbres d'héritages avec la classe de base en haut.

On avait vu le problème reprit ci-dessous apparaître:


//: c07:music:Music.java
// Héritage & upcasting.
class Note {
  private int value;
  private Note(int val) { value = val; }
  public static final Note
    MIDDLE_C = new Note(0),
    C_SHARP  = new Note(1),
    B_FLAT   = new Note(2);
} // Etc.
class Instrument {
  public void play(Note n) {
    System.out.println("Instrument.play()");
  }
}

// Les objets Wind sont des instruments
// car ils ont la même interface:
class Wind extends Instrument {
  // Redéfinition de la méthode de l'interface:
  public void play(Note n) {
    System.out.println("Wind.play()");
  }
}

public class Music {
  public static void tune(Instrument i) {
    // ...
    i.play(Note.MIDDLE_C);
  }
  public static void main(String[] args) {
    Wind flute = new Wind();
    tune(flute); // Upcasting
  }
} ///:~

La méthode Music.tune() accepte une référence sur un Instrument, mais également sur tout ce qui dérive de Instrument. Dans le main(), ceci se matérialise par une référence sur un objet Wind qui est passée à tune(), sans qu'un changement de type (un cast) soit nécessaire. Ceci est correct; l'interface dans Instrument doit exister dans Wind, car Wind hérite de la classe Instrument. Utiliser l'upcast de Wind vers Instrument peut « rétrécir » cette interface, mais au pire elle est réduite à l'interface complète d'Instrument.

Pourquoi utiliser l'upcast?

Ce programme pourrait vous sembler étrange. Pourquoi donc oublier intentionnellement le type d'un objet? C'est ce qui arrive quand on fait un upcast, et il semble beaucoup plus naturel que tune() prenne tout simplement une référence sur Wind comme argument. Ceci introduit un point essentiel: en faisant ça, il faudrait écrire une nouvelle méthode tune() pour chaque type de Instrument du système. Supposons que l'on suive ce raisonnement et que l'on ajoute les instruments à cordes [Stringed] et les cuivres [Brass]:


//: c07:music2:Music2.java
// Surcharger plutôt que d'utiliser l'upcast.

class Note {
  private int value;
  private Note(int val) { value = val; }
  public static final Note
    MIDDLE_C = new Note(0),
    C_SHARP = new Note(1),
    B_FLAT = new Note(2);
} // Etc.

class Instrument {
  public void play(Note n) {
    System.out.println("Instrument.play()");
  }
}

class Wind extends Instrument {
  public void play(Note n) {
    System.out.println("Wind.play()");
  }
}

class Stringed extends Instrument {
  public void play(Note n) {
    System.out.println("Stringed.play()");
  }
}

class Brass extends Instrument {
  public void play(Note n) {
    System.out.println("Brass.play()");
  }
}

public class Music2 {
  public static void tune(Wind i) {
    i.play(Note.MIDDLE_C);
  }
  public static void tune(Stringed i) {
    i.play(Note.MIDDLE_C);
  }
  public static void tune(Brass i) {
    i.play(Note.MIDDLE_C);
  }
  public static void main(String[] args) {
    Wind flute = new Wind();
    Stringed violin = new Stringed();
    Brass frenchHorn = new Brass();
    tune(flute); // Pas d' upcast
    tune(violin);
    tune(frenchHorn);
  }
} ///:~

Ceci fonctionne, mais avec un inconvénient majeur: il vous faut écrire des classes spécifique à chaque ajout d'une classe Instrument. Ceci implique davantage de programmation dans un premier temps, mais également beaucoup de travail à venir si l'on désire ajouter une nouvelle méthode comme tune() ou un nouveau type d' Instrument. Sans parler du compilateur qui est incapable de signaler l'oubli de surcharge de l'une de vos méthodes qui fait que toute cette construction utilisant les types devient assez compliquée.

Ne serait-il pas plus commode d'écrire une seule méthode qui prenne la classe de base en argument plutôt que toutes les classes dérivées spécifiques? Ou encore, ne serait-il pas agréable d'oublier qu'il y a des classes dérivées et d'écrire votre code en ne s'adressant qu'à la classe de base?

C'est exactement ce que le polymorphisme vous permet de faire. Souvent, ceux qui viennent de la programmation procédurale sont déroutés par le mode de fonctionnement du polymorphisme.

The twist

L'ennui avec Music.java peut être visualisé en exécutant le programme. L'output est Wind.play(). C'est bien sûr le résultat attendu, mais il n'est pas évident de comprendre le fonctionnement.. Examinons la méthode tune():


  public static void tune(Instrument i) {
    // ...
    i.play(Note.MIDDLE_C);
  }

Elle prend une référence sur un Instrument en argument. Comment le compilateur peut-il donc deviner que cette référence sur un Instrument pointe dans le cas présent sur un objet Wind et non pas un objet Brass ou un objet Stringed? Hé bien il ne peut pas. Mieux vaut examiner le mécanisme d'association [binding] pour bien comprendre la question soulevée.

Liaison de l'appel de méthode

Raccorder un appel de méthode avec le corps de cette méthode est appelé association. Quand cette association est réalisée avant l'exécution du programme (par le compilateur et l'éditeur de lien, s'il y en a un), c’est de l’association prédéfinie. Vous ne devriez pas avoir déjà entendu ce terme auparavant car avec les langages procéduraux, c'est imposé . Les compilateurs C n'ont qu'une sorte d'appel de méthode, l'association prédéfinie.

Ce qui déroute dans le programme ci-dessus tourne autour de l'association prédéfinie car le compilateur ne peut pas connaître la bonne méthode a appeler lorsqu'il ne dispose que d'une référence sur Instrument.

La solution s'appelle l' association tardive, ce qui signifie que l'association est effectuée à l'exécution en se basant sur le type de l'objet. L'association tardive est également appelée association dynamique [dynamic binding ou run-time binding]. Quand un langage implémente l'association dynamique, un mécanisme doit être prévu pour déterminer le type de l'objet lors de l'exécution et pour appeler ainsi la méthode appropriée. Ce qui veut dire que le compilateur ne connaît toujours pas le type de l'objet, mais le mécanisme d'appel de méthode trouve et effectue l'appel vers le bon corps de méthode. Les mécanismes d'association tardive varient selon les langages, mais vous pouvez deviner que des informations relatives au type doivent être implantées dans les objets.

Toutes les associations de méthode en Java utilisent l'association tardive à moins que l'on ait déclaré une méthode final. Cela signifie que d'habitude vous n'avez pas à vous préoccuper du déclenchement de l'association tardive, cela arrive automatiquement.

Pourquoi déclarer une méthode avec final? On a vu dans le chapitre précédant que cela empêche quelqu'un de redéfinir cette méthode. Peut-être plus important, cela « coupe » effectivement l'association dynamique, ou plutôt cela indique au compilateur que l'association dynamique n'est pas nécessaire. Le compilateur génère du code légèrement plus efficace pour les appels de méthodes spécifiés final. Cependant, dans la plupart des cas cela ne changera pas la performance globale de votre programme; mieux vaut utiliser final à la suite d'une décision de conception, et non pas comme tentative d'amélioration des performances.

Produire le bon comportement

Quand vous savez qu'en Java toute association de méthode se fait de manière polymorphe par l'association tardive, vous pouvez écrire votre code en vous adressant à la classe de base en sachant que tous les cas des classes dérivées fonctionneront correctement avec le même code. Dit autrement, vous « envoyez un message à un objet et laissez l'objet trouver le comportement adéquat. »

L'exemple classique utilisée en POO est celui de la forme [shape]. Cet exemple est généralement utilisé car il est facile à visualiser, mais peut malheureusement sous-entendre que la POO est cantonnée au domaine graphique, ce qui n'est bien sûr pas le cas.

Dans cet exemple il y a une classe de base appelée Shape et plusieurs types dérivés: Circle, Square, Triangle, etc. Cet exemple marche très bien car il est naturel de dire qu'un cercle est « une sorte de forme. » Le diagramme d'héritage montre les relations :

Image

l'upcast pourrait se produire dans une instruction aussi simple que :


Shape s = new Circle();

On créé un objet Circle et la nouvelle référence est assignée à un Shape, ce qui semblerait être une erreur (assigner un type à un autre), mais qui est valide ici car un Cercle [Circle] est par héritage une sorte de forme [Shape]. Le compilateur vérifie la légalité de cette instruction et n'émet pas de message d'erreur.

Supposons que vous appeliez une des méthode de la classe de base qui a été redéfinie dans les classes dérivées :


s.draw();

De nouveau, vous pourriez vous attendre à ce que la méthode draw() de Shape soit appelée parce que c'est après tout une référence sur Shape. Alors comment le compilateur peut-il faire une autre liaison? Et malgré tout le bon Circle.draw() est appelé grâce à la liaison tardive (polymorphisme).

L'exemple suivant le montre de manière légèrement différente :


//: c07:Shapes.java
// Polymorphisme en Java.

class Shape {
  void draw() {}
  void erase() {}
}

class Circle extends Shape {
  void draw() {
    System.out.println("Circle.draw()");
  }
  void erase() {
    System.out.println("Circle.erase()");
  }
}

class Square extends Shape {
  void draw() {
    System.out.println("Square.draw()");
  }
  void erase() {
    System.out.println("Square.erase()");
  }
}

class Triangle extends Shape {
  void draw() {
    System.out.println("Triangle.draw()");
  }
  void erase() {
    System.out.println("Triangle.erase()");
  }
}

public class Shapes {
  public static Shape randShape() {
    switch((int)(Math.random() * 3)) {
      default:
      case 0: return new Circle();
      case 1: return new Square();
      case 2: return new Triangle();
    }
  }
  public static void main(String[] args) {
    Shape[] s = new Shape[9];
    // Remplissage du tableau avec des formes [shapes]:
    forint i = 0; i < s.length; i++)
      s[i] = randShape();
    // Appel polymorphe des méthodes:
    forint i = 0; i < s.length; i++)
      s[i].draw();
  }
} ///:~

La classe de base Shape établit l'interface commune pour tout ce qui hérite de Shape — C'est à dire, toutes les formes (shapes en anglais) peuvent être dessinées [draw] et effacées [erase]. Les classes dérivées redéfinissent ces méthodes pour fournir un comportement unique pour chaque type de forme spécifique.

La classe principale Shapes contient une méthode statique randShape () qui rend une référence sur un objet sélectionné de manière aléatoire à chaque appel. Remarquez que la généralisation se produit sur chaque instruction return, qui prend une référence sur un cercle [circle], un carré [square], ou un triangle et la retourne comme le type de retour de la méthode, en l'occurrence Shape. Ainsi à chaque appel de cette méthode vous ne pouvez pas voir quel type spécifique vous obtenez, puisque vous récupérez toujours une simple référence sur Shape.

Le main() a un tableau de références sur Shape remplies par des appels a randShape(). Tout ce que l'on sait dans la première boucle c'est que l'on a des objets formes [Shapes], mais on ne sait rien de plus (pareil pour le compilateur). Cependant, quand vous parcourez ce tableau en appelant draw() pour chaque référence dans la seconde boucle, le bon comportement correspondant au type spécifique se produit comme par magie, comme vous pouvez le constater sur l'output de l'exemple :


Circle.draw()
Triangle.draw()
Circle.draw()
Circle.draw()
Circle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Square.draw()

Comme toutes les formes sont choisies aléatoirement à chaque fois, vous obtiendrez bien sûr des résultats différents. L'intérêt de choisir les formes aléatoirement est d'illustrer le fait que le compilateur ne peut avoir aucune connaissance spéciale lui permettant de générer les appels corrects au moment de la compilation. Tous les appels à draw() sont réalisés par liaison dynamique.

Extensibilité

Revenons maintenant à l'exemple sur l'instrument de musique. En raison du polymorphisme, vous pouvez ajouter autant de nouveaux types que vous voulez dans le système sans changer la méthode tune(). Dans un programme orienté objet bien conçu, la plupart ou même toutes vos méthodes suivront le modèle de tune() et communiqueront seulement avec l'interface de la classe de base. Un tel programme est extensible parce que vous pouvez ajouter de nouvelles fonctionnalités en héritant de nouveaux types de données de la classe de base commune. Les méthodes qui utilisent l'interface de la classe de base n'auront pas besoin d'être retouchées pour intégrer de nouvelles classes.

Regardez ce qui se produit dans l'exemple de l'instrument si vous ajoutez des méthodes dans la classe de base et un certain nombre de nouvelles classes. Voici le schéma :

Image

Toutes ces nouvelles classes fonctionnent correctement avec la vieille méthode tune(), sans modification. Même si tune() est dans un fichier séparé et que de nouvelles méthodes sont ajoutées à l'interface de Instrument, tune() fonctionne correctement sans recompilation. Voici l'implémentation du diagramme présenté ci-dessus :


//: c07:music3:Music3.java
// Un programme extensible.
import java.util.*;

class Instrument {
  public void play() {
    System.out.println("Instrument.play()");
  }
  public String what() {
    return "Instrument";
  }
  public void adjust() {}
}

class Wind extends Instrument {
  public void play() {
    System.out.println("Wind.play()");
  }
  public String what() { return "Wind"; }
  public void adjust() {}
}

class Percussion extends Instrument {
  public void play() {
    System.out.println("Percussion.play()");
  }
  public String what() { return "Percussion"; }
  public void adjust() {}
}

class Stringed extends Instrument {
  public void play() {
    System.out.println("Stringed.play()");
  }
  public String what() { return "Stringed"; }
  public void adjust() {}
}

class Brass extends Wind {
  public void play() {
    System.out.println("Brass.play()");
  }
  public void adjust() {
    System.out.println("Brass.adjust()");
  }
}

class Woodwind extends Wind {
  public void play() {
    System.out.println("Woodwind.play()");
  }
  public String what() { return "Woodwind"; }
}

public class Music3 {
// Indépendants des types, ainsi les nouveaux types
  // ajoutés au système marchent toujours bien:
  static void tune(Instrument i) {
    // ...
    i.play();
  }
  static void tuneAll(Instrument[] e) {
    forint i = 0; i < e.length; i++)
      tune(e[i]);
  }
  public static void main(String[] args) {
    Instrument[] orchestra = new Instrument[5];
    int i = 0;
    // Upcasting pendant l'ajout au tableau:
    orchestra[i++] = new Wind();
    orchestra[i++] = new Percussion();
    orchestra[i++] = new Stringed();
    orchestra[i++] = new Brass();
    orchestra[i++] = new Woodwind();
    tuneAll(orchestra);
  }
} ///:~

Ce livre a été écrit par Bruce Eckel ( télécharger la version anglaise : Thinking in java )
Ce chapitre a été traduit par Jérome Dannoville ( groupe de traduction )
télécharger la version francaise (PDF) | Commandez le livre en version anglaise (amazon) | télécharger la version anglaise
pages : 1 2 3 4 
Penser en Java 2nde édition - Sommaire |  Préface |  Avant-propos | Chapitre : 1  2  3  4  5  6  7  8  9  10  11  12  13  14  15 |  Annexe : A B C D  | Tables des matières - Thinking in Java