Skip to main content

Substitution d'objets - 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 classe : 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 B. La classe D est appelée "classe dérivée", "sous-classe", "classe fille" et la classe B est appelée "classe de base", "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 avec une seule racine.
  • Héritage multiple si le mécanisme accepte plusieurs classes de base directes. La hiérarchie correspond à une forêt avec plusieurs racines.

Lien "est-un"

L'héritage est un lien entre classe/type 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.
  • 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 des méthodes et variables) de l'ensemble des classes impliquées dans la hiérarchiser 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. Du coup, le lien "est-un" correspond à trois mécanismes d'héritages :

  • l'héritage entre une classe et des interfaces
  • l'héritage entre interfaces
  • l'héritage entre classes

Héritages 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 méthodeInstance ([ liste de paramètres ])
{
// code
}
abstract [public | protected] typeRetour méthodeInstance ([ liste de paramètres ]);
static [final] [public | protected | private] typeRetour méthodeClasse ([ liste de paramètres ])
{
// code
}

// Membres constructeurs .
[public | protected | private] constructeur ([ liste de paramètres ])
{
//code
}
}

Sans préence 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 admet forcément 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 estVerouille(){
return estVerrouille;
}
}

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 (directe ou indirecte)

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 préférentiel par classes dérivées aux membres de la classe de base et aux classes du 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 classes 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.

Constructeur

Les constructeurs ne sont pas hérités. Il faut systématiquement les définir dans la classe dérivée.

L'instanciation d'une classe dérivé 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 constructeurs de la classe dérivée ne peut s'exécuter qu'après celui du constructeur de la classe de base. Cette 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 se trouver en première ligne du code 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() sur la première ligne du code du constructeur, le compilateur fait appel au constructeur sans paramètre de la classe de base ("appel implicite").

public PorteVerrouCharniere(){
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 déclaré dans la classe de base

  • la portée peut être élargie (protégée en public, paquetage en protégé ou public)
  • le type de retour peut-être un sous type du type de retour de la méthode

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(); // ??
c.estFerme(); // ??

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 la modification du code des méthodes de classe héritées (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 en Java, il est possible 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.

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 contiendra 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 PorteCouilissante.

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'objet 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.

package porte;

import matos.onde.DenBleu;

public abstact 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;
estDeVerouille = true;
envoyerCommande("mettre_verrou");
}

public void verrouiller(){
envoyerCommande("mettre_verrou");
estDeVerrouille = false;
}

public void deverrouiller(){
envoyerCommande("enlever_verrou");
estDeVerouille = 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 que 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.

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 Allsage{
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 constante :

interface Ulukai{
public static final int MAX = -999;
}

interface Adelpha{
public static final int MAX = 666;
}

interface Outcast extends Adelpha, Ulukai{}

L'appel Outcast.MAX est ambigu; le compilateur provoque une erreur. Pour l'éviter dans Outcast, il est nécessaire de définir une autre variable MAX qui va masquer les deux autres : public static final 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'instances.

[public] interface NomInterface [extends interface1, interface2]{
static final public type nom = valeur;

[public] static type methodeClasse([liste paramètre]);

default [public] type methodeInstanceAvecCode([liste paramètre]){

}
}

Le code par défaut de la méthode d'instance est uitlisé si la classe réalisant cette interface ne redéfinit pas cette méthode.

Ce mécanisme de code par défaut ("défault method") permet d'ajouter de nouvelles fonctionnalités à une interface sans régression du code binaire des sources écrits avant la version précédent 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 cases 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 Evilcorp{

}

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 dernière redéfinition dans la hiérarchie d'interfaces qui est choisie.
interface Allsafe{
public default void mrRobot(){
System.out.println("Allsafe");
}
}

class Fsociety{
public void mrRobot(){
System.out.println("Fsociety");
}
}

class Fsociety implements Evilcorp{}

class Elliot extends Fsociety implements Allsafe{}