Capsule de théorie Héritage et sous‑typage

Héritage et classe abstraite

En programmation orientée objet, la notion d’héritage recouvre le plus souvent deux aspects distincts :

  • La réutilisation de code (héritage d’implémentation)
  • Le sous‑typage

L’héritage d’implémentation permet de créer une nouvelle classe en étendant les fonctionnalités d’une classe existante. La classe qui est étendue est appelée classe de base (base class, parent class ou super class), et la classe qui l’étend est appelée classe dérivée (derived class ou extended class). La classe dérivée hérite de l’implémentation de la classe de base. L’héritage permet donc d’étendre une classe existante sans avoir à modifier son code. De fait, l’héritage est l’un des mécanismes qui permettent de satisfaire le principe ouvert-fermé (Open-closed principle). En Java, une classe est toujours directement ou indirectement une classe dérivée de la classe Object. Si l’on n’utilise pas explicitement le mot clé extends dans la déclaration d’une classe, celle‑ci étend implicitement la classe Object, c’est pourquoi toutes les classes Java ont, notamment, des méthodes telles que toString ou equals qu’il est possible de redéfinir (override).

En programmation orienté objet, une classe est l’implémentation d’un type, mais cette implémentation peut être partielle. C’est-à-dire que certaines de ses méthodes d’instance publiques sont déclarées, mais pas implémentées (le corps de ces méthodes est absent). Une classe dont l’implémentation du type est partielle est une classe abstraite. Puisque l’implémentation est incomplète, cette classe ne peut pas être instanciée. Pour créer un objet dont le type est celui d’une classe abstraite, on doit étendre cette classe abstraite à l’aide du mécanisme d’héritage pour compléter l’implémentation du type. Lorsqu’une classe étend une autre classe, le type de la classe dérivée devient un sous‑type de celui de classe de base. Si l’on respecte le principe de substitution de Liskov (Liskov substitution principle), cela signifie qu’une instance de la classe dérivée peut être utilisée partout où une valeur du type de la classe de base est attendue sans modifier les propriétés du programme.

Nous pouvons étendre la définition de la notion de classe pour couvrir ce cas de figure; une classe est :

  • L’implémentation complète ou partielle d’un type.
  • Un truc pour construire des objets si l’implémentation est complète.
  • Une unité d’organisation du code.

En UML, on représente l’extension d’une classe de base par un trait continu terminé par une pointe de flèche fermée. Dans l’exemple ci‑dessous, les classes R et S étendent la classe de base T. La relation entre les classes dérivées et la classe de base est appelée relation d’extension. Le nom des classes abstraite et des méthodes virtuelles pures (voir plus bas) est écrit en italique.

Fig. 1 — Relation d'extension
Fig. 1 — Relation d'extension (héritage d'implémentation)

Interface

De manière générale, l’interface d’une classe est l’ensemble des méthodes d’instance publiques de cette classe. En Java, une interface désigne également une forme particulière de classe que l’on déclare à l’aide du mot clé interface et qui contient seulement la signature de ses méthodes, pas leur implémentation. On peut considérer une interface comme une classe dont toutes les méthodes sont virtuelles pures. On peut donc dire qu’une interface en Java est une classe qui se réduit à la spécification d’un type (ce qui est conforme à l’acception d’interface en programmation orientée objet). Le nom d’une interface, comme le nom d’une classe, peut se trouver partout où le nom d’un type est attendu. En revanche, comme elle ne fournit pas d’implémentation pour ses méthodes, on ne peut pas l’utiliser pour créer des objets. Pour pouvoir instancier des objets du type défini par une interface, on doit d’abord créer une classe qui implémente (réalise) cette interface à l’aide du mot clé implements. Une classe peut implémenter une ou plusieurs interfaces.

Fig. 2 — Relation de réalisation et relation d'extension
Fig. 2 — Relation de réalisation (héritage d'interface)

En UML, on représente l’implémentation d’une interface par une classe, à l’aide d’un trait discontinu terminé par une pointe de flèche fermée. La figure 2 montre une classe S qui implémente une interface T. La relation entre la classe qui implémente l’interface, et l’interface est appelée relation de réalisation. Dans l’exemple de la figure 3, la classe S réalise (implémente) les interface T et U.

Fig. 3 — Réalisation (implémentation) de plusieurs interfaces
Fig. 3 — Réalisation (implémentation) de plusieurs interfaces

Dans l’exemple de la figure 4, l’interface T étend les interfaces U et V, et la classe R implémente l’interface T et étend la classe S. Il est à noter que la relation entre l’interface T et les interfaces U et V est une relation d’extension (trait continu). En effet, puisqu’une interface n’a pas d’implémentation, cela n’aurait pas de sens de dire que l’interface T « implémente » les interfaces U et V. Nous disons donc que l’interface T « étend » les interface U et V. En Java, la relation d’extension (trait continu) se traduit toujours par le mot clé extends et la relation de réalisation (trait discontinu) se traduit toujours par le mot clé implements. Cet exemple montre également que le sous-typage est transitif : si T est un sous‑type de U et R est un sous‑type de T, alors R est un sous‑type de T.

Fig. 4 — Héritage d'interface et héritage d'implémentation
Fig. 4 — Héritage d'interface et héritage d'implémentation

Méthode virtuelle et redéfinition (overriding)

Lorsqu’on crée une nouvelle classe en étendant une classe de base, on peut non seulement ajouter des méthodes, mais également redéfinir (override) les méthodes virtuelles de la classe de base. Une méthode n’est pas nécessairement virtuelle, elle peut être :

  • Virtuelle pure : elle doit être redéfinie.
  • Virtuelle : elle peut être redéfinie.
  • Non-virtuelle : elle ne peut pas être redéfinie.

En Java, les méthodes sont virtuelles par défaut, il n’y a donc rien à faire pour permettre leur redéfinition dans une classe dérivée. On peut, par contre, interdire la redéfinition d’une méthode à l’aide du modificateur final ou, au contraire, rendre sa redéfinition obligatoire avec le modificateur abstract. Si l’une des méthodes d’une classe est virtuelle pure, alors cette classe doit obligatoirement être déclarée comme étant abstraite. Dans le cas d’une interface, toutes les méthodes sont implicitement virtuelles pures.

Lorsqu’on redéfinit une méthode, on peut invoquer la méthode de la classe de base à l’aide du mot clé super. On peut, en outre, utiliser l’annotation @Override pour indiquer au compilateur notre intention de redéfinir la méthode et lui permettre de vérifier que la signature correspond bien à la signature de la méthode originale.

Sous‑type et classe dérivée

Du point de vue du compilateur, l’utilisation d’un mécanisme d’héritage (en Java, implements ou extends) implique toujours la création d’un sous‑type. Cela signifie qu’il acceptera une instance d’une classe dérivée partout (à quelques exceptions près) où une instance de la classe de base est attendue.

Le compilateur ne peut pas vérifier que le sous‑type que vous avez créé répond effectivement à la définition d’un sous‑type selon le principe de substitution de Liskov; c’est au programmeur de s’assurer qu’une valeur du sous‑type qu’il a créé peut effectivement être substituée à une valeur du type de la classe de base. Pour cela, on peut utiliser la règle suivante : un type B est vraisemblablement un sous‑type d’un type A, si l’on peut dire que B est un A. Par exemple, le type «chien» peut être considéré comme un sous‑type de «mammifère», car cela a un sens de dire qu’un chien est un mammifère; a contrario, cela n’a aucun sens de dire qu’une maison est une brique, «maison» ne peut donc pas être un sous‑type de «brique». Toutefois, cette règle n’est pas absolue et même en l’appliquant, il est très facile de violer le LSP comme le montre l’exemple de la hiérarchie rectangle/carré.