Programmation Java

Héritage, polymorphisme et interfaces en Java

Débutant9 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Le polymorphisme permet à un même point d'appel de fonctionner avec de nombreux types concrets, et Java propose trois outils pour l'exprimer : l'héritage de classes, les classes abstraites et les interfaces. Ce guide explique comment la liaison dynamique résout réellement un appel, comment concevoir des interfaces avec des méthodes default et static, comment les classes sealed et le pattern matching ferment une hiérarchie en toute sécurité, et pourquoi la composition est généralement le meilleur moyen de réutiliser du code.

Ce que vous allez apprendre

  • Expliquer ce qui se passe à l'exécution quand une méthode redéfinie est appelée via une référence de supertype
  • Choisir entre une classe abstraite et une interface pour une conception donnée
  • Utiliser les méthodes default, static et private d'une interface sans casser ses implémentations
  • Modéliser un ensemble fermé de variantes avec des interfaces sealed et un switch exhaustif
  • Reconnaître les situations où la composition est préférable à l'héritage

L'héritage est la fonctionnalité la plus étroitement associée à la programmation orientée objet, et celle dont on abuse le plus souvent. Son véritable intérêt n'est pas la réutilisation de code — la composition s'en charge mieux — mais le polymorphisme : écrire du code contre un type général et laisser chaque objet concret fournir son propre comportement. Ce guide couvre les mécanismes que Java met à disposition pour cela (extension de classe, classes abstraites, interfaces), les ajouts récents qui rendent les hiérarchies plus sûres (types sealed et pattern matching), et les arbitrages sur le moment d'utiliser chacun. Les exemples ciblent Java 21.

Étendre une classe

class Manager extends Employee fait de Manager une sous-classe qui hérite de tous les champs et méthodes accessibles d'Employee et peut ajouter ou remplacer du comportement. Un Manager est un Employee : tout code qui attend un Employee accepte donc un Manager. Java n'autorise qu'une seule superclasse ; toute classe sans clause extends explicite étend java.lang.Object.

public class Employee {
    private final String name;
    private final long monthlySalaryCents;

    public Employee(String name, long monthlySalaryCents) {
        this.name = name;
        this.monthlySalaryCents = monthlySalaryCents;
    }

    public long annualPayCents() {
        return monthlySalaryCents * 12;
    }

    public String name() { return name; }
}

public class Manager extends Employee {
    private final long bonusCents;

    public Manager(String name, long monthlySalaryCents, long bonusCents) {
        super(name, monthlySalaryCents);       // must run the superclass constructor first
        this.bonusCents = bonusCents;
    }

    @Override
    public long annualPayCents() {
        return super.annualPayCents() + bonusCents;   // reuse, then extend
    }
}

Trois détails de ce code comptent. Le constructeur de la sous-classe appelle super(...) en première instruction ; si vous l'omettez, le compilateur insère super(), ce qui ne compile pas quand la superclasse n'a pas de constructeur sans argument. L'annotation @Override est facultative, mais devrait être obligatoire dans votre base de code : elle transforme en erreur de compilation une faute de frappe dans le nom de la méthode qui, sans elle, créerait silencieusement une nouvelle méthode. Enfin, les champs private sont hérités au sens où ils existent dans l'objet de la sous-classe, mais ils ne sont pas accessibles depuis son code : Manager accède au salaire via la méthode publique, pas via le champ.

Ce que signifient final et protected ici

Déclarer une classe final interdit d'en hériter ; déclarer une méthode final interdit de la redéfinir. Mettez final sur les classes qui ne sont pas conçues pour être étendues — c'est-à-dire la plupart —, car une classe extensible doit garantir que chacune de ses méthodes fonctionne quand n'importe quelle autre est redéfinie. Une bonne conception documente précisément comment étendre une classe, ou l'interdit.

Les membres protected sont visibles des sous-classes et du même package. Il est tentant de rendre des champs protected pour que les sous-classes les utilisent directement, mais cela lie la sous-classe à la représentation interne de la superclasse : la moindre modification ultérieure du champ casse toutes les sous-classes. Préférez des champs private avec des accesseurs protected si les sous-classes ont réellement besoin de ces données.

Liaison dynamique : comment l'appel est résolu

La règle essentielle : le compilateur vérifie l'appel d'après le type statique de la référence ; à l'exécution, l'implémentation est choisie d'après la classe réelle de l'objet.

Employee e = new Manager("Ada", 800_000, 2_000_000);
long pay = e.annualPayCents();    // compiles because Employee has the method
                                  // runs Manager's override: 11,600,000

Le type statique Employee détermine les méthodes que vous avez le droit d'appeler. Le type dynamique Manager détermine le corps qui s'exécute. C'est ce qui permet à une List<Employee> de contenir des salariés, des managers et des prestataires, et de calculer la masse salariale totale avec une seule boucle qui ne mentionne jamais aucune sous-classe.

Deux choses ne font pas l'objet d'une liaison dynamique : les champs et les méthodes static. L'accès à un champ e.someField est résolu d'après le type statique, et une méthode static appelée via une référence est elle aussi résolue d'après le type statique (le compilateur émet d'ailleurs un avertissement quand on appelle une méthode static via une instance). Si vous vous surprenez à vouloir « redéfinir » une méthode static, c'est qu'il vous faut une méthode d'instance.

La redéfinition obéit à des règles que le compilateur fait respecter. La méthode redéfinie doit avoir le même nom et les mêmes types de paramètres ; son type de retour peut être un sous-type de l'original (type de retour covariant) ; elle ne peut pas être moins accessible que l'original ; et elle ne peut pas déclarer d'exceptions vérifiées plus larges. Ces règles garantissent que tout appelant écrit pour la superclasse continue de fonctionner quand on lui passe une sous-classe.

Classes abstraites

Une classe abstraite ne peut pas être instanciée et peut déclarer des méthodes abstraites — des signatures sans corps que les sous-classes concrètes doivent implémenter. C'est le bon outil quand plusieurs implémentations partagent un état et une partie d'un algorithme, mais diffèrent sur certaines étapes :

public abstract class Report {
    private final String title;

    protected Report(String title) { this.title = title; }

    public final String render() {                // template: fixed skeleton
        return header() + "\n" + body() + "\n" + footer();
    }

    protected String header() { return "== " + title + " =="; }
    protected String footer() { return "-- end --"; }
    protected abstract String body();             // each subclass supplies this
}

public class SalesReport extends Report {
    private final long totalCents;

    public SalesReport(long totalCents) {
        super("Sales");
        this.totalCents = totalCents;
    }

    @Override
    protected String body() { return "Total: " + totalCents / 100.0; }
}

C'est le pattern Template Method : la classe abstraite fixe la séquence et les sous-classes comblent les trous. Remarquez le final sur render() : le squelette lui-même n'est pas renégociable.

Interfaces

Une interface déclare un contrat — un ensemble de signatures de méthodes — sans état d'instance. Une classe implémente autant d'interfaces qu'elle le souhaite ; c'est la réponse de Java à l'héritage multiple : on peut hériter de types issus de nombreuses sources, mais d'une implémentation issue d'une seule superclasse.

public interface Shape {
    double area();

    default String describe() {                   // default method: has a body
        return getClass().getSimpleName() + " with area " + area();
    }

    static Shape unitSquare() {                   // static method on the interface
        return new Rectangle(1, 1);
    }
}

public record Rectangle(double width, double height) implements Shape {
    @Override public double area() { return width * height; }
}

public record Circle(double radius) implements Shape {
    @Override public double area() { return Math.PI * radius * radius; }
}

Les méthodes d'une interface sont implicitement public abstract ; ses champs sont implicitement public static final, si bien qu'une interface n'est pas un endroit où conserver un état. Depuis Java 8, une interface peut aussi contenir :

  • Des méthodes default — des implémentations héritées par toutes les classes qui l'implémentent, sauf redéfinition. Elles existent pour qu'une interface très largement implémentée puisse gagner une méthode sans casser les classes existantes ; Collection.stream() et Iterable.forEach() ont été ajoutées ainsi.
  • Des méthodes static — des méthodes utilitaires ou des fabriques rattachées à l'interface.
  • Des méthodes private (Java 9) — des méthodes d'aide partagées entre méthodes default, invisibles des implémentations.

Si une classe hérite de deux méthodes default de même signature provenant de deux interfaces, elle ne compile pas tant qu'elle ne redéfinit pas elle-même la méthode ; à l'intérieur, elle peut choisir l'une d'elles avec InterfaceName.super.method(). Une méthode héritée d'une superclasse l'emporte toujours sur une méthode default d'interface, ce qui préserve la stabilité des hiérarchies de classes existantes quand on ajoute des méthodes default.

Interface ou classe abstraite ?

InterfaceClasse abstraite
Champs d'instanceNonOui
ConstructeursNonOui
Héritage multipleOui, sans limiteNon, une seule superclasse
Corps de méthodesDefault, static et private uniquementTous
Niveaux d'accèsPublic (ou méthodes d'aide private)Tous
ExprimeUne capacité ou un rôleUne implémentation partielle avec un état partagé

Commencez par une interface. S'il s'avère que plusieurs implémentations partagent des champs et du code, ajoutez une classe abstraite qui implémente l'interface et faites-en hériter ces implémentations ; les autres peuvent toujours implémenter l'interface directement. C'est ainsi que List et AbstractList s'articulent dans le JDK.

Une interface marqueur ne déclare aucune méthode et sert uniquement à étiqueter un type — Serializable en est l'exemple le plus connu. Les annotations ont remplacé la plupart des nouveaux usages de cette idée, mais vous la croiserez dans du code existant.

Hiérarchies sealed et pattern matching

Une interface classique est ouverte : n'importe qui peut l'implémenter, si bien qu'un code qui fait un switch sur « toutes les sortes de formes » ne peut jamais voir son exhaustivité vérifiée. Java 17 a finalisé les classes et interfaces sealed, qui nomment leurs sous-types autorisés :

public sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String authorisationCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(java.time.Instant retryAfter) implements PaymentResult {}

Chaque sous-type autorisé doit lui-même être final, sealed ou explicitement non-sealed, et les records sont implicitement final : cette hiérarchie est donc fermée. Le pattern matching pour switch de Java 21 (JEP 441) permet alors au compilateur de vérifier l'exhaustivité :

static String message(PaymentResult result) {
    return switch (result) {
        case Approved a -> "Approved, code " + a.authorisationCode();
        case Declined d -> "Declined: " + d.reason();
        case Pending p  -> "Pending until " + p.retryAfter();
        // no default needed: the compiler knows these are all the cases
    };
}

Ajoutez un quatrième record à permits, et chacun de ces switch cesse de compiler tant qu'il ne traite pas le nouveau cas. C'est précisément la sécurité qu'une branche default aurait sacrifiée. Les record patterns (case Approved(String code) ->) déstructurent directement les composants, et les gardes (case Declined d when d.reason().startsWith("fraud") ->) affinent un cas.

Les types sealed éloignent certaines conceptions du polymorphisme classique. Si le comportement varie selon le sous-type et trouve naturellement sa place dans ce sous-type, utilisez une méthode redéfinie. S'il appartient plutôt au consommateur — sérialisation, rendu, règles de tarification qui évoluent plus souvent que les types —, un switch sur une hiérarchie sealed garde cette logique regroupée sans imposer au type les préoccupations de chaque module.

Les anciens tests instanceof suivis d'un cast ont eux aussi été simplifiés : if (obj instanceof Circle c) { ... c.radius() ... } lie la variable de pattern en une seule étape (Java 16).

Préférer la composition à l'héritage

L'héritage lie une sous-classe à l'implémentation de sa superclasse, pas seulement à son interface. Une modification d'une méthode de la superclasse peut casser une sous-classe qui dépendait de la façon dont elle appelait d'autres méthodes : c'est le problème de la « classe de base fragile » (fragile base class). La manière la plus propre de réutiliser un comportement est la composition : conserver une référence vers l'objet qui possède ce comportement et lui déléguer le travail.

// Inheritance: brittle. If HashSet.addAll is later changed to call add,
// or stops calling it, this count silently becomes wrong.
class CountingSet<E> extends HashSet<E> {
    private int added;
    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
}

// Composition: robust. Behaviour depends only on Set's public contract.
class CountingSet<E> implements Set<E> {
    private final Set<E> inner;
    private int added;
    CountingSet(Set<E> inner) { this.inner = inner; }
    @Override public boolean add(E e) { added++; return inner.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return inner.addAll(c); }
    // ... every other Set method forwards to inner
}

La version composée demande plus de code (une classe abstraite de transfert en élimine l'essentiel), mais elle ne peut pas être cassée par les détails internes de HashSet, fonctionne avec n'importe quelle implémentation de Set et se teste avec une fausse implémentation. Utilisez l'héritage quand il existe une véritable relation est-un et que la superclasse est conçue pour cela ; utilisez la composition pour les relations a-un et pour réutiliser un comportement.

Erreurs courantes

Appeler des méthodes redéfinissables depuis un constructeur. La redéfinition de la sous-classe s'exécute avant l'initialisation des champs de celle-ci, et lit donc des valeurs par défaut (null, 0). Un constructeur ne devrait appeler que des méthodes private ou final.

Rompre le contrat de la superclasse. Une redéfinition qui lève une exception là où le parent n'en levait pas, ignore un argument que le parent prenait en compte ou affaiblit une postcondition viole le principe de substitution de Liskov : les appelants écrits pour le parent se mettent à échouer. Si la sous-classe ne peut pas honorer le contrat, ce n'est pas un sous-type.

Les hiérarchies profondes. Quatre niveaux d'extends pour partager quelques méthodes utilitaires, c'est un piège pour la maintenance. Aplatissez avec la composition et les interfaces.

Oublier @Override. Un nom de méthode mal orthographié (equal au lieu de equals) compile comme une toute nouvelle méthode, et votre redéfinition ne s'exécute jamais.

Des branches default dans les switch sur types sealed. Elles compilent, mais désactivent la vérification d'exhaustivité qui justifiait le scellement.

Exposer des champs protected. Ils figent la représentation de la superclasse pour toutes les sous-classes qui existent.

Confondre chaînes d'instanceof et conception. Une méthode qui teste instanceof sur cinq sous-types puis aiguille est généralement le signe d'une méthode polymorphe manquante — ou, si les types vous appartiennent et forment un ensemble fermé, d'un switch sur une hiérarchie sealed.

Points clés à retenir

  • Le polymorphisme signifie que du code écrit pour un supertype fonctionne avec n'importe quel sous-type ; à l'exécution, la redéfinition est choisie d'après la classe réelle de l'objet, tandis que le compilateur vérifie l'appel d'après le type déclaré.
  • Les champs et les méthodes static ne font pas l'objet d'une liaison dynamique ; seules les méthodes d'instance en bénéficient.
  • Les interfaces décrivent des capacités et permettent l'héritage multiple de type ; les classes abstraites partagent un état et des implémentations partielles. Commencez par une interface.
  • Les méthodes default permettent aux interfaces d'évoluer ; une classe doit résoudre explicitement les conflits entre méthodes default.
  • Les interfaces sealed associées au switch avec pattern matching offrent un traitement vérifié par le compilateur d'un ensemble fermé de variantes — laissez de côté le default.
  • Réutilisez le comportement par composition et délégation ; réservez l'héritage aux véritables relations est-un, avec une superclasse conçue pour être étendue.

Sources et lectures complémentaires

Questions fréquentes

Faut-il utiliser une classe abstraite ou une interface ?
Utilisez une interface pour décrire une capacité que des classes sans lien entre elles peuvent partager ; une classe peut en implémenter plusieurs. Utilisez une classe abstraite quand les implémentations partagent un état ou une implémentation partielle qui exige des champs, des constructeurs ou des membres non publics. Dans le doute, commencez par une interface : vous pourrez toujours lui adjoindre plus tard une classe squelette abstraite.
Pourquoi Java ne permet-il pas l'héritage multiple de classes ?
Deux superclasses pourraient fournir des champs et des implémentations contradictoires, sans règle propre pour décider laquelle l'emporte. Java permet à la place d'implémenter autant d'interfaces que l'on veut, et depuis Java 8 les interfaces peuvent porter des méthodes default ; les conflits entre méthodes default doivent être résolus explicitement par la classe qui les implémente.
Quelle différence entre redéfinition (overriding) et surcharge (overloading) ?
La redéfinition remplace une méthode de la superclasse par une version de la sous-classe de même signature, choisie à l'exécution selon la classe réelle de l'objet. La surcharge déclare dans une même classe plusieurs méthodes de même nom mais aux paramètres différents, et c'est le compilateur qui choisit entre elles, à la compilation, d'après le type des arguments.
Quand utiliser une interface sealed ?
Quand l'ensemble des implémentations est fixé par conception et que du code ailleurs doit traiter chaque cas : états d'une commande, nœuds d'une expression, types de résultat. Le scellement permet au compilateur de vérifier qu'un switch couvre tous les sous-types autorisés ; ajouter une variante plus tard provoque donc des erreurs de compilation précisément là où son traitement manque.