Version sans JavaScript, en lecture seule : les ateliers, les quiz et l’exécution du code n’y sont pas. Version complète · Catalogue · Se connecter

Support de cours · chapitre 4

Hériter ou composer

L'héritage est la relation la plus forte entre deux classes. Ce chapitre présente ce qu'il apporte, ce qu'il coûte, et les cas où il vaut mieux recourir à la composition.

Ce chapitre prolonge la séquence 2. On utilise l'héritage quand la sous-classe doit pouvoir remplacer la super-classe, et la composition quand il s'agit de combiner des comportements.

Ce qu'apporte l'héritage

class CanardEnPlastique {
    private String nom;
    private int prix;                 // en centimes

    CanardEnPlastique(String nom, int prix) { this.nom = nom; this.prix = prix; }
    public void cancaner() { System.out.println("coin coin"); }
}

class CanardSwag extends CanardEnPlastique {
    private final String accessoire;

    CanardSwag(String nom, int prix, String accessoire) {
        super(nom, prix);             // la mère d'abord, toujours
        this.accessoire = accessoire;
    }

    @Override public void cancaner() { System.out.println("coin (mais stylé)"); }
}

L'héritage apporte deux choses :

  • la réutilisation : CanardSwag n'a pas eu à réécrire le nom ni le prix ;
  • le polymorphisme : partout où l'on attend un CanardEnPlastique, on peut utiliser un CanardSwag.
List<CanardEnPlastique> bassin = new ArrayList<>();
bassin.add(new CanardEnPlastique("Gérard", 499));
bassin.add(new CanardSwag("Jacqueline", 999, "cape"));

for (CanardEnPlastique c : bassin) c.cancaner();
// coin coin
// coin (mais stylé)      ← l'objet réel décide

Ce que coûte l'héritage

Une hiérarchie de classes fige un choix fait au début du projet, c'est-à-dire au moment où l'on connaissait le moins bien le besoin.

class Canard {
    void nager() { }
    void voler() { }
    void cancaner() { }
}

class CanardEnPlastique extends Canard {
    // rien à redéfinir... pour l'instant
}

Le besoin évolue ensuite : un canard jouet ne vole pas, et il fait « pouet ».

class CanardEnPlastique extends Canard {
    @Override void voler() {
        throw new UnsupportedOperationException("Un jouet ne vole pas !");
    }
    @Override void cancaner() { System.out.println("pouet pouet"); }
}

Dans cet exemple, l'erreur ne vient pas de CanardEnPlastique mais de la décision d'avoir placé voler() dans Canard.

Un exemple du JDK : Stack extends Vector

La classe Stack du JDK est un exemple connu d'héritage mal choisi.

class Stack extends Vector {
    // hérite de toutes les méthodes de Vector
}

Une pile ne devrait offrir que deux opérations, empiler et dépiler. En héritant de Vector, elle hérite aussi de insertElementAt et removeElementAt, qui permettent de modifier le milieu de la pile. C'est donc sa classe mère qui rompt l'abstraction de pile.

Avec la composition, la classe n'expose que les opérations voulues :

class Pile<T> {
    private final Vector<T> elements = new Vector<>();

    void empiler(T item) { elements.add(item); }
    T depiler() { return elements.remove(elements.size() - 1); }
}

Pile utilise un Vector mais ne l'expose pas : elle a un vecteur, alors que Stack est un vecteur.

Combiner deux comportements indépendants

Supposons qu'un canard ait un comportement de vol et un comportement de cri, chacun pouvant prendre trois valeurs.

classDiagram
    direction TB
    class Canard
    class CanardVolantCancanant
    class CanardVolantMuet
    class CanardVolantPouet
    class CanardNageantCancanant
    class autres["... et cinq autres"]

    Canard <|-- CanardVolantCancanant
    Canard <|-- CanardVolantMuet
    Canard <|-- CanardVolantPouet
    Canard <|-- CanardNageantCancanant
    Canard <|-- autres

Avec l'héritage, il faut une classe par combinaison : CanardVolantCancanant, CanardVolantMuet, CanardVolantPouet, CanardNageantCancanant, etc., soit neuf classes, et vingt-sept si un troisième axe apparaît.

Avec la composition, une seule classe suffit :

interface VolStrategy  { void voler(); }
interface ChantStrategy { void chanter(); }

class Canard {
    private VolStrategy vol;
    private ChantStrategy chant;

    Canard(VolStrategy vol, ChantStrategy chant) { this.vol = vol; this.chant = chant; }

    void effectuerVol()   { vol.voler(); }
    void effectuerChant() { chant.chanter(); }

    void equiper(VolStrategy v) { this.vol = v; }   // ← et même en cours de route
}
classDiagram
    direction LR
    class Canard {
        -VolStrategy vol
        -ChantStrategy chant
        +effectuerVol()
        +effectuerChant()
        +equiper(VolStrategy)
    }
    class VolStrategy {
        <<interface>>
        +voler()
    }
    class ChantStrategy {
        <<interface>>
        +chanter()
    }
    Canard o-- VolStrategy
    Canard o-- ChantStrategy
    VolStrategy <|.. VolerAvecAiles
    VolStrategy <|.. NePasVoler
    ChantStrategy <|.. Cancaner
    ChantStrategy <|.. Muet

Et l'héritage multiple ?

Java interdit l'héritage multiple entre classes, pour éviter le problème du diamant : si Canard héritait de Oiseau et de Jouet, qui héritent tous deux de Objet, on ne saurait pas quelle implémentation appliquer.

Java propose à la place les interfaces : une classe peut en implémenter autant qu'elle le souhaite.

interface Volant   { void voler(); }
interface Flottant { void flotter(); }

class Canard implements Volant, Flottant {
    public void voler()   { }
    public void flotter() { }
}

Cette solution est plus souple, mais elle a ses propres dérives, par exemple une classe sans rôle clair qui implémente dix interfaces. Le chapitre 5 explique comment bien les utiliser.

Choisir entre héritage et composition

Question d'épreuve

Pourquoi préférer la composition à l'héritage pour combiner deux comportements indépendants ?

L'héritage impose une classe par combinaison (n classes pour n valeurs, n×m pour deux axes), alors que la composition combine les comportements dans une seule classe. Elle permet en outre de changer le comportement à l'exécution.