Looking for Computer Science  & Information Technology online courses ?
Check my new web site: https://www.yesik.it !

Cet article est une reprise de l'article Encapsulation, héritage et polymorphisme que j'avais précédemment publié sur http://wiki.esicom-st-malo.fr

Cet article introduit trois notions clés de la programmation orientée objets: l'encapsulation, l'héritage et le polymorphisme.

L'encapsulation

Nous avons vu dans l'article introduction aux objets comment il était possible de modéliser dans un programme informatique un objet du mode réel. Nous avons en particulier introduit la notion de classe (famille ou groupe auquel appartiennent les objets).

Ainsi, par exemple, un programme de gestion pourrait manipuler des objets de la classe Employe:

/* La classe des employés */
public class Employe
{
    /* attributs */
    private String nom;
    private String prenom;
    private int echelon;
 
    /* constructeur */
    public Employe(String nomInitial,
                     String prenomInitial,
                     int echelonInitial) {
        nom = nomInitial;
        prenom = prenomInitial;
        echelon = echelonInitial;
    }
 
    /* méthodes */
    public void affiche() {
        System.out.println(nom + " " + prenom + " " + echelon +
                            " (" + calculeSalaire() + ")");
    }
 
    public void donnePromotion() {
        echelon = echelon+1;
    }
 
    public int calculeSalaire() {
        return 100000+echelon*12000;
    }
}

Remarque:

Si vous avez suivi l'article précédent, vous remarquerez que la classe Employe s'est quelque peu étoffée:

  • On a ajouté une méthode pour calculer le salaire de l'employé à partir de son échelon: le salaire de base est 100000, et chaque échelon l'augmente de 12000 (l'unité monétaire étant laissé à l'appréciation de chacun ;)
  • La méthode affiche affiche le salaire mensuel de l'employé.

Nous pouvons utiliser cette classe à partir du programme suivant:

public class Programme {
    public static void main(String[] args) {
        Employe         empl1 = new Employe("Hogg", "Boss", 1);
        Employe         empl2 = new Employe("Coltrane", "Rosco", 1);
 
 
        empl1.affiche();
        empl2.affiche();
    }
}

Nous pouvons ensuite compiler et tester ce programme:

sh$ javac Employe.java Programme.java
sh$ java Programme
Hogg Boss 1 (112000)
Coltrane Rosco 1 (112000)

Si vous avez bien compris les notions de classe et d'objet - et les notions connexes de méthode, attribut et état - vous devriez être en territoire connu!

Dans une bonne conception orientée objets une classe regroupe des données (les attributs) et les traitements (les méthodes) pour manipuler ces données. Ce principe est l'encapsulation.

L'encapsulation vise à regrouper les données et les traitements associés.

Ne confondez pas encapsulation et information-hiding

Le confusion est fréquente dans la littérature française, peut-être parce qu'il n'existe pas de traduction officielle pour information-hiding, mais ce dernier concept est différent de l'encapsulation [1]:

  • l'encapsulation vise à regrouper les données et leur traitements;
  • l'information-hiding vise à cacher les détails d'implémentation et à ne permettre l'accès à un objet qu'au travers d'une interface bien définie.

Ainsi, un langage orienté objets comme Java ou C++ met en œuvre l'encapsulation en permettant de créer des classes qui regroupent les attributs et les méthodes associées.

Dans ces mêmes langages, l'information-hiding est mis en œuvre (de façon totalement optionnelle - à la discrétion du programmeur) par l'utilisation des modificateurs private ou public (entre autres).

Dans la pratique une conception orientée objets moderne fait intervenir conjointement ces deux principes [2].

L'héritage

Continuons sur notre exemple. Dans notre société, tous les employés ne touchent pas chaque mois un salaire fixe: certains touchent en plus des primes, des bonus ou des commissions. C'est le cas, par exemple des commerciaux qui touchent une prime mensuelle en fonction des ventes qu'ils ont effectuées.

Motivation

En fait, un commercial se comporte en tout point comme un employé ordinaire sauf pour le calcul de son salaire. Si l'on voulait "coder" cela, la première idée qui viendrait à l'esprit serait d'écrire la classe Commercial suivante:

/* La classe des commerciaux */
public class Commercial
{
    /* attributs */
    private String nom;
    private String prenom;
    private int echelon;
    private int prime;   /* MODIF */
 
    /* constructeur */
    public Commercial(String nomInitial,
                     String prenomInitial,
                     int echelonInitial) {
        nom = nomInitial;
        prenom = prenomInitial;
        echelon = echelonInitial;
        prime = 0; /* MODIF */
    }
 
    /* méthodes */
    public void affiche() {
        System.out.println(nom + " " + prenom + " " + echelon +
                            " (" + calculeSalaire() + ")");
    }
 
    public void donnePromotion() {
        echelon = echelon+1;
    }
 
    public int calculeSalaire() {
        return 100000+echelon*12000+prime; /* MODIF */
    }
 
    /* MODIF */
    /* Méthode spécifique aux commerciaux */
    public void fixePrime(int laPrime) {
        prime = laPrime;
    }
}

C'est en fait un "copier/coller" de la classe Employe: à part le nom de la classe (Commercial à la la place de Employe) les modifications sont mineures (identifiées dans le source par un commentaire /* MODIF */):

Le programme suivant pourrait être utilisé pour tester:

public class Programme {
    public static void main(String[] args) {
        Commercial      empl1 = new Commercial("Hogg", "Boss", 1);
        Employe         empl2 = new Employe("Coltrane", "Rosco", 1);
 
 
        empl1.fixePrime(25000);
        empl1.affiche();
        empl2.affiche();
    }
}

Compilons/testons:

sh$ javac Commercial.java Programme.java
sh$ java Programme
Hogg Boss 1 (137000)
Coltrane Rosco 1 (112000)

Tout semble fonctionner: au même échelon Boss Hogg a bien gagné plus que Rosco, à cause de sa prime.

Cependant, si fonctionnellement cette solution semble convenir, elle a quand même un gros inconvénient: en effet, c'est généralement un gros défaut de conception que d'avoir du code dupliqué. Or ici, ce ne sont pas simplement quelques lignes qui sont dupliquées, mais quasiment l'intégralité de la classe Employe! Sans revenir en détail sur ce principe, il est communément admis que la duplication de code rend les changements difficiles, réduis la clarté du code et ouvre la porte à des incohérences dans le programme. Bref, il faut éviter!

Mise en œuvre

La notion d'héritage permet d'éviter cette redondance de code. L'idée est simple: elle consiste à permettre de créer une nouvelle classe à partir d'une classe existante, simplement en indiquant en quoi elle diffère.

Reprenons notre problème: nous voulons gérer les commerciaux:

En java, le premier point se code:

public class Commercial extends Employe {
}

Dans le jargon de la programmation orientée objets, on dit que la classe Commercial est une classe dérivée de la classe Employe. A l'inverse, on dit que la classe Employe est la classe de base de la classe Commercial.

Note:

Comme souvent en programmation orientée objets, le même concept peut avoir plusieurs noms. Ainsi, plutôt que de parler en termes de classe de base et classe dérivée, on peut aussi dire que:

  • Employe est la classe mère de Commercial et réciproquement Commercial est une classe fille de Employe
  • Employe est la super-classe de Commercial et réciproquement Commercial est une sous-classe de Employe.

Maintenant, que nous avons dit qu'un Commercial est une sorte d'Employe, il faut expliquer en quoi il diffère. Tout d'abord ajoutons ce qui est spécifique à un commercial: la gestion de sa prime:

public class Commercial extends Employe {
    private int prime; 
 
    /* constructeur */
    public Commercial(String nomInitial,
                     String prenomInitial,
                     int echelonInitial) {
        super(nomInitial, prenomInitial, echelonInitial);
        prime = 0;
    }
 
    /* Méthode spécifique aux commerciaux */
    public void fixePrime(int laPrime) {
        prime = laPrime;
    }
}

Vous remarquez dans le constructeur de Commercial, l'appel super(nomInitial, prenomInitial, echelonInitial). C'est simplement la syntaxe Java pour dire "construit tout d'abord un Employe". Ensuite, nous initialisons la prime (partie spécifique aux commerciaux) à 0. Toutes ces modifications sont des choses en plus, que les commerciaux ont, mais pas les employées ordinaires.

Maintenant, il reste le cas du calcul du salaire. Tous les employés, commerciaux ou non, ont une méthode pour calculer leur salaire. Seulement, pour les commerciaux, au calcul normal du salaire, il faut ajouter la prime. On pourrait être tenté d'écrire:

/* ... */
    public int calculeSalaire() {
        return 100000+echelon*12000+prime;
    }
/* ... */

Non seulement cela introduit à nouveau de la redondance en répétant le code qui sert à calculer le salaire d'un employé, mais surtout cela entre en conflit avec le principe d'information-hiding: l'échelon est une propriété définie dans la classe employée. Par conséquent seules les méthodes de la classe Employe devraient avoir le droit d'y accéder!

Note:

En Java, pour permettre au compilateur de détecter les violations de l'information-hiding il faut déclarer les attributs avec le mot-clé private. Tous les langages ne disposent pas de cette possibilité. C'est parfois au programmeur d'avoir la discipline nécessaire pour ne pas accéder directement aux attributs des classes de base!

Pour effectuer le calcul du salaire de notre commercial, il va falloir demander le calcul du salaire d'un employé, puis ajouter à cette somme la prime que touche le commercial. Tout cela s'écrit ainsi en Java (notez l'utilisation du mot-clé super - un peu comme dans le constructeur):

/* ... */
    public int calculeSalaire() {
        return super.calculeSalaire()+prime;
    }
/* ... */

Le super est-il obligatoire?

On pourrait être tenté d'écrire la méthode calculeSalaire ainsi (sans super):

/* ... */
    public int calculeSalaire() {
        return calculeSalaire()+prime;
    }
/* ... */

Mais cela ne marche pas: ici, la méthode calculeSalaire se ré-appelle elle-même. Autrement dit, la méthode calculeSalaire de Commercial

  1. appelle la méthode calculeSalaire de Commercial
  2. qui ré-appelle la méthode calculeSalaire de Commercial
  3. qui ré-appelle la méthode calculeSalaire de Commercial
  4. qui ...

... jusqu'à ce que la machine virtuelle Java détecte un trop grand nombre de récursions et se termine avec un Stack overflow.

Le mot-clé super est indispensable pour bien dire que la méthode calculeSalaire de Commercial doit appeler la méthode calculeSalaire de sa classe de base.

L'héritage est un moyen de créer de nouvelles classes à partir de classes déjà existantes afin de réutiliser leurs attributs et leurs comportements.


Le polymorphisme

L'utilisation de l'héritage dans le seul but de réutiliser du code est parfois appelé implementation inheritance [3]. Cependant, si l'héritage se contentait uniquement d'être un moyen d'éviter le redondance de code, ce mécanisme ne serait guère plus puissant que les mécanismes d'inclusion qui existent dans certains langages (instruction du pré-processeur C #include, ou équivalents).

Mais ce n'est pas le cas. En effet, un objet d'une classe dérivée peut être utilisé partout ou un objet d'une de ses classes de base est attendu: c'est le principe de substitution de Liskov. Concrètement, partout où un Employe est attendu, on peut utiliser un Commercial.

Remarque:

Une classe dérivée peut:

  • Avoir des attributs en plus de ceux définis dans sa classe de base;
  • Avoir des méthodes en plus de celles définies dans sa classe de base;
  • ou redéfinir des méthodes héritées de sa classe de base.

Par contre, elle ne peut pas enlever quoi que ce soit (attribut ou méthode) à sa classe de base. Si c'était le cas, il ne serait plus possible d'utiliser une instance de la classe dérivée partout où une instance de la classe de base est attendue. Ce serait donc une violation du principe de substitution.

Par conséquent, si en définissant une classe dérivée vous avez l'impression de vous retrouver avec des attributs ou des méthodes en trop, c'est vraisemblablement que votre hiérarchie de classe est à revoir!

Pour illustrer cela, nous allons introduire dans notre programme la notion de département. Un département possède un nom et 0, 1 ou plusieurs employés peuvent y travailler. Nous allons donc coder la classe Departement suivante:

import java.util.*;
/**
 * Un département
 */
public class Departement {
    String              nom; /* nom du département */
    List<Employe>       employes; /* liste  des employés */
 
    /* constructeur */
    public Departement(String leNom) {
        nom = leNom;
        employes = new LinkedList<Employe>();
    }
 
    /* méthodes */
    public void ajouteEmploye(Employe employe) {
        employes.add(employe);
    }
 
    public void affiche() {
        System.out.println("Département: " + nom);
        for(Employe empl : employes)
            empl.affiche();
    }
}

Comme vous le voyez, cette classe possède deux méthodes:

Le programme principal pourrait s'écrire:

public class Programme {
    public static void main(String[] args) {
        Commercial      boss = new Commercial("Hogg", "Boss", 1);
        boss.fixePrime(25000);
 
        Employe         empl = new Employe("Coltrane", "Rosco", 1);
 
        Departement     dep  = new Departement("Hazzard County");
        dep.ajouteEmploye(boss);
        dep.ajouteEmploye(empl);
        dep.affiche();
    }
}

Le programme crée toujours un commercial (boss) et un employé (empl). Le programme donne toujours une prime à Boss Hogg. Mais maintenant, les deux employés sont ajoutés à un département.

Or si vous observez attentivement la classe Departement vous pourrez voir que de son point de vue, elle ne manipule que des Employe. Et pourtant, si on lance le programme, on se rend compte que le salaire de Boss Hogg inclus bien sa prime comme calculée dans la méthode calculeSalaire de la classe Commercial!

En fait, grâce à l'héritage, un Commercial est un Employe. Toutes les méthodes d'un Employe peuvent être appelées sur un Commercial. Par contre, si une de ces méthodes est redéfinie dans la classe Commercial, alors c'est bien cette méthode qui sera appelée - pas celle de la classe de base. Un même appel de méthode peut entraîner un comportement différent selon l'objet sur lequel la méthode est appelée. Cette capacité est appelée le polymorphisme. Le mécanisme exact qui permet de déterminer la bonne méthode à exécuter varie d'un langage à l'autre. C++ et Java utilisent un mécanisme connu en français sous le nom de liaison dynamique (Dynamic dispatch).

Le polymorphisme permet d'utiliser les objets de différentes classes au travers d'une interface commune définie dans une classe de base, tout en s'assurant que chacun exhibe son comportement spécifique.


Type et polymorphisme

Dans un langage compilé et fortement typé comme Java ou C++ le compilateur se base uniquement sur le type déclaré (visible dans le source) de l'objet pour déterminer les méthodes qu'il est possible d'appeler. Par contre, à l'exécution, c'est le type réel de l'objet qui sert pour déterminer la méthode qui est exécutée. Cela oblige le programmeur à prendre certaines précautions lors du codage.

Ainsi, par exemple, on pourrait avoir voulu coder notre programme principal ainsi:

/* ... */
        Employe         boss = new Commercial("Hogg", "Boss", 1);
        boss.fixePrime(25000);
/* ... */

En vertu du principe de substitution, il est parfaitement légal de déclarer boss comme étant de la classe Employe (un Commercial est un Employe).

Par contre, le compilateur va refuser l'appel de méthode boss.fixePrime. Pourquoi? Le programmeur sait que boss est un commercial et que par conséquent on peut lui donner une prime. Par contre, le compilateur lui ne sait que ce qui a été déclaré. Ici, que boss est un Employe. Rien de plus. Par conséquent le compilateur n'autorise pas l'appel boss.fixePrime puisqu'il n'y a pas de méthode fixePrime déclarée dans la classe Employe.

Voir aussi