Programmation orientée objet · C3 Héritage et polymorphisme · Chapitre 2 · 8 h
Polymorphisme
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.
Animation · 8 étapes
Type déclaré contre type réel : qui décide de la méthode exécutée ?
- Une référence Animal vers un objet Chien — 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.
- a.crier() — ce que fait le COMPILATEUR — À la compilation, seul le type déclaré est connu. Le compilateur vérifie qu'`Animal` possède bien une méthode `crier()` : c'est le cas, l'appel est accepté. Il ne sait rien, et ne veut rien savoir, de l'objet qui sera réellement là.
- a.crier() — ce que fait la MACHINE — À l'exécution, la machine regarde le type RÉEL de l'objet et appelle la version la plus spécifique : `Chien.crier()`. C'est la liaison dynamique, et c'est tout le polymorphisme. Le compilateur autorise, l'exécution choisit.
- Le même appel, un autre objet — Remplacez `new Chien()` par `new Chat()` : la ligne `a.crier()` n'a pas changé d'un caractère, et le comportement change. C'est ce qui permet d'écrire du code qui traite uniformément des objets dont on ignore le type exact.
- a.aboyer() — refusé à la compilation — L'objet EST un Chien et possède bien `aboyer()`. Pourtant le code ne compile pas : le compilateur ne voit qu'un `Animal`, et `Animal` n'a pas cette méthode. Le type déclaré limite ce qu'on a le DROIT d'appeler, le type réel décide de ce qui S'EXÉCUTE.
- ((Chien) a).aboyer() — le transtypage — Le transtypage ne transforme pas l'objet : il change ce que le compilateur croit savoir de la référence. L'objet était déjà un Chien ; on cesse simplement de le regarder comme un Animal.
- ((Chat) a).crier() — accepté, puis rejeté — Cette ligne COMPILE : rien n'interdit a priori qu'un Animal soit un Chat. Mais à l'exécution, l'objet est un Chien, et la machine lève une ClassCastException. Un transtypage descendant est une promesse faite au compilateur, vérifiée seulement à l'exécution.
- La parade : demander avant d'affirmer — `if (a instanceof Chat)` interroge le type RÉEL et rend false : on n'entre pas, et rien ne casse. Un transtypage descendant sans test préalable est un pari ; avec le test, c'est une vérification. Et un code truffé d'instanceof signale généralement qu'une méthode redéfinie aurait mieux fait l'affaire.
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.
Quiz · 1 question
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 ?
- journaliser(Chien), car l'objet est réellement un Chien : c'est le polymorphisme — version Chien
- journaliser(Animal), car il s'agit d'une SURCHARGE, résolue par le compilateur sur le type DÉCLARÉ de l'argument — seule la redéfinition regarde le type réel — version Animal
- Le code est ambigu et ne compile pas — ambigu
Réponse : C'est le piège d'examen du bloc III, et il repose sur la confusion entre deux mécanismes aux noms voisins. Ici il n'y a AUCUNE redéfinition : deux méthodes différentes, de signatures différentes, dans la même classe — c'est une surcharge. Or la surcharge est résolue par le COMPILATEUR, d'après les types déclarés, exactement comme au chapitre 4. Le compilateur voit un argument déclaré Animal et choisit journaliser(Animal) ; le fait que l'objet soit un Chien ne l'intéresse pas, et la machine n'a plus aucun choix à faire au moment de l'appel. Pour obtenir le comportement attendu, il faudrait une méthode journaliser() REDÉFINIE dans Animal et Chien, appelée sur l'objet — a.journaliser() — car seule la redéfinition consulte le type réel. Retenir la ligne du tableau : surcharge = compilation + type déclaré ; redéfinition = exécution + type réel.
Quiz · 1 question
Quand préférer une interface à une classe abstraite ?
- Toujours : les interfaces sont plus modernes et remplacent les classes abstraites depuis Java 8 — toujours
- Quand on exprime « SAIT FAIRE » plutôt que « EST UN », qu'on n'a ni code ni attribut à partager, et qu'une classe doit pouvoir en cumuler plusieurs — une classe n'hérite que d'une classe mais implémente autant d'interfaces qu'elle veut — sait faire contre est un
- Quand la hiérarchie a plus de trois niveaux, l'héritage devenant alors trop profond — profondeur de hiérarchie
Réponse : Les deux outils répondent à deux besoins distincts. Une CLASSE ABSTRAITE exprime « est un », partage du CODE et des ATTRIBUTS, peut avoir un constructeur appelé par super(...) — et il n'y en a qu'une, Java interdisant l'héritage multiple de classes à cause du problème du diamant. Une INTERFACE exprime « sait faire », n'apporte qu'un contrat, et se cumule sans limite. L'exemple canonique tient les deux : un Livre EST un Document (héritage) et SAIT ÊTRE emprunté et comparé (interfaces). Les méthodes default de Java 8 brouillent un peu la frontière en autorisant du code dans une interface, mais le critère reste valable : les attributs d'instance demeurent réservés aux classes. Et la profondeur de la hiérarchie est un autre sujet — un signal de mauvaise conception, mais qui ne se corrige pas en remplaçant l'héritage par des interfaces.
À 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 de code
Prédisez quelle méthode s'exécute, écrivez une boucle sans test de type, puis violez le contrat equals/hashCode.
Point de départ
// ── 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");
Solution
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"; }
}
class Journal {
// On simule ce que le COMPILATEUR Java fait : il choisit d'après le type
// DÉCLARÉ, sans jamais regarder l'objet réel.
journaliser(typeDeclare) {
if (typeDeclare === "Chien") return "journaliser(Chien) : un chien précis";
return "journaliser(Animal) : un animal quelconque";
}
}
const j = new Journal();
console.log("— 1. quelle méthode s'exécute ? —");
const chien = new Chien(), chat = new Chat();
console.log(" a.crier() déclaré Animal, réel Chien -> " + chien.crier() +
" (redéfinition : TYPE RÉEL)");
console.log(" a.presenter() déclaré Animal, réel Chat -> " + chat.presenter());
console.log(" presenter() n'est PAS redéfinie : c'est celle d'Animal qui part.");
console.log(" Mais le this.crier() qu'elle contient repart en liaison dynamique");
console.log(" et exécute Chat.crier(). Le parent appelle la version de la fille.");
console.log(" j.journaliser(a) déclaré Animal, réel Chien -> " + j.journaliser("Animal"));
console.log(" LE PIÈGE : surcharge, pas redéfinition. Le compilateur a choisi");
console.log(" d'après le type DÉCLARÉ, et la machine n'avait plus rien à décider.");
console.log(" a.aboyer() déclaré Animal, réel Chien -> REFUSÉ À LA COMPILATION");
console.log(" L'objet possède la méthode, mais Animal ne la déclare pas.");
console.log(" Il faut ((Chien) a).aboyer(), après un test instanceof.");
console.log("");
console.log("— 2. une boucle sans 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; }
}
// La classe ajoutée. Aucune ligne existante n'a été touchée.
class Triangle extends Forme {
constructor(b, h) { super("triangle"); this.b = b; this.h = h; }
aire() { return (this.b * this.h) / 2; }
}
const formes = [new Cercle(2), new Rectangle(3, 4), new Triangle(3, 4)];
let total = 0;
for (const f of formes) { console.log(" " + f.decrire()); total += f.aire(); }
console.log(" total : " + total.toFixed(2) + " — la boucle ignore quelles formes existent");
// La version que le polymorphisme évite.
function aireParTests(f) {
if (f instanceof Cercle) return Math.PI * f.r * f.r;
if (f instanceof Rectangle) return f.l * f.h;
// ← et il a fallu revenir ICI pour ajouter le triangle. Puis dans le
// calcul de périmètre, l'affichage, la sauvegarde…
if (f instanceof Triangle) return (f.b * f.h) / 2;
throw new Error("forme inconnue");
}
console.log(" version à instanceof : même résultat, mais chaque nouvelle forme");
console.log(" oblige à retrouver TOUS les tests du programme.");
console.log("");
console.log("— 3. identité, égalité, et le contrat hashCode —");
class Livre {
constructor(isbn, titre) { this.isbn = isbn; this.titre = titre; }
// Deux livres sont égaux s'ils désignent le même ouvrage, donc le même ISBN.
equals(autre) {
if (this === autre) return true;
if (!(autre instanceof Livre)) return false; // l'un des emplois légitimes
return this.isbn === autre.isbn;
}
// Le contrat : deux objets égaux DOIVENT avoir le même code de hachage.
hashCode() { return this.isbn; }
toString() { return this.titre + " (" + this.isbn + ")"; }
}
const a = new Livre(1234, "Dune");
const b = new Livre(1234, "Dune");
console.log(" a === b : " + (a === b) + " (identité : deux objets distincts)");
console.log(" a.equals(b) : " + a.equals(b) + " (égalité de contenu)");
// Un ensemble qui range par code de hachage, comme un HashSet.
function ensemble(hachage) {
const seaux = new Map();
return {
ajouter(x) {
const h = hachage(x);
if (!seaux.has(h)) seaux.set(h, []);
const seau = seaux.get(h);
if (!seau.some((y) => x.equals(y))) seau.push(x);
},
contient(x) {
const seau = seaux.get(hachage(x)) ?? []; // on ne cherche QUE dans ce seau
return seau.some((y) => x.equals(y));
},
};
}
const bon = ensemble((x) => x.hashCode());
bon.ajouter(a);
console.log(" contrat respecté : l'ensemble contient b ? " + bon.contient(b));
const casse = ensemble((x) => Math.floor(Math.random() * 100)); // hashCode incohérent
casse.ajouter(a);
console.log(" contrat violé : l'ensemble contient b ? " + casse.contient(b) +
" — l'objet est là, et introuvable");
console.log(" Aucune erreur n'est levée : la recherche va simplement dans le");
console.log(" mauvais seau. C'est pourquoi equals et hashCode se redéfinissent");
console.log(" TOUJOURS ensemble.");
En travaux pratiques
Travaux pratiques 6 · 4 h
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.
Avant de commencer
- Le TP 5 : héritage et substitution
Énoncé
- 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.
- 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. Indice : L'une se résout à la compilation sur le type déclaré, l'autre à l'exécution sur le type réel.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
Correction
class Document {
String decrire() { return "document"; } /* redéfinie */
}
class Livre extends Document {
@Override String decrire() { return "livre"; }
}
static void traiter(Document d) { System.out.println("traiter Document"); }
static void traiter(Livre l) { System.out.println("traiter Livre"); }
Document d = new Livre();
d.decrire() → « livre » TYPE RÉEL (exécution)
traiter(d) → « traiter Document » TYPE DÉCLARÉ (compilation)Deux mécanismes, deux moments, deux résultats. La REDÉFINITION est résolue à l'exécution sur le type réel de l'objet — c'est le polymorphisme. La SURCHARGE est résolue à la compilation sur le type déclaré de l'expression — le compilateur ignore ce que la variable contiendra. Cette expérience, faite une fois, dissipe la confusion pour de bon.
/* AVANT : tout est dans Mediatheque */
double penalite(Document d, int joursDeRetard) {
if (d instanceof Livre) return joursDeRetard * 0.20;
else if (d instanceof Dvd) return joursDeRetard * 1.00;
else if (d instanceof Revue) return joursDeRetard * 0.10;
return 0;
}
/* ajouter Partition : modifier ici, ET dans dureeEmprunt(),
ET dans afficher(), ET dans exporter()… → 4 endroits,
et le compilateur n'en signale AUCUN si vous en oubliez un */Chaque nouvelle sous-classe oblige à retrouver et modifier toutes les cascades. Le compilateur ne vous aide pas : le code oublié compile et renvoie silencieusement zéro. Une suite de instanceof sur une hiérarchie est presque toujours le signe qu'une méthode manque à cette hiérarchie.
public abstract class Document {
public abstract double penaliteParJour();
}
class Livre extends Document { public double penaliteParJour(){return 0.20;} }
class Dvd extends Document { public double penaliteParJour(){return 1.00;} }
class Revue extends Document { public double penaliteParJour(){return 0.10;} }
/* dans Mediatheque, il ne reste que : */
double penalite(Document d, int jours) { return jours * d.penaliteParJour(); }
/* ajouter Partition : créer UN fichier. Rien d'autre à toucher.
Et si on oublie la méthode, LE COMPILATEUR REFUSE. */Le gain n'est pas la brièveté : c'est que le compilateur devient votre filet. Une méthode abstraite oblige chaque sous-classe à répondre, et l'oubli est une erreur de compilation et non un zéro silencieux. C'est le principe ouvert-fermé — ouvert à l'extension, fermé à la modification — et il devient concret ici.
classe abstraite interface
état (champs) : OUI non (sauf constantes)
constructeur : OUI non
héritage : UNE seule PLUSIEURS implémentées
méthodes concrètes : OUI oui (default), sans état
ici : classe abstraite Document (il y a un état commun :
titre, disponibilité, et un constructeur à factoriser)
+ interface Empruntable si des objets non-Document
doivent aussi être empruntablesLe critère pratique : une classe abstraite dit ce qu'un objet EST et partage du code, une interface dit ce qu'il SAIT FAIRE. Les deux se combinent sans conflit — et comme Java n'autorise qu'un seul parent, l'interface est ce qui permet à une classe de jouer plusieurs rôles.
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Document d)) return false;
return titre.equals(d.titre) && annee == d.annee;
}
/* hashCode NON redéfini */
Set<Document> s = new HashSet<>();
s.add(new Document("Dune", 1965));
s.add(new Document("Dune", 1965));
s.size() → 2 /* alors qu'ils sont « égaux » ! */HashSet cherche d'abord le SEAU par hashCode, et ne compare par equals que dans ce seau. Deux objets égaux mais de hachages différents atterrissent dans des seaux différents et ne se rencontrent jamais. D'où le contrat, absolu : deux objets égaux DOIVENT avoir le même hachage. La réciproque est fausse, et c'est normal — des objets différents peuvent partager un hachage.
@Override public int hashCode() { return Objects.hash(titre, annee); }
/* les quatre propriétés d'equals */
réflexivité : x.equals(x)
symétrie : x.equals(y) == y.equals(x)
transitivité: x=y et y=z ⟹ x=z
cohérence : le résultat ne change pas si les objets ne changent pas
et : x.equals(null) est toujours faux
/* le piège de la symétrie */
livre.equals(document) → true si equals utilise instanceof
document.equals(livre) → false si l'autre utilise getClass()
→ le comportement dépend de l'ORDRE : contrat rompuCasser la symétrie rend les collections imprévisibles : un même élément est trouvé ou non selon l'ordre d'insertion. La règle qui évite le problème : les champs utilisés par equals et hashCode doivent être IMMUABLES — sans quoi modifier un objet déjà rangé dans un ensemble le rend introuvable, y compris par lui-même.
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 · 6 cartes
- Énoncez la règle de la liaison dynamique.
- Le COMPILATEUR vérifie l'appel sur le TYPE DÉCLARÉ — il décide de ce qu'on a le droit d'appeler. La MACHINE choisit la méthode sur le TYPE RÉEL de l'objet — elle décide de ce qui s'exécute. C'est le comportement par défaut de toutes les méthodes d'instance en Java. Conséquence : a.aboyer() est refusé à la compilation si a est déclaré Animal, même si l'objet est un Chien.
- Opposez surcharge et redéfinition sur quatre points.
- SURCHARGE : même nom, signature DIFFÉRENTE, dans la MÊME classe, résolue à la COMPILATION d'après le type DÉCLARÉ des arguments. REDÉFINITION : même nom, signature IDENTIQUE, dans une classe FILLE, résolue à l'EXÉCUTION d'après le type RÉEL de l'objet. Confondre les deux est l'erreur la plus fréquente du bloc : deux méthodes surchargées sur le type du paramètre semblent polymorphes et ne le sont pas.
- Quel est le bénéfice concret du polymorphisme, et quelle formule le résume ?
- Une boucle sur une liste de Formes qui appelle f.aire() ne contient AUCUN test de type, et ajouter une nouvelle forme ne demande de modifier aucune ligne existante. La version procédurale à cascade de if oblige à retrouver tous les tests du programme — calcul d'aire, périmètre, affichage, sauvegarde. Formule : LE POLYMORPHISME REMPLACE LES TESTS DE TYPE PAR DE LA LIAISON DYNAMIQUE. Un programme objet truffé d'instanceof n'a pas encore été écrit en objet.
- Transtypage montant et descendant : que garantit chacun ?
- MONTANT (vers le parent) : implicite et toujours sûr, puisqu'un Chien EST un Animal. DESCENDANT (vers la fille) : doit être écrit, et n'est qu'une PROMESSE faite au compilateur, vérifiée seulement à l'exécution — d'où la ClassCastException. La garde est instanceof, ou la forme condensée if (a instanceof Chien c). Critère de conception : un code plein d'instanceof signale une méthode manquante ; il n'est légitime que pour equals, le code ancien, ou un traitement extérieur à la hiérarchie.
- Classe abstraite ou interface : quel critère ?
- CLASSE ABSTRAITE : exprime « est un », partage du CODE et des ATTRIBUTS, peut avoir un constructeur appelé par super — et il n'y en a qu'une, Java interdisant l'héritage multiple de classes à cause du problème du diamant. INTERFACE : exprime « sait faire », n'apporte qu'un contrat, et se cumule sans limite. Exemple canonique : un Livre EST un Document et SAIT ÊTRE emprunté et comparé. Les méthodes default brouillent la frontière, mais les attributs d'instance restent réservés aux classes.
- Pourquoi ne compare-t-on jamais deux objets avec == , et quel contrat lie equals et hashCode ?
- == teste l'IDENTITÉ (est-ce le même objet ?), equals l'ÉGALITÉ DE CONTENU. Deux chaînes de même contenu créées par new sont == false et equals true — et le == semble parfois marcher à cause d'un cache de littéraux, ce qui le rend traître. CONTRAT : hashCode doit être redéfinie EN MÊME TEMPS que equals, et deux objets égaux doivent avoir le même code de hachage. Le violer casse silencieusement HashMap et HashSet : un objet rangé devient introuvable, sans aucune erreur.