Héritage


L'héritage est un mécanisme qui a été introduit dans la programmation objet dans le but d'éviter la réécriture inutile de code lorsqu'une classe n'est qu'un cas spécial d'une autre classe:

Heritage.jpg, 35kB

La classe plus spécialisée est appelée sous-classe (ou classe fille)et la classe plus générale, classe mère.

Lorsqu'une relation d'héritage est définie entre deux classes, la sous-classe hérite de tous les attributs et méthodes de la classe mère. C'est à dire que tout se passe comme si les attributs et méthodes de la classe mère avaient été explicitement définies pour la sous-classe.

Une sous-classe d'une classe donnée représente donc une sorte de cas particulier de cette classe qui possède des attributs et méthodes supplémentaires et spécifiques.

Notez que la relation d'héritage entre classes peut se faire à plusieurs niveaux: la classe mère d'une classe, peut également avoir une classe mère ... etc.

Prenons le cas d'un héritage à trois niveaux: la classe C hérite de la classe B, qui elle même hérite de la classe A:

Heritage-Multi-Niveau.jpg, 18kB

Dans ce cas, la classe C héritera de tous les attributs et méthodes de la classe B, mais également de ceux de la classe A. A et B sont des classes ascendantes de la classe C et inversement B et C sont des classes descendantes de la classe A.

De manière générale, une classe hérite donc des attributs et méthodes de toutes ses classes ascendantes.

La redéfinition de méthode

La redéfinition de méthode (ou overriding en anglais) est une notion liée à l'héritage: c'est le fait de pouvoir redéfinir une méthode déjà définie dans la classe mère.

Redefinitionjpg.jpg, 57kB

Nous verrons à travers un exemple comment cela se traduit en C++.

Heritage en C++

En C++, il existe plusieurs manière de déclarer qu'une classe B est une sous-classe d'une classe A. Dans ce cours, nous ne verrons que la suivante:

 class B  public : A {
     ....
 }

En la déclarant de cette manière, la classe B hérite de toutes les membres publiques de la classe A. Attention : les membres privés ne sont pas hérités.

Présentation de l'exemple

L'exemple de projet qui nous servira à introduire les notions d'héritage et de redéfinition de méthode se trouve dans le dossier Exemple-C-ProgObjet1/Formes.

Il contient la définition d'une classe Cercle comme sous-classe d'une autre classe nommée Forme.

Toute forme possède une aire. Un cercle est une forme spéciale qui possède également un rayon.

Pour éviter les confusions, une instance de la classe Forme sera appelée par la suite forme quelconque. Par contre, une forme sans autre précision désignera soit une forme quelconque soit un cercle.

Voici les différents choix proposés par la boucle de saisie des données:

Formes-Choix.jpg, 12kB
Le choix Nouvelle Forme

Le choix Nouvelle Forme permet de générer une forme de deux manières:

Chaque forme générée, que ce soit une forme quelconque ou un cercle, est stockée dans un même tableau TF.

Notez qu'il est impossible en principe de stocker dans un même tableau des données de types différents. Nous verrons un peu plus loin ce qui permet ce miracle.

A chaque ajout d'une nouvelle forme, l'ensemble des formes contenues dans le tableau TF est affiché. Chaque ligne affichée décrit une des formes contenues dans le tableau, en précisant son numéro (l'indice de l'élément du tableau dans lequel elle est rangée), sa classe , son aire et s'il s'agit d'un cercle, son rayon.

Voici un exemple d'exécution où nous avons créé un cercle de rayon 10, puis une forme quelconque d'aire 400:

Formes-Nouvelle-Forme.jpg, 52kB
Le choix Afficher tout

Le choix Afficher tout permet d'afficher toutes les formes en mémoires.

Formes-Afficher-Tout.jpg, 23kB
Le choix Selectionner

Ce choix permet de n'afficher que les formes dont l'aire est supérieure à une aire donnée et, uniquement s'il s'agit d'un cercle, dont le rayon est supérieur à un rayon donné.

Exemple:

Formes-Selectionner.jpg, 46kB

La classe Forme

Commencons par la classe mère, c'est à dire la classe Forme dans notre exemple. Voici sa déclaration (pour l'instant nous ommettons les instructions contenues dans les méthodes afin de ne pas rentrer tout de suite dans les détails):

class Forme {
   public :
     float Aire;
     
     Forme (float a) { ... }
     
     static void Ajouter (Forme * f) { ... }
 
     bool AireSuperieure (float a) { ... }
        
     virtual string Classe () { ... }
               
     virtual string Description () { ...}			              
};

Une forme quelconque ne possède donc que l'attribut Aire. Le constructeur initialise cet attribut avec l'aire a passé en paramètre.

La méthode statique Ajouter ajoute une forme f dans le tableau TF. D'après la règle de compatibilité entre paramètres formels et paramètres effectifs que nous avons vus dans le cours dédiés aux sous-programmes, cette méthode ne permet pas à priori d'ajouter un cercle dans TF. Pourtant si ! Nous verrons comment un peu plus loin.

La méthode AireSuperieure retourne true si la forme à laquelle elle est appliquée a une aire supérieure à a.

Nous reviendrons ultérieurement sur le rôle des deux méthodes restantes : Classe et Description et la signfication du mot clé virtual qui les précède.

La classe Cercle

Voici la déclaration de la classe Cercle en tant que sous-classe de la classe Forme (en omettant comme avant le code des méthodes) :

class Cercle : public  Forme {
  public: 
    float Rayon;
        
    Cercle (float r, float a) : Forme (a) { ... }
       
    static float CalculerAire (float r) { ... }
                                                 
    bool RayonSuperieur (float r){ ... }

    virtual string Classe () { ... }
       
    virtual string Description () { ... }

};

La syntaxe particulière du constructeur sera expliquée plus loin.

On constate que les deux méthodes Classe et Description sont redéfinies dans la classe Cercle.

Attributs de la classe Cercle

Elle a un attribut supplémentaire Rayon de type float et hérite de l'attribut Aire de la classe Forme. Par conséquent, bien qu'il ne soit pas défini explicitement dans la classe Cercle, l'attribut Aire est bien un attribut de cette classe. Autrement dit la classe Forme possède deux attributs: Rayon et Aire.

Constructeur de la classe Cercle

Le constructeur de la classe Cercle possède deux paramètres: r et a de type float qui servent respectivement à initialiser le rayon et l'aire d'un nouveau cercle.

Méthodes de la classe Cercle

La classe Cercle possède six méthodes. Parmis elles, deux sont héritées de la classe Forme: il s'agit de la méthode statique AjouterForme et de la méthode AireSuperieure.

Les quatre autres méthodes sont spécifiques:

Type effectif

Avant d'entrer dans le fonctionnement des différentes méthodes des classes Cercle et Forme et la manière dont elles exploitent l'héritage, nous allons tenter de répondre aux questions qui sont restées jusqu'ici sans réponse:

En principe ceci n'est pas possible en vertu de la règle de validité d'une affectation. Sauf exception pour les types numériques, on ne peut pas affecter à une variable une valeur de type différent de celle de la variable. Nous allons découvrir ici qu'il existe une autre exception à cette règle qui nous permettra de répondre aux questions précédentes.

Voyons ceci à travers un exemple.

Déclarons un pointeur f sur un objet de la classe Forme ainsi qu'un pointeur c sur un objet de la classe Cercle:

 Forme * f;
 Cercle * c;

Dans ce cas, le compilateur acceptera l'affectation suivante:

  f = c;

bien que f et c ne soient pas de même type !

après cette instruction, f contiendra donc un pointeur sur un objet de la classe Cercle, bien qu'il soit déclaré en tant que pointeur sur un objet de la classe Forme. Nous dirons dans ce cas que Cercle * est le type effectif de f, par opposition a son type déclaré (Forme * dans notre exemple).

De manière générale, si a est un pointeur sur un objet de la classe A et b un pointeur sur un objet de la classe B et si de plus B est une classe descendante de A, l'affectation a = b sera autorisée, mais pas l'affectation b = a.

Revenons à présent plus précisement aux deux questions qui nous préoccupaient.

Le tableau TF

Le tableau TF de notre exemple nous sert à stocker toutes les formes générées par le programme. Il est gèré comme une pile avec un indice de fin (NF). Voici sa déclaration:

const int M = 100;
int NF=-1; 
Forme * TF[M];

D'après cette déclaration, chaque élément de TF est un pointeur sur un objet de la classe Forme. Mais comme nous venons de le voir, C++ autorise de lui affecter une pointeur sur un objet d'une classe descendante de Forme, donc en particulier l'adresse d'un objet de la classe Cercle.

Cela explique comment TF peut contenir des formes quelconques et des cercles !

La méthode Ajouter

Voici le code de cette méthode:

static void Ajouter (Forme * f) {
    NF++;
    TF[NF] = f;
}

Le paramètre formel étant de type Forme *, il est à priori impossible d'utiliser cette méthode pour ajouter un Cercle dans TF. Mais, comme nous venons de le voir, le paramètre effectif peut très bien être un pointeur sur un cercle par son type effectif bien qu'il soit déclaré en tant que Forme *. Par exemple comme ceci:

  Forme * c;
  c = new Cercle (r,a);
  Forme.Ajouter (c);
 

dans ce cas les types déclarés du paramètre formel et du paramètre effectif sont les mêmes et nous avons réussi à passer un cercle en paramètre sans contredire la règle de compatibilité entre paramètre formel et effectif !

En fait, C++ autorise même que les types des paramètres formels et effectifs soient distincts. Le compilateur acceptera par exemple, les instructions suivantes:

  Cercle *  c;
  c = new Cercle (r,a);
  Forme.Ajouter (c);
 

car Cercle est une classe descendante de Forme.

Les constructeurs

Voici le constructeur de la classe Forme:

Forme (float a) { Aire = a; } 

et voici celui de la classe Cercle:

Cercle (float r, float a) : Forme (a) { Rayon = r; }

La syntaxe étrange du constructeur de la classe Cercle s'explique ainsi: en C++ l'instanciation d'un objet de la classe fille déclenche automatiquement celui de la classe mère. Dans notre exemple, l'instanciation d'un cercle provoque tout d'abord l'appel du constructeur de la classe Forme, puis celui de la classe Cercle. L'écriture : Forme (a) sert à préciser que lors de l'instanciation d'un cercle, le constructeur de la classe Forme sera appelé avec le paramètre a du constructeur de la classe Cercle.

De manière générale, le constructeur de la classe fille d'une classe se déclare de la manière suivante:

 nom de la classe fille ( listes des paramètres ) 
    : nom de la classe mère ( noms des paramètres à transmettre )

Les méthodes redéfinies

Rappelons qu'une méthode est redéfinie si elle est déclarée à la fois dans la classe mère et dans la classe fille. Dans notre exemple, nous en avons deux: il s'agit des méthodes Classe et Description.

Considérons une variable f déclarée en tant que pointeur sur un objet de la classe Forme et dont le type effectif est Cercle *:

 Forme *  f = new Cercle (r,a);

Si nous appliquons une méthode redéfinie à f, quelle va être la méthode appelée? celle de la classe fille (Cercle) ou celle de la classe mère (Forme) ?

La réponse est : celle de la classe mère, sauf s'il s'agit d'une méthode virtuelle.

Une méthode refédéfine est virtuelle si le mot clé virtual figure dans son entête. C'est donc le cas de nos deux méthodes Classe et Description.

Dans le cas d'une méthode redéfinie virtuelle, c'est le type effectif qui détermine la méthode appelée et non pas le type déclaré.

A présent, voyons comment cela se traduit concrètement avec les méthodes Classe et Description de notre projet.

La méthode Classe

La méthode Classe nous sert à déterminer le nom de la classe à laquelle appartient une forme dont la classe effective est à priori inconnue. Pour cela nous avons utilisé la redéfinition de méthode.

Dans la classe Forme, la méthode Classe est définie comme suit:

virtual string Classe () { return "Forme"; }

et dans la classe Cercle, comme ceci:

virtual string Classe () { return "Cercle"; } 
La méthode Description

La méthode Description retourne une chaine de caractères décrivant une forme de classe effective à priori inconnue. En redéfinissant cette méthode, nous lui donnons deux comportements possibles: pour une forme quelconque, elle donnera son aire et pour un cercle, son aire et son rayon. Ceci nous permettra en particulier d'afficher de manière élégante toutes les formes en mémoire (donc contenues dans le tableau TF) par une simple boucle for ne contenant aucun test:

for (i=0;i <= NF; i++){
   cout << i << ": " +TF[i]->Description() + "\n";

Voyons à présent comment sont codées les deux versions de la méthode Description.

Dans la classe Forme, elle est déclarée comme suit:

virtual string Description () {				
   return Classe() + " d'aire "+ ftostr(floor(Aire));
}

Dans la classe Cercle, Description est déclarée comme ceci:

virtual string Description () {
   return Forme::Description()+" et de rayon "+ ftostr(Rayon); }

La chaine de caractères construite utilise la méthode Description de la classe Forme. Pour préciser qu'il s'agit bien de celle de la classe Forme, nous avons préfixé le nom de la méthode par Forme:: (la fonction ftostr est une fonction de la librairie ETBib que j'ai écrite. Elle convertie un float en chaine de caractères).

De manière générale, si une méthode est redéfinie, pour appeler la méthode de la classe mère dans la classe fille on utilisera la notation

  Nom de la classe mère::nom de la méthode ( paramètres)

Voyons à présent comment ces méthodes s'exécutent selon le type effectif de l'objet auquel elles s'appliquent.

Considérons un élément TF[i] du tableau TF. Le type effectif de cet élément peut être Forme * ou Cercle *.

Dans le premier cas,TF[i]->Description () exécutera la méthode Description de la classe Forme. La méthode Classe retournera dans ce cas la chaine "Forme". En supposant que l'aire de cette forme soit égale à 100, la chaine retournée sera "Forme d'aire 100".

Dans le deuxième cas,TF[i]->Description () exécutera la méthode Description de la classe Cercle. La méthode Classe() retournera dans ce cas la chaine "Cercle". Supposons que ce soit un cercle de rayon 2 et d'aire 12. Forme::Description() retournera la chaine "Cercle d'aire 12". Le résultat retourné sera donc "Cercle d'aire 12 et de rayon 2".

Le cast

Le cast est un moyen de forcer une conversion de type dans une expression, en faisant figurer le nouveau type entre parenthèses juste avant l'expression à tranformer:

  ( Type )  Expression

Cette opération peut s'appliquer sur n'importe quel type (elle n'est donc pas spécifique à la programmation objet) et en particulier à des pointeurs sur des objets. Exemple:

 (Cercle *)  TF[i]

force le compilateur à interpréter TF[i] (déclaré en tant que Forme *), comme un pointeur sur un objet de la classe Cercle.

Dans notre exemple, cela nous permet d'appliquer une méthode non reféfinie de la classe Cercle à un élément de TF. Il s'agit de la méthode RayonSuperieur:

bool RayonSuperieur (float r){
   return Rayon >= r;
}

Appliquée à un cercle, cette méthode retournera la valeur true, si son rayon est supérieur à r.

Nous utilisons cette méthode dans la procédure associée au choix Sélectionner:

void Choix_Selectionner () {
....
for (i=0;i <= NF; i++){
   if (TF[i]->AireSuperieure(Aire_Min))
     if (TF[i]->Classe()=="Forme")
        cout << i << ": "+TF[i]->Description();
     else
       if (  ((Cercle *)TF[i])->RayonSuperieur(Rayon_Min)  )
          cout <<  i << ": "+TF[i]->Description();
	}		
}

Sans cast, nous n'aurions aucun moyen d'appliquer la méthode RayonSuperieur à un élément de TF, même lorsque le type effectif de celui ci est Cercle *.