cursus.

Cours 3 · Héritage et polymorphismeLeçon 2 sur 2

Polymorphisme

8 h de lecture11 sections Version PDF

À la fin de cette leçon, vous saurez

Liaison dynamique, référence de type parent vers objet fils, transtypage et instanceof, classes abstraites, interfaces, Object, toString et equals.

Une question, et une seule, occupe ce chapitre :

Animal a = new Chien();a.crier();                 /* laquelle des deux méthodes crier() s'exécute ? */

La variable est déclarée Animal. L'objet est un Chien. Les deux classes possèdent une méthode crier(). Laquelle part ?

C'est l'obstacle de l'année, et il mérite une séance entière. Non parce que la réponse est compliquée — elle tient en une phrase — mais parce qu'elle exige de tenir deux types en tête en même temps, et de savoir lequel gouverne quoi.

Deux types pour une seule variable

Animal a = new Chien();

Cette ligne met en jeu deux types différents.

Le type déclaréAnimal — est ce que le compilateur connaît. Il décide de ce qu'on a le droit d'appeler.

Le type réelChien — est la nature effective de l'objet créé. Elle ne changera plus jamais, et elle décide de ce qui s'exécute.

Toute la difficulté du chapitre est là, et toute son utilité aussi.

Animation · étape 1 / 80:00 / 0:12

Deux types différents cohabitent dans une seule ligne. À gauche du signe, le type DÉCLARÉ : ce que le compilateur croira savoir. À droite, le type RÉEL de l'objet créé, qui ne changera plus jamais. C'est cette dissociation qui rend tout le chapitre difficile — et utile.

Prêt à lancer · 0:00 / 0:12
Étapes

La liaison dynamique

La règle, dans sa formulation exacte : le compilateur vérifie l'appel sur le type déclaré ; la machine choisit la méthode sur le type réel.

C'est la liaison dynamique (late binding), et c'est le mécanisme par défaut en Java pour toutes les méthodes d'instance.

Il faut immédiatement la mettre en regard de la surcharge du chapitre 4, car les deux mots se ressemblent et les deux mécanismes s'opposent :

Surcharge (overload)Redéfinition (override)
Même nom, et…signature différentesignature identique
Dans…la même classeune classe fille
Résolue…à la compilationà l'exécution
D'après…le type déclaré des argumentsle type réel de l'objet

Confondre les deux est l'erreur la plus fréquente du bloc III, et elle produit le classique piège d'examen : une méthode surchargée sur le type du paramètre semble « polymorphe » et ne l'est pas. journaliser(Animal a) et journaliser(Chien c) sont choisies par le compilateur d'après le type déclaré — donc journaliser(a) appellera la version Animal, même si l'objet est un Chien. Seule la redéfinition regarde le type réel.

Ce que le polymorphisme permet d'écrire

Voici le bénéfice, et il justifie les huit heures.

List<Forme> formes = List.of(new Cercle(2), new Rectangle(3, 4), new Triangle(3, 4, 5)); double total = 0;for (Forme f : formes) {    total += f.aire();          /* chaque objet répond à SA façon */}

Cette boucle ne contient aucun test de type. Elle ne sait pas quelles formes existent, et elle n'a pas besoin de le savoir : chaque objet sait calculer son aire.

La conséquence est ce qui compte : ajouter un Losange ne demande de modifier aucune ligne de cette boucle. On écrit la nouvelle classe, on l'ajoute à la liste, et le code existant la traite. Comparez avec la version procédurale :

if (f.type == CERCLE)         total += Math.PI * f.rayon * f.rayon;else if (f.type == RECTANGLE) total += f.largeur * f.hauteur;else if (f.type == TRIANGLE)  ...

Ici, chaque nouvelle forme oblige à retrouver tous les if du programme — et il y en a partout : le calcul d'aire, le périmètre, l'affichage, la sauvegarde. C'est exactement la quatrième panne du chapitre 1, et le polymorphisme est ce qui la supprime.

La formule à retenir : le polymorphisme remplace les tests de type par de la liaison dynamique. Un programme objet truffé de if (x instanceof ...) est un programme qui n'a pas encore été écrit en objet.

Transtypage et instanceof

Le transtypage montant — vers le parent — est implicite et toujours sûr :

Animal a = new Chien();        /* un Chien EST un Animal */

Le transtypage descendant — vers la fille — doit être écrit, et il est risqué :

Chien c = (Chien) a;           /* compile, et peut échouer à l'exécution */

Le compilateur l'accepte parce qu'il ne peut pas savoir ; la machine vérifie et lève une ClassCastException si l'objet n'est pas du bon type. Un transtypage descendant est donc une promesse faite au compilateur, vérifiée seulement à l'exécution.

D'où la garde :

if (a instanceof Chien) {    ((Chien) a).aboyer();}

Java moderne permet de condenser en if (a instanceof Chien c) { c.aboyer(); }, ce qui évite le double nom et le transtypage explicite.

Un dernier mot, qui est un critère de conception : un code plein d'instanceof signale généralement une méthode manquante. Si l'on teste le type pour choisir un comportement, ce comportement devrait être une méthode redéfinie dans chaque classe. instanceof est légitime pour equals, pour dialoguer avec du code ancien, ou pour un traitement réellement extérieur à la hiérarchie — rarement ailleurs.

Classes abstraites

Que doit rendre Forme.aire() ? Rien de sensé : une forme sans plus de précision n'a pas d'aire. On déclare donc la méthode sans corps, et la classe devient abstraite.

public abstract class Forme {    private final String nom;     public Forme(String nom) { this.nom = nom; }        /* un constructeur ! */     public abstract double aire();                       /* pas de corps */     public String decrire() {                            /* méthode concrète */        return nom + " d'aire " + aire();                /* appelle l'abstraite */    }}

Quatre propriétés à connaître.

On ne peut pas l'instancier : new Forme("x") est refusé, ce qui est cohérent — l'objet serait incomplet.

Elle peut avoir un constructeur, appelé par super(...) depuis les filles. Cela surprend souvent, et c'est indispensable pour initialiser la partie commune.

Elle mélange abstrait et concret. decrire() est écrite une fois pour toutes, et appelle aire() qu'elle ne connaît pas encore : c'est le polymorphisme employé à l'intérieur de la classe mère, et c'est le motif le plus puissant du chapitre.

Toute fille concrète doit implémenter les méthodes abstraites, sinon elle est abstraite à son tour et ne peut pas être instanciée. Le compilateur l'impose, ce qui transforme un oubli en erreur de compilation plutôt qu'en bogue.

Interfaces

Une interface pousse l'abstraction à son terme : elle ne contient qu'un contrat, sans attribut d'instance et, historiquement, sans aucune implémentation.

public interface Empruntable {    boolean emprunter(String qui);    void rendre();} public class Livre extends Document implements Empruntable, Comparable<Livre> {    @Override public boolean emprunter(String qui) { ... }    @Override public void rendre() { ... }    @Override public int compareTo(Livre autre) { ... }}

La différence décisive avec une classe abstraite est celle-ci : une classe Java n'hérite que d'une seule classe, mais peut implémenter autant d'interfaces qu'elle veut.

Java interdit l'héritage multiple de classes pour une raison précise, le problème du diamant : si C héritait de A et de B qui redéfinissent tous deux f(), quelle version C recevrait-elle ? C++ répond par des règles compliquées ; Java refuse la question. Les interfaces n'ont pas ce problème, puisqu'elles n'apportent — en principe — aucune implémentation à hériter.

Le critère de choix, à retenir tel quel. Une classe abstraite exprime « est un », partage du code et des attributs, et il n'y en a qu'une. Une interface exprime « sait faire », n'apporte qu'un contrat, et on peut en cumuler. Un Livre est un Document — héritage — et sait être emprunté et comparé — interfaces.

Depuis Java 8, une interface peut fournir des méthodes default avec un corps, ce qui brouille un peu la frontière. Le critère reste le bon : les attributs d'instance, eux, demeurent réservés aux classes.

Object, toString, equals

Toute classe Java hérite, directement ou non, de Object. Trois de ses méthodes se redéfinissent constamment.

toString() rend une représentation textuelle. Par défaut, elle affiche Livre@1b6d3586 — le nom de la classe et un code de hachage, inutile. La redéfinir est le premier geste de confort, et elle est appelée automatiquement par System.out.println et par la concaténation de chaînes.

equals(Object) définit l'égalité de contenu. Sans redéfinition, elle teste l'identité — « est-ce le même objet ? » — ce qui donne le piège le plus fréquent du chapitre :

String a = new String("bonjour");String b = new String("bonjour");a == b          /* false : deux objets distincts */a.equals(b)     /* true  : même contenu */

On ne compare jamais deux objets avec == en Java, sauf pour tester délibérément l'identité. Le == sur des chaînes semble parfois fonctionner — à cause d'un cache de littéraux — ce qui le rend d'autant plus traître.

hashCode() doit être redéfinie en même temps que equals, et le contrat est strict : deux objets égaux doivent avoir le même code de hachage. Le violer casse silencieusement les HashMap et les HashSet du chapitre 9 — un objet rangé devient introuvable, sans aucune erreur.

Quiz · vérifiez votre compréhension Sans réponse

Une classe Journal déclare journaliser(Animal a) et journaliser(Chien c). On écrit Animal a = new Chien(); j.journaliser(a); Laquelle est appelée ?

Quiz · vérifiez votre compréhension Sans réponse

Quand préférer une interface à une classe abstraite ?

À vous

L'exercice est le plus important du semestre, et il est bâti sur la question d'ouverture.

D'abord une table de distribution : pour chaque appel, vous prédisez quelle méthode s'exécute, puis le programme vous le dit. Les cas sont choisis pour que type déclaré et type réel diffèrent, et l'un d'eux est une surcharge déguisée en polymorphisme — c'est le piège du premier quiz, et il faut se faire prendre une fois.

Ensuite la boucle sur des formes hétérogènes, sans un seul test de type. Vous ajouterez une nouvelle forme et vérifierez qu'aucune ligne existante ne change. Puis vous écrirez la version à instanceof pour mesurer ce que le polymorphisme évite.

Enfin equals : la différence entre == et equals, puis le contrat equals/hashCode délibérément violé, et un objet qui devient introuvable dans un ensemble où il se trouve pourtant.

Exercice · JavaScript · à vous de jouer

Prédisez quelle méthode s'exécute, écrivez une boucle sans test de type, puis violez le contrat equals/hashCode.

En attente
// ── 1. Quelle méthode s'exécute ? ─────────────────────────────────────────
class Animal {
  crier() { return "..."; }
  presenter() { return "je suis un animal et je fais " + this.crier(); }
}
class Chien extends Animal {
  crier() { return "Ouaf"; }
  aboyer() { return "Grrr"; }
}
class Chat extends Animal {
  crier() { return "Miaou"; }
}

// Une SURCHARGE, pas une redéfinition : deux méthodes différentes, dans la
// même classe, choisies d'après le type DÉCLARÉ de l'argument.
class Journal {
  journaliser(x) {
    // JavaScript n'a pas de surcharge : on simule la résolution que le
    // compilateur Java ferait, à partir du type DÉCLARÉ qu'on lui passe.
    return "à écrire";
  }
}

// Chaque appel est décrit par son type déclaré et son objet réel.
const APPELS = [
  { code: "a.crier()",              declare: "Animal", objet: new Chien(), attendu: "?" },
  { code: "a.presenter()",          declare: "Animal", objet: new Chat(),  attendu: "?" },
  { code: "j.journaliser(a)",       declare: "Animal", objet: new Chien(), attendu: "?" },
  { code: "a.aboyer()",             declare: "Animal", objet: new Chien(), attendu: "?" },
];

// ── 2. Une boucle sans aucun test de type ─────────────────────────────────
class Forme {
  constructor(nom) { this.nom = nom; }
  aire() { throw new Error("Forme est abstraite"); }
  decrire() { return this.nom + " d'aire " + this.aire().toFixed(2); }
}
class Cercle extends Forme {
  constructor(r) { super("cercle"); this.r = r; }
  aire() { return Math.PI * this.r * this.r; }
}
class Rectangle extends Forme {
  constructor(l, h) { super("rectangle"); this.l = l; this.h = h; }
  aire() { return this.l * this.h; }
}

// ── 3. == contre equals ───────────────────────────────────────────────────
class Livre {
  constructor(isbn, titre) { this.isbn = isbn; this.titre = titre; }
  // ← à écrire : deux livres sont égaux s'ils ont le même ISBN
  equals(autre) { return this === autre; }
  hashCode() { return 0; }   // ← contrat violé volontairement
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. PRÉDISEZ le résultat des quatre appels AVANT de lancer, puis complétez
//    journaliser() pour simuler la résolution de surcharge de Java.
// 2. Ajoutez une classe Triangle : combien de lignes existantes changent ?
//    Écrivez ensuite la version à instanceof, pour comparer.
// 3. Écrivez equals sur l'ISBN, puis un hashCode cohérent. Constatez ce qui
//    se passe dans un Set quand le contrat est violé.

for (const ap of APPELS) console.log("   " + ap.code.padEnd(22) + " -> à prédire");

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 6 · sur machine

Le troisième point qui coince : qui est appelé ?

Voir la liaison dynamique choisir la méthode sur le type RÉEL, la distinguer définitivement de la surcharge, et faire disparaître une cascade de tests de type.

4 h
Avant de commencer
  • Le TP 5 : héritage et substitution
  1. 1. La question de base

    Déclarez une variable de type Document contenant un Livre, et appelez une méthode redéfinie. Prédisez la sortie AVANT d'exécuter.

  2. 2. L'expérience décisive

    Dans la même classe, écrivez une méthode redéfinie ET une méthode surchargée prenant un Document et un Livre. Appelez les deux sur une variable déclarée Document contenant un Livre. Comparez.

  3. 3. La cascade de tests

    Écrivez une méthode qui calcule une pénalité de retard avec une suite de tests sur le type de document. Ajoutez ensuite un quatrième type et comptez les endroits à modifier.

  4. 4. La faire disparaître

    Remplacez la cascade par une méthode redéfinie dans chaque sous-classe. Ajoutez de nouveau un cinquième type et recomptez.

  5. 5. Abstraite ou interface

    Écrivez la même chose une fois avec une classe abstraite, une fois avec une interface. Dressez la liste des différences, et dites laquelle vous choisiriez ici.

  6. 6. equals et hashCode

    Redéfinissez equals sur Document. Mettez deux documents identiques dans un ensemble et constatez qu'ils y sont tous les deux. Corrigez.

  7. 7. Le contrat

    Écrivez un test qui vérifie les quatre propriétés d'equals, puis cassez-en une volontairement et observez le comportement de l'ensemble.

  8. 8. Au fil rouge

    Faites afficher par Mediatheque tout son catalogue en une seule boucle, sans un seul test de type, chaque document sachant se décrire.

C'est réussi quand
  • Vous prédisez correctement les deux sorties de l'étape 2
  • Ajouter un type de document ne modifie plus aucun code existant
  • Deux documents égaux ne rentrent qu'une fois dans un ensemble

Ce que la suite en fait

Le bloc IV rend le programme robuste et modélisable. Les exceptions du chapitre 7 sont elles-mêmes une hiérarchie polymorphe : attraper Exception attrape toutes ses filles, et c'est exactement la substituabilité de ce chapitre appliquée aux erreurs.

Le chapitre 8 donnera enfin la notation qui permet de dessiner tout cela : une flèche creuse pour l'héritage, une flèche pointillée pour l'implémentation d'interface — et l'on constatera qu'un diagramme de dix classes se lit en une minute là où le code demande une heure.

À retenir

Flashcards · 1 / 6Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 11 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.