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éel — Chien — 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.
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.
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érente | signature identique |
| Dans… | la même classe | une classe fille |
| Résolue… | à la compilation | à l'exécution |
| D'après… | le type déclaré des arguments | le 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.
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 ?
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.
Prédisez quelle méthode s'exécute, écrivez une boucle sans test de type, puis violez le contrat equals/hashCode.
// ── 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");
En travaux pratiques
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.
- Le TP 5 : héritage et substitution
- 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. 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. 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. 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. 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. 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. 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. 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.
- 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
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.