Hiérarchie de classes
La hiérarchie de classes correspond à une hiérarchie de type / sous-type entre classes. Elle organise les trois catégories de classes : concrète, abstraite et abstraite pure. La construction de cette hiérarchie se fait au travers du mécanisme d'héritage.
L'héritage ou lien "est-un"
Définition : le mécanisme d'héritage inclut dans la construction d'une classe les informations de la construction / définition d'une autre classe (existante).
Si une classe D hérite d'une classe B, la définition de la classe D inclut automatiquement le prototype des méthodes (d'instance et de classe) et la réalisation (code des méthodes, variables) de la classe B.
Toutes les modifications de la classe B se répercutent sur la classe D. La classe D est appelée "classe dérivée", "sous-classe" ou "classe fille" et la classe B est appelée "classe de base" ou "classe mère".
On distingue le mécanisme d'héritage en :
- héritage simple si le mécanisme n'accepte qu'une seule classe de base directe. La hiérarchie correspond à un arbre (ou à une forêt s'il n'existe pas de racine commune) ;
- héritage multiple si le mécanisme accepte plusieurs classes de base directes. La hiérarchie correspond alors à un graphe orienté sans cycle : une classe peut avoir plusieurs parents.
Lien "est-un"
L'héritage est un lien entre classes / types nommé dans le cours "est-un" ("is-a") : D hérite de B ou D est un B. Il y a au moins deux aspects à considérer avec le lien "est-un".
- La relation de type / sous-type : la nouvelle classe inclut le prototype (et la spécification) des méthodes de l'ensemble des classes impliquées dans la hiérarchie d'héritage. Le mécanisme d'héritage définit une relation de type / sous-type. L'intérêt est de permettre la substitution d'objets. C'est une propriété pour le client de la hiérarchie.
- La réutilisation de la réalisation : la nouvelle classe inclut la réalisation (corps des méthodes et variables) de l'ensemble des classes impliquées dans la hiérarchie d'héritage. L'intérêt est de permettre de factoriser du code. C'est une propriété pour le développeur.
Ces deux aspects ne sont pas forcément liés. Mais, en général, le mécanisme d'héritage ne les distingue pas. Il faut les gérer en même temps, ce qui peut introduire des incohérences dans la hiérarchie de classes. La hiérarchie doit plutôt mettre l'accent sur la relation de type / sous-type.
En Java
Dans la construction d'une classe, le langage Java sépare une classe avec réalisation et une classe sans réalisation. De ce fait, le lien "est-un" correspond à trois mécanismes d'héritage :
- l'héritage entre une classe et des interfaces ;
- l'héritage entre interfaces ;
- l'héritage entre classes.
Héritage d'interfaces
C'est un mécanisme d'héritage multiple.
Héritage entre une classe et des interfaces
[public] [abstract] class NomClasse implements UneInterface, UneAutreInterface {
}
Le mot-clé implements indique que la nouvelle classe correspond à une
réalisation des différentes interfaces de la liste.
Héritage entre interfaces
[public] interface NomInterface extends UneInterface, UneAutreInterface {
}
La nouvelle interface "inclut" toutes les déclarations contenues dans les
interfaces spécifiées par le mot-clé extends. Ce lien "est-un" permet à une
interface d'étendre ou de réunir des fonctionnalités d'autres interfaces.
Héritage entre classes
Le mot-clé extends définit la relation d'héritage entre deux classes. C'est un
héritage simple avec comme racine la classe java.lang.Object. Une même classe
peut avoir un héritage simple sur une construction class et un héritage
multiple sur des constructions interface.
Définition d'une classe avec réalisation
[public] [abstract | final] class NomClasse [extends UneClasse] [implements UneInterface, UneAutreInterface] {
// Membres variables
[final] [public | protected | private] type varInstance [= expression];
static [final] [public | protected | private] type varClasse [= expression];
// Membres méthodes
[final] [public | protected | private] typeRetour methodeInstance([liste de paramètres]) {
// code
}
abstract [public | protected] typeRetour methodeAbstraite([liste de paramètres]);
static [final] [public | protected | private] typeRetour methodeClasse([liste de paramètres]) {
// code
}
// Membres constructeurs
[public | protected | private] NomClasse([liste de paramètres]) {
// code
}
}
Sans présence du mot-clé extends, une classe hérite par défaut de la classe
java.lang.Object. La classe Object contient des méthodes d'instance communes
à tous les objets comme toString(), equals() ou getClass(). Le mot-clé
final devant une classe bloque l'héritage : cette classe n'admet pas de
sous-classe. Le mot-clé abstract devant une classe indique une classe
abstraite, c'est-à-dire non instanciable. Une classe abstraite est destinée à
avoir des sous-classes : elle ne peut pas être déclarée avec le mot-clé final.
Pour notre cas d'école, nous souhaitons ajouter un service de verrou à l'encapsulation "porte charnière".
package porte;
public class PorteVerrouCharniere extends PorteCharniere {
private boolean estVerrouille;
public PorteVerrouCharniere() {
estVerrouille = false;
}
public void verrouiller() {
estVerrouille = true;
}
public void deverrouiller() {
estVerrouille = false;
}
public boolean estVerrouille() {
return estVerrouille;
}
}
// Utilisation
PorteVerrouCharniere verrou = new PorteVerrouCharniere();
verrou.fermer();
verrou.estFerme();
verrou.estVerrouille();
Les membres d'une classe dérivée regroupent :
- les variables, méthodes et constructeurs définis dans la classe ;
- les membres hérités (variables et méthodes) définis dans les classes de base (directes ou indirectes).
Accès pour les classes dérivées
Une classe dérivée ne fait pas forcément partie du même paquetage que sa classe
de base. Le modificateur d'accès protected permet un accès privilégié aux
membres de la classe de base par les classes dérivées et par les classes du
même paquetage.
Pour éviter les dépendances de réalisation entre la classe de base et ses
classes dérivées, il est nécessaire de respecter l'encapsulation en évitant
d'avoir des attributs en accès protected.
Variable héritée
Les variables héritées d'une classe correspondent à l'ensemble des variables d'instance et des variables de classe définies dans la hiérarchie de classes (à partir de sa classe de base).
L'instanciation d'une classe dérivée crée une seule zone mémoire. Toutes les variables d'instance héritées (accessibles ou non) se trouvent dans cette zone mémoire.
Il n'est pas possible de modifier la déclaration (type, portée) d'une variable héritée. Déclarer dans la classe dérivée une variable de même nom ne la modifie pas : cela crée une nouvelle variable qui masque la variable héritée (pratique à éviter).
Constructeur
Les constructeurs ne sont pas hérités. Ils doivent être définis dans la classe dérivée (à défaut, seul le constructeur par défaut sans paramètre est ajouté par le compilateur).
L'instanciation d'une classe dérivée est valide uniquement si les attributs définis par les classes de base sont initialisés. Les attributs d'une classe de base sont initialisés avant ceux de la classe dérivée.
Le code du constructeur de la classe dérivée ne peut s'exécuter qu'après celui
du constructeur de la classe de base. Cet ordre d'initialisation impose un
chaînage d'appel des constructeurs des classes de base. En général, le
programmeur déclenche explicitement l'appel à un des constructeurs de la classe
de base directe en utilisant l'instruction super(...). Le choix du
constructeur se fait à partir du type des paramètres (mécanisme de surcharge).
L'instruction super(...) doit être la première instruction du
constructeur.
class PorteVerrouCoulissante extends PorteCoulissante {
// ...
public PorteVerrouCoulissante(int pasMax) {
super(pasMax);
// initialisation des attributs de la classe
}
}
Lorsqu'il n'y a pas d'instruction super(...) ou this(...) en première
instruction du constructeur, le compilateur insère un appel au constructeur sans
paramètre de la classe de base ("appel implicite"). Si la classe de base ne
possède pas de constructeur sans paramètre accessible, la compilation échoue.
public PorteVerrouCharniere() {
// appel implicite à super();
estVerrouille = false;
}
Méthode héritée
En fonction des portées, les méthodes d'instance héritées sont applicables sur l'instance de la classe dérivée. Les méthodes de classe de la classe de base sont applicables sur la classe dérivée.
PorteVerrouCharniere v = new PorteVerrouCharniere();
v.fermer();
v.verrouiller();
Le mécanisme de redéfinition des méthodes d'instance
Dans une classe dérivée, il est parfois nécessaire d'adapter le code d'une méthode d'instance héritée à la nouvelle réalisation. La redéfinition permet de réécrire le code d'une méthode héritée. Ce mécanisme n'est applicable que sur une méthode accessible et en respectant le prototype (nom et types des paramètres) déclaré dans la classe de base :
- la portée peut être élargie (protégée en publique, paquetage en protégée ou publique), jamais restreinte ;
- le type de retour peut être un sous-type du type de retour de la méthode de base (type de retour covariant) ;
- la clause
throwsne peut pas ajouter d'exception vérifiée qui ne soit pas un sous-type de celles déclarées par la méthode de base.
L'annotation @Override placée devant la méthode demande au compilateur de
vérifier qu'il s'agit bien d'une redéfinition (et non d'une surcharge
involontaire).
Prenons l'exemple de la classe PorteVerrouCharniere. Cette classe hérite de la
classe PorteCharniere et doit redéfinir le code de la méthode d'instance
ouvrir() mais en réutilisant le code de cette méthode dans la classe de base.
public class PorteVerrouCharniere extends porte.PorteCharniere {
// ...
public void ouvrir() {
if (!estVerrouille())
ouvrir();
}
}
Dans ce cas, nous avons un appel récursif. Nous devons avoir un moyen de
désigner la définition de la méthode dans la classe de base directe :
super.ouvrir(). Par défaut, toutes les méthodes d'instance accessibles par la
classe dérivée peuvent être redéfinies. Le mot-clé final devant le prototype
de la méthode empêche la redéfinition de cette méthode dans les classes
dérivées.
Retour sur le polymorphisme
Pour assurer la substitution d'objets, l'aiguillage vers la méthode doit se faire à l'exécution. Pour cela, la liaison dynamique est employée sur les méthodes d'instance.
PorteVerrouCharniere v = new PorteVerrouCharniere();
v.fermer();
v.verrouiller();
v.ouvrir();
v.estFerme();
PorteCharniere c = v;
c.ouvrir(); // liaison dynamique : code de PorteVerrouCharniere.ouvrir()
c.estFerme(); // méthode héritée de PorteCharniere
Voici une version du même service verrou appliqué à l'encapsulation "porte coulissante" :
public class PorteVerrouCoulissante extends PorteCoulissante {
private boolean estDeVerrouille;
public PorteVerrouCoulissante(int pasMax) {
super(pasMax);
estDeVerrouille = true;
}
public void verrouiller() {
estDeVerrouille = false;
}
public void deverrouiller() {
estDeVerrouille = true;
}
public boolean estVerrouille() {
return !estDeVerrouille;
}
@Override
public void ouvrir() {
if (estDeVerrouille)
super.ouvrir();
}
}
Au sujet des méthodes de classe
Le langage Java permet de redéclarer dans une classe dérivée une méthode de
classe héritée (toujours en respectant le prototype défini dans la classe de
base). Mais avec une restriction forte : l'aiguillage des méthodes de classe
s'effectue avec une liaison statique. Nous avons affaire à un mécanisme de
masquage et non à un mécanisme de redéfinition. Comme il est possible en Java
de déclencher une méthode de classe sur une instance, l'envoi du message a.m()
est ambigu par rapport à l'objectif de substitution d'objets dans le code : c'est
le type déclaré de a qui détermine la méthode appelée.
Classe abstraite
Une classe abstraite (abstract class en Java) n'est pas instanciable mais
contient quand même du code (attributs et méthodes). Dans la hiérarchie de
classes, une classe abstraite va servir à factoriser le code utilisé dans les
classes dérivées. Pour respecter l'encapsulation, une classe abstraite
définissant des attributs doit définir un ou plusieurs constructeurs. Dans une
classe, une méthode abstraite (sans corps) est déclarée avec le mot-clé
abstract et sans accolades. Le code d'une méthode abstraite est défini dans
les classes dérivées. Une classe contenant une méthode abstraite est forcément
abstraite. La réalisation de cette classe abstraite peut faire appel à la
méthode abstraite. Par polymorphisme, l'exécution de cet appel déclenchera le
code d'une des classes dérivées. La liaison statique pour les méthodes de classe
implique qu'une méthode de classe ne peut pas être abstraite.
Factoriser la méthode envoyerCommande()
Nous modifions l'héritage de classes. Le code à factoriser est contenu dans une
classe abstraite PorteAbstraite qui devient la classe de base des classes
PorteCharniere et PorteCoulissante.
package porte;
import matos.onde.DentBleu;
public abstract class PorteAbstraite {
private final DentBleu canal;
protected PorteAbstraite(DentBleu d) {
canal = d;
}
final protected void envoyerCommande(String... cmms) {
canal.connecter();
for (int i = 0; i < cmms.length; i++)
canal.envoyer(cmms[i]);
canal.deconnecter();
}
}
Dans ce cas, le code factorisé est déclenché par les classes dérivées.
package porte;
public class PorteCharniere extends PorteAbstraite {
private boolean estFerme;
public PorteCharniere() {
super(new matos.onde.DentBleu());
_ouvrir_();
}
public boolean estFerme() {
return estFerme;
}
public void fermer() {
envoyerCommande("pivoter_charniere");
estFerme = true;
}
public void ouvrir() {
_ouvrir_();
}
private void _ouvrir_() {
envoyerCommande("manoeuvrer_bec", "pivoter_charniere");
estFerme = false;
}
}
Pour traiter la substitution d'objets dans la classe Telecommande, la classe
abstraite PorteAbstraite doit posséder un lien "est-un" avec l'interface
Porte.
Factoriser le traitement du verrou
De la même façon, le code est factorisé dans une classe de base commune aux deux
classes. Le code de la méthode ouvrir() contient une partie de code à
factoriser et une partie de code dépendant de chaque réalisation de
l'abstraction "porte". La méthode ouvrir() est définie dans la classe de base
avec le code factorisé. Pour contenir le code dépendant des réalisations, nous
définissons une méthode abstraite. Cette fois, c'est la classe abstraite qui
déclenche un traitement sur les classes dérivées (patron de conception méthode
patron, ou template method).
package porte;
import matos.onde.DentBleu;
public abstract class PorteAbstraite {
private final DentBleu canal;
private boolean estDeVerrouille;
final protected void envoyerCommande(String... cmms) {
canal.connecter();
for (int i = 0; i < cmms.length; i++)
canal.envoyer(cmms[i]);
canal.deconnecter();
}
protected PorteAbstraite(DentBleu d) {
canal = d;
estDeVerrouille = true;
envoyerCommande("enlever_verrou");
}
public void verrouiller() {
envoyerCommande("mettre_verrou");
estDeVerrouille = false;
}
public void deverrouiller() {
envoyerCommande("enlever_verrou");
estDeVerrouille = true;
}
public boolean estVerrouille() {
return !estDeVerrouille;
}
// code factorisé de la méthode ouvrir()
final public void ouvrir() {
if (estDeVerrouille)
faireOuvrir();
}
// code de la méthode ouvrir() dépendant des réalisations
protected abstract void faireOuvrir();
}
Substitution d'objets et factorisation de code
Le cas de la factorisation du verrou peut s'envisager d'une autre manière. Nous
allons encapsuler le code à factoriser, c'est-à-dire placer le code factorisé
dans un objet séparé des instances de PorteCharniere et de PorteCoulissante.
Nous définissons une classe Verrou contenant la réalisation du verrou et les
méthodes définies dans l'interface Porte. Le code factorisé est appliqué sur
les instances de PorteCharniere et de PorteCoulissante. La classe Verrou
possède un lien "a-un" pour déclencher les méthodes sur ces instances. Cette
solution nécessite une substitution des instances de PorteCharniere et de
PorteCoulissante dans le code de la classe Verrou. Nous avons besoin de
l'interface Porte et de la relation de type / sous-type (c'est le principe du
patron de conception décorateur).
Héritage multiple et choix des membres hérités
Dans le cas de l'héritage multiple, la déclaration / signature d'une méthode abstraite ou la définition d'un membre hérité peut provenir de plusieurs classes de base directes.
L'héritage multiple de la déclaration / signature d'une méthode abstraite ne provoque pas de cas ambigu de choix. La déclaration / signature est unique même si elle est répétée, et un code est à fournir pour cette déclaration.
interface Allsafe {
public void mrRobot();
}
interface Evilcorp {
public void mrRobot();
}
abstract class Fsociety {
abstract public void mrRobot();
}
class Elliot extends Fsociety implements Allsafe, Evilcorp {
public void mrRobot() {
System.out.println("hello");
}
}
Par contre, l'héritage multiple de définition (de code) peut aboutir à une ambiguïté dans le choix de la définition à utiliser. Un exemple avec des variables de classe constantes :
interface Ulukai {
public static final int MAX = -999;
}
interface Adelpha {
public static final int MAX = 666;
}
interface Outcast extends Adelpha, Ulukai {}
L'accès Outcast.MAX est ambigu ; le compilateur provoque une erreur. Pour
l'éviter, il faut soit qualifier l'accès (Ulukai.MAX), soit définir dans
Outcast une autre variable MAX qui va masquer les deux autres :
public static final int MAX = Ulukai.MAX;.
Les interfaces en Java 8
La version 8 du langage Java a modifié la construction interface. Elle peut
contenir la définition de méthodes de classe et le code par défaut de méthodes
d'instance.
[public] interface NomInterface [extends Interface1, Interface2] {
static final public type nom = valeur;
[public] static type methodeClasse([liste paramètres]) {
// code obligatoire
}
default [public] type methodeInstanceAvecCode([liste paramètres]) {
// code par défaut
}
}
Le code par défaut de la méthode d'instance est utilisé si la classe réalisant cette interface ne redéfinit pas cette méthode.
Ce mécanisme de code par défaut (default method) permet d'ajouter de nouvelles fonctionnalités à une interface sans casser la compatibilité (binaire et source) des classes écrites pour la version précédente de cette interface. Le cas classique d'héritage multiple d'interfaces :
interface Ulukai {
public static void mClasse() {
System.out.println("Classe_Ulukai");
}
public default void mInstance() {
System.out.println("Ulukai");
}
}
interface Adelpha {
public static void mClasse() {
System.out.println("Classe_Adelpha");
}
public default void mInstance() {
System.out.println("Adelpha");
}
}
interface Outcast extends Adelpha, Ulukai {
}
Pour éviter l'ambiguïté, une méthode de classe définie dans une interface n'est pas héritée. Elle est accessible uniquement à travers l'interface. Par contre, pour une méthode d'instance, il y a ambiguïté sur le code par défaut à utiliser. Le compilateur provoque une erreur.
error: interface Outcast inherits unrelated defaults for mInstance() from types Adelpha and Ulukai
Il faut redéfinir le code par défaut dans l'interface Outcast.
interface Outcast extends Adelpha, Ulukai {
public default void mInstance() {
Adelpha.super.mInstance();
// Ulukai.super.mInstance();
}
}
Comme l'objectif est de fournir un code par d éfaut à une méthode d'instance, un algorithme de résolution a été ajouté pour éviter les cas les plus fréquents d'ambiguïté.
- L'héritage de classe est prioritaire.
interface Allsafe {
public default void mrRobot() {
System.out.println("Allsafe");
}
}
class Fsociety {
public void mrRobot() {
System.out.println("Fsociety");
}
}
class Elliot extends Fsociety implements Allsafe {
}
// new Elliot().mrRobot() affiche "Fsociety"
C'est toujours la définition faite dans la hiérarchie de classes qui est choisie.
- Sans définition de la méthode dans la hiérarchie de classes, c'est la définition par défaut la plus spécifique (celle de la sous-interface la plus basse dans la hiérarchie d'interfaces) qui est choisie.
interface Allsafe {
public default void mrRobot() {
System.out.println("Allsafe");
}
}
interface Evilcorp extends Allsafe {
public default void mrRobot() {
System.out.println("Evilcorp");
}
}
class Fsociety implements Evilcorp {}
class Elliot extends Fsociety implements Allsafe {}
// new Elliot().mrRobot() affiche "Evilcorp" : Evilcorp est plus spécifique que Allsafe