C3 — Héritage et polymorphismeDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 2 · Programmation orientée objet

Cours 3Héritage et polymorphisme

Le cœur du cours : factoriser sans abuser du « est-un », puis écrire du code qui traite uniformément des objets de types différents.

2 chapitres · 14 h de travail estimé

  1. 1. Héritage6 h
  2. 2. Polymorphisme8 h

Chapitre 1 · 6 h

Héritage

La relation « est-un », extends et super, redéfinition de méthodes, hiérarchie de classes, et le choix entre héritage et composition.

Chaque année, le même code apparaît en TP :

class Moteur  { int puissance; void demarrer() { ... } }class Voiture extends Moteur { String marque; }

Le raisonnement est compréhensible : une voiture a une puissance, une voiture démarre, et Moteur possède déjà les deux. Hériter évite de réécrire.

Le résultat est pourtant une faute de conception, et elle se voit à une conséquence : partout où le programme attend un Moteur, on pourra désormais lui donner une Voiture. Un atelier de réparation de moteurs acceptera une voiture entière. Une liste de moteurs en stock contiendra des véhicules.

Ce chapitre donne le mécanisme de l'héritage, puis le critère qui dit quand on a le droit de s'en servir. Le second compte plus que le premier — c'est le deuxième point qui coince du semestre.

Le mécanisme

public class Document {    protected String titre;    private int identifiant;     public Document(String titre) { this.titre = titre; }    public String decrire() { return "Document : " + titre; }} public class Livre extends Document {    private String auteur;     public Livre(String titre, String auteur) {        super(titre);                 /* appelle le constructeur du parent */        this.auteur = auteur;    }     @Override    public String decrire() {        return super.decrire() + ", par " + auteur;    }}

Quatre points à relever.

extends établit la relation. Livre est un Document : il hérite de tous les membres non privés de son parent — attributs et méthodes — et peut en ajouter.

Les attributs privés du parent existent mais sont inaccessibles. Chaque Livre contient bien un identifiant en mémoire ; simplement, le code de Livre ne peut pas le lire directement. Il doit passer par un accesseur du parent, s'il en existe un. C'est l'encapsulation du chapitre 3, qui s'applique aussi aux filles — et c'est ce qui justifie l'existence de protected.

super(...) appelle le constructeur du parent, et doit être la première instruction. La raison est de bon sens : la partie parente de l'objet doit être valide avant qu'on ne complète la partie fille. Si on l'omet, Java insère un super() sans argument — et si le parent n'a pas de constructeur sans argument, le code ne compile pas. C'est une erreur fréquente et son message est déroutant.

@Override n'est pas décoratif. Cette annotation demande au compilateur de vérifier qu'une méthode du parent est bien redéfinie. Sans elle, une faute de frappe — decrir() au lieu de decrire() — crée une nouvelle méthode qui ne sera jamais appelée à la place de l'autre, et tout compile. Il faut la mettre systématiquement.

Redéfinir, et prolonger

Redéfinir (override), c'est fournir dans la fille une méthode de même signature que dans le parent. C'est elle qui sera exécutée sur un objet de la fille — le chapitre 6 dira précisément pourquoi.

super.decrire() appelle la version du parent depuis la fille. C'est ce qui permet de prolonger un comportement au lieu de le remplacer : faire ce que fait le parent, puis ajouter. L'oubli de cet appel est une source classique de bogues — une fille qui redéfinit initialiser() sans appeler celle du parent laisse la moitié de l'objet non initialisée.

Trois interdits à connaître. On ne peut pas restreindre la visibilité en redéfinissant : une méthode public ne peut pas devenir protected chez la fille, sinon un objet fille vu comme un parent perdrait une méthode qu'il est censé avoir. On ne peut pas redéfinir une méthode final. Et une méthode static n'est pas redéfinie mais masquée, ce qui est un piège dont le chapitre 6 montrera l'effet.

Une hiérarchie d'héritage se lit d'un coup d'œil sous forme de graphe. Dans le diagramme interactif ci-dessous, chaque flèche part d'une sous-classe et pointe vers son parent (extends) : Livre, Magazine et CD sont des Document et héritent de son titre et de sa méthode afficher(). Déplacez les nœuds pour explorer la structure.

Diagramme · Une hiérarchie d'héritage (extends)

  • Document — attributs : - titre : String — méthodes : + afficher() : void
  • Livre — attributs : - auteur : String
  • Magazine — attributs : - numero : int
  • CD — attributs : - duree : int

Relations

  • Livre ▷ hérite Document
  • Magazine ▷ hérite Document
  • CD ▷ hérite Document

Le test « est-un »

Voici le vrai sujet du chapitre.

Avant d'écrire extends, on formule la phrase à voix haute : « Un B est-il un A ? »

Un Livre est un Document.          ✔  cohérentUn Rectangle est une Forme.        ✔Un Étudiant est une Personne.      ✔Une Voiture est un Moteur.         ✘  non — elle en POSSÈDE unUn Compte est une Banque.          ✘  non — il appartient à une banqueUne Commande est une ListeArticles ✘  non — elle en contient une

Le test échoue le plus souvent de la même façon : on hérite parce qu'on a repéré des attributs communs, ce qui est un argument de réutilisation, pas de nature.

Il faut aller un cran plus loin, car « est-un » reste parfois trompeur. Le critère rigoureux est le suivant : tout ce qui est vrai d'un objet de la classe mère doit rester vrai d'un objet de la classe fille. Autrement dit, un code écrit pour le parent doit continuer de fonctionner correctement si on lui donne une fille — sans le savoir.

Le contre-exemple d'école est celui du carré et du rectangle. Un carré est un rectangle, en mathématiques. Et pourtant :

class Rectangle { void setLargeur(double l); void setHauteur(double h); double aire(); }class Carre extends Rectangle { /* setLargeur modifie aussi la hauteur */ }

Un code écrit pour Rectangle peut légitimement faire : fixer la largeur à 5, fixer la hauteur à 4, et attendre une aire de 20. Sur un Carre, il obtient 16. Le code du parent est cassé par la fille, alors même que la phrase « est-un » était vraie.

La leçon est double. La relation « est-un » porte sur le COMPORTEMENT, pas sur le vocabulaire. Et quand un héritage force à écrire des exceptions dans le parent — « sauf si c'est un carré » — c'est le signe qu'il faut le défaire.

Quiz · 1 question

Un étudiant écrit class Voiture extends Moteur parce que les deux ont une puissance et une méthode demarrer(). Quelle est la conséquence concrète ?

  • Aucune : c'est une factorisation valide, puisque le code commun n'est écrit qu'une foisfactorisation valide
  • Partout où le programme attend un Moteur, on pourra désormais lui donner une Voiture : un atelier de réparation de moteurs acceptera un véhicule entier, et une liste de moteurs contiendra des voituressubstitution absurde
  • Le code ne compilera pas, car Moteur et Voiture n'ont pas de constructeur compatibleerreur de compilation

Réponse : L'héritage ne factorise pas seulement du code : il DÉCLARE une relation de nature, et cette déclaration a une conséquence opérationnelle immédiate — la substituabilité. Écrire extends, c'est affirmer qu'une Voiture peut être utilisée partout où un Moteur est attendu, ce qui est absurde et produira des signatures de méthodes trompeuses, des collections hétéroclites et des tests impossibles à écrire. Le raisonnement de l'étudiant repose sur des ATTRIBUTS COMMUNS, ce qui est un argument de réutilisation et jamais un argument de nature. La bonne relation est « a-un » : la Voiture POSSÈDE un Moteur, donc un attribut privé de type Moteur, et elle délègue demarrer() en appelant moteur.demarrer(). Le code commun n'est pas dupliqué pour autant — il reste dans Moteur.

Héritage contre composition

Quand « est-un » échoue, la bonne relation est presque toujours « a-un » — la composition.

public class Voiture {    private final Moteur moteur;          /* une Voiture A UN moteur */    private String marque;     public Voiture(String marque, Moteur moteur) {        this.marque = marque;        this.moteur = moteur;    }     public void demarrer() {        moteur.demarrer();                /* délégation */    }}

Le code de Moteur n'est toujours pas dupliqué : il est appelé, au lieu d'être hérité. Et la relation absurde disparaît — aucune Voiture ne peut plus se faire passer pour un Moteur.

Quatre avantages, qui expliquent la recommandation générale de préférer la composition.

On n'expose que ce qu'on veut. L'héritage rend publiques toutes les méthodes publiques du parent, y compris celles qui n'ont aucun sens pour la fille. La composition ne délègue que ce qu'on choisit.

On peut changer de composant à l'exécution — remplacer le moteur — ce qu'un lien d'héritage, fixé à la compilation, ne permet pas.

On peut en avoir plusieurs. Une Voiture a quatre Roue ; l'héritage ne saurait pas l'exprimer.

Le couplage est plus faible. Une fille dépend des détails internes de son parent : une modification du parent peut casser toutes ses filles, sans qu'aucune signature n'ait changé. C'est le « problème de la classe de base fragile », et il n'a pas d'équivalent en composition.

La règle de conduite, à retenir telle quelle : héritage quand la relation est vraiment « est-un » et que la substituabilité tient ; composition dans tous les autres cas — c'est-à-dire le plus souvent.

Quiz · 1 question

Un Carre hérite de Rectangle et redéfinit setLargeur pour modifier aussi la hauteur. Un code écrit pour Rectangle fixe la largeur à 5, la hauteur à 4, et attend une aire de 20. Que conclure ?

  • Rien d'anormal : un carré a bien une largeur égale à sa hauteur, le code appelant est simplement mal écritappelant mal écrit
  • L'héritage est fautif malgré la phrase « un carré est un rectangle » : un code correct pour le parent est cassé par la fille, donc la substituabilité ne tient pas — le critère porte sur le COMPORTEMENT, pas sur le vocabulairesubstituabilité rompue
  • Il suffit de rendre setLargeur final dans Rectangle pour interdire la redéfinitionfinal suffit

Réponse : C'est le contre-exemple d'école, et il montre que « est-un » énoncé en français ne suffit pas. Le critère rigoureux est : tout ce qui est vrai d'un objet de la classe mère doit rester vrai d'un objet de la classe fille, de sorte qu'un code écrit pour le parent continue de fonctionner s'il reçoit une fille SANS LE SAVOIR. Ici l'appelant ne fait rien d'illégitime : Rectangle promet deux dimensions indépendantes, il en use. C'est Carre qui rompt la promesse. Rendre setLargeur final déplacerait le problème sans le résoudre — Carre ne pourrait plus maintenir son invariant et deviendrait un rectangle ordinaire. La sortie est de ne pas hériter : soit Carre est une classe indépendante, soit les deux implémentent une interface Forme commune qui ne promet que aire(), sans setters. Signal général : quand un héritage force à écrire « sauf si c'est un… » dans le parent, il faut le défaire.

À vous

L'exercice attaque les deux difficultés du chapitre dans l'ordre.

D'abord un jeu de paires candidates — Livre/Document, Voiture/Moteur, Carre/Rectangle et quelques autres — sur lesquelles vous appliquez le test « est-un » et justifiez votre verdict. Le corrigé donne le raisonnement, pas seulement la réponse.

Ensuite la démonstration mécanique de la rupture : un test écrit pour Rectangle — fixer deux dimensions, vérifier l'aire — que vous exécutez sur un Rectangle puis sur un Carre. Le même test passe puis échoue, sans avoir été modifié. C'est la définition opérationnelle de la substituabilité, et elle vaut mieux qu'un long discours.

Enfin la refonte de Voiture extends Moteur en composition, avec délégation — et la vérification qu'une Voiture ne peut plus être rangée dans une liste de moteurs.

Exercice de code

Appliquez le test « est-un », mesurez la rupture de substituabilité, puis remplacez un héritage fautif par une délégation.

Point de départ

// ── 1. Le test « est-un » ─────────────────────────────────────────────────
const PAIRES = [
  { fille: "Livre",    mere: "Document",      indice: "un livre est un document" },
  { fille: "Voiture",  mere: "Moteur",        indice: "une voiture démarre, et a une puissance" },
  { fille: "Etudiant", mere: "Personne",      indice: "un étudiant a un nom et un âge" },
  { fille: "Compte",   mere: "Banque",        indice: "un compte appartient à une banque" },
  { fille: "Carre",    mere: "Rectangle",     indice: "en mathématiques, c'en est un" },
  { fille: "Pile",     mere: "ArrayList",     indice: "une pile stocke des éléments en liste" },
];

// ← à écrire : pour chaque paire, répondre estUn (true/false) et dire
//   pourquoi. Deux questions : la phrase « une F est une M » est-elle vraie
//   de NATURE ? et un code écrit pour M fonctionnerait-il sur une F ?
const VERDICTS = {};

// ── 2. La substituabilité, mesurée ────────────────────────────────────────
class Rectangle {
  constructor(l, h) { this._largeur = l; this._hauteur = h; }
  setLargeur(l) { this._largeur = l; }
  setHauteur(h) { this._hauteur = h; }
  aire() { return this._largeur * this._hauteur; }
}

class Carre extends Rectangle {
  constructor(cote) { super(cote, cote); }
  setLargeur(l) { this._largeur = l; this._hauteur = l; }   // maintient l'invariant
  setHauteur(h) { this._largeur = h; this._hauteur = h; }
}

// Un test écrit POUR Rectangle, par quelqu'un qui ignore que Carre existe.
function testEcritPourRectangle(forme) {
  forme.setLargeur(5);
  forme.setHauteur(4);
  return { aire: forme.aire(), attendu: 20 };
}

// ── 3. Voiture : héritage fautif, puis composition ────────────────────────
class Moteur {
  constructor(puissance) { this.puissance = puissance; this.tourne = false; }
  demarrer() { this.tourne = true; return "moteur " + this.puissance + " ch démarré"; }
  arreter() { this.tourne = false; }
}

class VoitureHeritee extends Moteur {          // ← la faute
  constructor(marque, puissance) { super(puissance); this.marque = marque; }
}

class Voiture {
  #moteur;
  constructor(marque, moteur) { this.marque = marque; this.#moteur = moteur; }
  // ← à écrire : déléguer demarrer() au moteur, sans hériter de lui
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Remplissez VERDICTS pour les six paires.
// 2. Lancez testEcritPourRectangle sur un Rectangle puis sur un Carre.
// 3. Écrivez la délégation dans Voiture, et vérifiez qu'une Voiture ne peut
//    plus être rangée dans une liste de moteurs.

console.log("   " + testEcritPourRectangle(new Rectangle(1, 1)).aire);

Solution

const VERDICTS = {
  Livre:    { estUn: true,  pourquoi: "un livre EST un document : tout ce qu'on fait d'un document (décrire, ranger, référencer) a un sens sur un livre" },
  Voiture:  { estUn: false, pourquoi: "elle POSSÈDE un moteur — attributs communs n'est pas nature commune. Composition" },
  Etudiant: { estUn: true,  pourquoi: "un étudiant EST une personne, et tout code écrit pour Personne reste correct" },
  Compte:   { estUn: false, pourquoi: "il APPARTIENT à une banque : c'est une association, pas une nature" },
  Carre:    { estUn: false, pourquoi: "vrai en mathématiques, faux en programmation : Rectangle promet deux dimensions INDÉPENDANTES, promesse qu'un carré ne peut pas tenir" },
  Pile:     { estUn: false, pourquoi: "une pile UTILISE une liste pour stocker, mais hériter exposerait get(i) et remove(i), qui violent le LIFO. Composition" },
};

console.log("— 1. le test « est-un » —");
for (const [nom, v] of Object.entries(VERDICTS)) {
  console.log("   " + nom.padEnd(10) + (v.estUn ? "HÉRITAGE   " : "composition") + "  " + v.pourquoi);
}

class Rectangle {
  constructor(l, h) { this._largeur = l; this._hauteur = h; }
  setLargeur(l) { this._largeur = l; }
  setHauteur(h) { this._hauteur = h; }
  aire() { return this._largeur * this._hauteur; }
  get nom() { return this.constructor.name; }
}
class Carre extends Rectangle {
  constructor(cote) { super(cote, cote); }
  setLargeur(l) { this._largeur = l; this._hauteur = l; }
  setHauteur(h) { this._largeur = h; this._hauteur = h; }
}

function testEcritPourRectangle(forme) {
  forme.setLargeur(5);
  forme.setHauteur(4);
  return { nom: forme.nom, aire: forme.aire(), attendu: 20 };
}

console.log("");
console.log("— 2. le MÊME test, deux objets —");
for (const forme of [new Rectangle(1, 1), new Carre(1)]) {
  const r = testEcritPourRectangle(forme);
  console.log("   " + r.nom.padEnd(10) + " aire = " + String(r.aire).padStart(3) +
              "  attendu " + r.attendu + (r.aire === r.attendu ? "   ok" : "   ÉCHEC"));
}
console.log("   Le test n'a pas été modifié d'un caractère. L'appelant ne fait rien");
console.log("   d'illégitime : Rectangle promet deux dimensions indépendantes, il en");
console.log("   use. C'est Carre qui rompt la promesse — donc l'héritage est fautif,");
console.log("   malgré la phrase « un carré est un rectangle ».");

console.log("");
console.log("— 3. héritage fautif contre composition —");
class Moteur {
  constructor(puissance) { this.puissance = puissance; this.tourne = false; }
  demarrer() { this.tourne = true; return "moteur " + this.puissance + " ch démarré"; }
  arreter() { this.tourne = false; }
}
class VoitureHeritee extends Moteur {
  constructor(marque, puissance) { super(puissance); this.marque = marque; }
}
class Voiture {
  #moteur;
  constructor(marque, moteur) { this.marque = marque; this.#moteur = moteur; }
  // DÉLÉGATION : on appelle le moteur au lieu d'hériter de lui. Le code de
  // Moteur n'est pas dupliqué, et on n'expose que ce qu'on a choisi.
  demarrer() { return this.marque + " : " + this.#moteur.demarrer(); }
  get puissance() { return this.#moteur.puissance; }
  changerMoteur(m) { this.#moteur = m; }    // impossible avec l'héritage
}

// Un atelier qui ne travaille que sur des moteurs.
function atelier(moteurs) {
  return moteurs.map((m) => (m instanceof Moteur ? "révisé" : "REFUSÉ : ce n'est pas un moteur"));
}

const stock = [new Moteur(90), new VoitureHeritee("Renault", 110)];
console.log("   avec l'héritage fautif : " + atelier(stock).join("  |  "));
console.log("   Une voiture entière est passée pour un moteur, et l'atelier l'a prise.");

const stock2 = [new Moteur(90), new Voiture("Renault", new Moteur(110))];
console.log("   avec la composition    : " + atelier(stock2).join("  |  "));
console.log("   " + new Voiture("Peugeot", new Moteur(75)).demarrer());
console.log("   La relation absurde a disparu, et l'on peut même changer de moteur");
console.log("   à l'exécution — ce qu'un lien d'héritage, fixé à la compilation,");
console.log("   n'aurait jamais permis.");

En travaux pratiques

Travaux pratiques 5 · 4 h

Le deuxième point qui coince : hériter à tort

Écrire une hiérarchie qui paraît naturelle, prouver qu'elle est fausse, et la remplacer par de la composition — pour que le critère de choix devienne un réflexe.

Avant de commencer

  • Les TP 1 à 4

Énoncé

  1. Une hiérarchie légitimeFaites dériver Livre, Dvd et Revue de Document. Mettez dans la classe mère ce qui est commun, et rien d'autre. Comptez les lignes économisées.
  2. superÉcrivez les constructeurs des filles en appelant celui de la mère. Retirez l'appel et lisez l'erreur. Expliquez pourquoi il doit être en première instruction.
  3. La hiérarchie qui semble justeÉcrivez une classe Rectangle avec largeur, hauteur et surface. Faites-en dériver Carre, en redéfinissant les modificateurs pour garder les côtés égaux.
  4. La casserÉcrivez une méthode qui prend un Rectangle, lui met une largeur de 5 et une hauteur de 4, et vérifie que la surface vaut 20. Passez-lui un Carre. Indice : La méthode ne fait rien d'illégitime : elle utilise le contrat de Rectangle.
  5. Le principeÉnoncez en une phrase la règle que le carré viole, et vérifiez si votre hiérarchie de documents la respecte.
  6. Le deuxième contre-exempleFaites dériver une Pile d'une classe de liste. Ajoutez un élément par la méthode d'insertion de la liste et constatez ce que devient l'invariant de la pile.
  7. ComposerRéécrivez la pile en CONTENANT une liste plutôt qu'en héritant d'elle. Comparez la surface publique des deux versions.
  8. Le critèreÉcrivez la question à se poser avant tout héritage, et appliquez-la aux cinq relations suivantes : Livre/Document, Carre/Rectangle, Pile/Liste, Cercle/Forme, Employe/Personne.

C'est réussi quand

  • Votre test échoue avec un Carre, sans que le test soit fautif
  • Vous énoncez le principe de substitution sans le relire
  • Votre pile par composition n'expose aucune méthode qui casse son invariant

Correction

L'héritage légitime
public abstract class Document {
  protected final String titre;
  protected boolean disponible = true;

  protected Document(String titre) { this.titre = titre; }

  public boolean emprunter() { … }
  public abstract int dureeEmpruntEnJours();   /* diffère par type */
}

public class Livre extends Document {
  private final int pages;
  public Livre(String titre, int pages) {
      super(titre);                 /* PREMIÈRE instruction */
      this.pages = pages;
  }
  @Override public int dureeEmpruntEnJours() { return 21; }
}

super(...) doit être en première instruction parce que la partie héritée doit être construite avant qu'on puisse s'appuyer dessus : sinon une méthode de la mère pourrait lire des champs non initialisés. Si vous ne l'écrivez pas, Java insère super() sans argument — et l'erreur apparaît quand la mère n'a pas de constructeur sans argument.

Le carré qui n'est pas un rectangle
class Carre extends Rectangle {
  @Override public void setLargeur(int l) { super.setLargeur(l);
                                            super.setHauteur(l); }
  @Override public void setHauteur(int h) { super.setLargeur(h);
                                            super.setHauteur(h); }
}

void tester(Rectangle r) {
  r.setLargeur(5);
  r.setHauteur(4);
  assert r.surface() == 20;    /* légitime pour un Rectangle */
}

tester(new Rectangle()) → 20, réussit
tester(new Carre())     → 16, ÉCHOUE

La méthode tester est irréprochable : elle n'utilise que le contrat public de Rectangle. C'est la sous-classe qui a menti. En mathématiques un carré EST un rectangle ; en programmation, ce qui compte n'est pas la nature des objets mais leur COMPORTEMENT — et un rectangle mutable dont les côtés varient indépendamment n'est pas substituable par un carré.

Le principe de substitution
Partout où l'on attend une classe mère, on doit pouvoir mettre
n'importe quelle fille SANS que le programme cesse d'être correct.

en pratique, une sous-classe ne doit jamais :
- renforcer ce qu'elle exige en entrée
- affaiblir ce qu'elle garantit en sortie
- casser un invariant de la mère
- lever une exception que la mère ne déclarait pas

Ce principe transforme une question de goût en question vérifiable : écrivez les tests de la classe mère, et faites-les passer à chaque fille. S'ils échouent, l'héritage est faux — et ce test mécanique vaut mieux que n'importe quelle discussion sur la modélisation. À noter : si Rectangle était IMMUABLE, sans modificateurs, Carre pourrait légitimement en dériver.

La pile qui hérite, et l'invariant perdu
class Pile<T> extends ArrayList<T> {
  public void empiler(T t) { add(t); }
  public T depiler() { return remove(size() - 1); }
}

Pile<String> p = new Pile<>();
p.empiler("a"); p.empiler("b");
p.add(0, "triché");        /* HÉRITÉ d'ArrayList : insertion au milieu */
p.remove(0);               /* HÉRITÉ : retrait par le bas */
/* la pile n'est plus une pile : l'invariant DERNIER ENTRÉ,
 PREMIER SORTI est cassé, et on ne pouvait pas l'empêcher */

Hériter, c'est hériter de TOUT — y compris des méthodes qui contredisent votre invariant. Java lui-même porte la cicatrice de cette erreur : java.util.Stack hérite de Vector et souffre exactement de ce défaut, ce que la documentation officielle reconnaît en recommandant Deque à la place.

La composition
class Pile<T> {
  private final List<T> elements = new ArrayList<>();   /* CONTIENT */

  public void empiler(T t) { elements.add(t); }
  public T depiler() {
      if (elements.isEmpty()) throw new NoSuchElementException();
      return elements.remove(elements.size() - 1);
  }
  public boolean estVide() { return elements.isEmpty(); }
}

surface publique : héritage 40+ méthodes | composition 3 méthodes

On choisit ce que l'on expose, au lieu de subir ce que l'on reçoit. On peut aussi changer ArrayList pour LinkedList sans que le moindre appelant s'en aperçoive — ce qu'un héritage rendait impossible, puisque le type de la liste faisait partie du contrat public.

Le critère, appliqué
La question n'est PAS « est-ce un » mais :
« puis-je le substituer partout, sans casser le contrat ? »

Livre / Document     OUI  — un livre fait tout ce qu'un document fait
Carre / Rectangle    NON  — sur un rectangle MUTABLE
Pile / Liste         NON  — la liste offre trop
Cercle / Forme       OUI  — si Forme n'expose que des opérations communes
Employe / Personne   ATTENTION — souvent un RÔLE, pas une nature :
                   une personne peut devenir employée, cesser de l'être,
                   ou l'être deux fois. La composition est préférable.

par défaut : COMPOSER. Hériter seulement quand la substitution
est vérifiée par des tests.

Le dernier cas est le plus instructif : l'héritage fige à la construction ce qu'un rôle a de temporaire. Un objet ne peut pas changer de classe, alors qu'une personne change d'emploi. La composition modélise cela sans effort — et c'est pourquoi la règle par défaut, dans le code industriel, est de composer.

Ce que la suite en fait

Le chapitre 6 exploite ce que celui-ci a construit. Puisqu'un Livre est un Document, une variable déclarée Document peut désigner un Livre — et la question devient : quelle version de decrire() s'exécute alors ? C'est le polymorphisme, et c'est l'obstacle de l'année.

Le contre-exemple du carré y trouvera aussi sa vraie sortie : une interface commune, qui ne promet que ce que les deux formes savent réellement tenir.

À retenir

Flashcards · 5 cartes

Que fait exactement extends, et qu'advient-il des attributs privés du parent ?
extends déclare que la fille EST UN parent : elle hérite de tous ses membres non privés et peut en ajouter. Les attributs PRIVÉS du parent existent bien en mémoire dans chaque objet fille, mais le code de la fille ne peut pas les lire directement — c'est l'encapsulation, qui s'applique aussi aux filles, et c'est ce qui justifie protected.
Quelles sont les règles de super(...) et de @Override ?
super(...) appelle le constructeur du parent et doit être la PREMIÈRE instruction : la partie parente doit être valide avant qu'on complète la partie fille. Omis, Java insère un super() sans argument — et si le parent n'en a pas, le code ne compile pas, avec un message déroutant. @Override n'est pas décoratif : il demande au compilateur de VÉRIFIER qu'une méthode du parent est bien redéfinie ; sans lui, une faute de frappe crée une méthode nouvelle qui ne sera jamais appelée, et tout compile.
Quel est le critère rigoureux qui autorise un héritage ?
Pas la phrase « est-un » en français, mais la SUBSTITUABILITÉ : tout ce qui est vrai d'un objet de la classe mère doit rester vrai d'un objet de la classe fille, de sorte qu'un code écrit pour le parent continue de fonctionner s'il reçoit une fille sans le savoir. Le contre-exemple du carré et du rectangle le montre : la phrase est vraie, le comportement ne suit pas. Signal d'alarme : quand un héritage force à écrire « sauf si c'est un… » dans le parent, il faut le défaire.
Pourquoi hériter parce que deux classes ont des attributs communs est-il une faute ?
Parce que les attributs communs sont un argument de RÉUTILISATION, jamais un argument de NATURE. Écrire extends déclare une substituabilité : partout où un Moteur est attendu, une Voiture pourra être donnée — un atelier de moteurs acceptera un véhicule. La bonne relation est « a-un » : la Voiture POSSÈDE un moteur en attribut privé et délègue demarrer(). Le code n'est pas dupliqué pour autant : il est appelé au lieu d'être hérité.
Quels sont les quatre avantages de la composition sur l'héritage ?
1) ON N'EXPOSE QUE CE QU'ON VEUT : l'héritage rend publiques toutes les méthodes du parent, la composition ne délègue que le choisi. 2) ON PEUT CHANGER DE COMPOSANT à l'exécution, un lien d'héritage étant fixé à la compilation. 3) ON PEUT EN AVOIR PLUSIEURS (quatre roues). 4) LE COUPLAGE EST PLUS FAIBLE : une fille dépend des détails internes de son parent, dont une modification peut la casser sans qu'aucune signature ait changé — c'est le problème de la classe de base fragile. Règle : héritage si « est-un » ET substituabilité, composition sinon, c'est-à-dire le plus souvent.

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é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 · 8 étapes

Type déclaré contre type réel : qui décide de la méthode exécutée ?

  1. Une référence Animal vers un objet ChienDeux 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.
  2. 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à.
  3. 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.
  4. Le même appel, un autre objetRemplacez `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.
  5. a.aboyer() — refusé à la compilationL'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.
  6. ((Chien) a).aboyer() — le transtypageLe 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.
  7. ((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.
  8. 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é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 · 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 polymorphismeversion 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éelversion Animal
  • Le code est ambigu et ne compile pasambigu

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 8toujours
  • 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 veutsait faire contre est un
  • Quand la hiérarchie a plus de trois niveaux, l'héritage devenant alors trop profondprofondeur 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é

  1. La question de baseDé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écisiveDans 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.
  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îtreRemplacez 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 hashCodeRedé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 rougeFaites 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

L'expérience décisive
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.

La cascade, et ce qu'elle coûte
/* 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.

La disparition
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 ou interface
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 empruntables

Le 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.

equals sans hashCode
@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.

Le contrat complet
@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 rompu

Casser 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.