• 10 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 10/02/2022

Découvrez la notion d'héritage

Analysez la relation d'héritage entre deux classes

Reprenons notre classe Personnage  ; nous allons la simplifier :

#ifndef DEF_PERSONNAGE
#define DEF_PERSONNAGE
 
#include <iostream>
#include <string>
 
class Personnage
{
    public:
        Personnage();
        void recevoirDegats(int degats);
        void coupDePoing(Personnage &cible) const;
 
    private:
        int m_vie;
        std::string m_nom;
};
 
#endif

La classe Personnage a un nom et une quantité de vie. On n'a mis qu'un seul constructeur, celui par défaut. Il permet d'initialiser le Personnage avec un nom et de lui donner 100 points de vie. Le Personnage peut recevoir des dégâts via la méthode recevoirDegats  , et en distribuer via la méthode coupDePoing()  .

À titre informatif, voici l'implémentation des méthodes dans Personnage.cpp  :

#include "Personnage.hpp"
 
using namespace std;
 
Personnage::Personnage() : m_vie(100), m_nom("Jack")
{
 
}
 
void Personnage::recevoirDegats(int degats)
{
    m_vie -= degats;
}
 
void Personnage::coupDePoing(Personnage &cible) const
{
    cible.recevoirDegats(10);
}

La classe Guerrier hérite de la classe Personnage

Créons une nouvelle classe qui soit une sous-classe de Personnage (on dit que cette classe hérite de Personnage) et une classe Guerrier qui hérite de Personnage .

La définition de la classe, dans Guerrier.hpp  , ressemble à ceci :

#ifndef DEF_GUERRIER
#define DEF_GUERRIER
 
#include <iostream>
#include <string>
#include "Personnage.hpp"
//Ne pas oublier d'inclure Personnage.hpp pour pouvoir en hériter !
 
class Guerrier : public Personnage
//Signifie : créer une classe Guerrier qui hérite de la classe Personnage
{
 
};
 
#endif

Grâce à ce qu'on vient de faire, la classe Guerrier contiendra de base tous les attributs et méthodes de la classe Personnage  . Dans un tel cas, la classe Personnage est appelée la classe "Mère", et la classe Guerrier la classe "Fille".

Mais quel intérêt de créer une nouvelle classe si c'est pour qu'elle contienne les mêmes attributs et méthodes ?

Le truc, c'est qu'on peut rajouter des attributs et des méthodes spéciales dans la classe Guerrier  . Par exemple, on pourrait rajouter une méthode qui ne concerne que les guerriers, du genre frapperAvecUnMarteau :

#ifndef DEF_GUERRIER
#define DEF_GUERRIER
 
#include <iostream>
#include <string>
#include "Personnage.hpp"
 
class Guerrier : public Personnage
{
    public:
        void frapperAvecUnMarteau() const;
        //Méthode qui ne concerne que les guerriers
};
 
#endif

Schématiquement, on peut représenter la situation comme ça :

La classe Guerrier possèdera 3 méthodes : - 2 de la classe dont elle hérite : recevoirDegats et coupDePoing - 1 qui lui est propre : frapperCommeUnSourdAvecUnMarteau
Un héritage entre classes

Le schéma se lit de bas en haut, c'est-à-dire que Guerrier hérite de Personnage  :

  1. Guerrier est la classe fille.

  2. Personnage est la classe mère.

On dit que Guerrier est une "spécialisation" de la classe Personnage  . Elle possède toutes les caractéristiques d'un Personnage  (de la vie, un nom, elle peut recevoir des dégâts) mais elle possède en plus des caractéristiques propres au Guerrier , comme frapperAvecUnMarteau() .

En C++, quand on a deux classes qui sont liées par la relation "est un", on utilise l'héritage pour mettre en évidence ce lien. Un Guerrier "est un" Personnage amélioré qui possède une méthode supplémentaire.

Revoyons toutes les étapes pour créer une classe qui hérite d’une classe mère ; il y a deux ou trois points à ne pas oublier :

La classe Magicien hérite aussi de Personnage

Tant qu'il n'y a qu'un seul héritage, l'intérêt semble encore limité. Mais multiplions un peu les héritages et les spécialisations, et nous allons vite voir tout l'intérêt de la chose.

Par exemple, si on créait une classe Magicien qui hérite elle aussi de Personnage  ? Après tout, un Magicien est un Personnage  , donc il peut récupérer les mêmes propriétés de base : de la vie, un nom, donner un coup de poing, etc. La différence, c'est que le Magicien peut aussi envoyer des sorts magiques, par exemple bouleDeFeu et bouleDeGlace  . Pour utiliser sa magie, il a une réserve de magie qu'on appelle "Mana" (cela fait un attribut à rajouter). Quand Mana tombe à zéro, il ne peut plus lancer de sort.

#ifndef DEF_MAGICIEN
#define DEF_MAGICIEN
 
#include <iostream>
#include <string>
#include "Personnage.hpp"
 
class Magicien : public Personnage
{
    public:
        void bouleDeFeu() const;
        void bouleDeGlace() const;
 
    private:
        int m_mana;
};
 
#endif

Je ne vous donne pas l'implémentation des méthodes (le .cpp  ) ici ; retenez juste le principe :

Deux classes héritent d'une même classe

Le plus beau, c'est qu'on peut faire une classe qui hérite d'une classe qui hérite d'une autre classe !

Imaginons qu'il y ait deux types de magiciens :

  1. Ceux qui font de la magie blanche, ils envoient des sorts de guérison.

  2. Et ceux qui font de la magie noire, ils jettent des mauvais sorts.

Multiples héritages

Et cela pourrait continuer longtemps comme cela.

La dérivation de type

Imaginons le code suivant :

Personnage monPersonnage;
Guerrier monGuerrier;
 
monPersonnage.coupDePoing(monGuerrier);
monGuerrier.coupDePoing(monPersonnage);

Si vous compilez, cela fonctionne. Mais si vous êtes attentif, vous devriez vous demander pourquoi cela a fonctionné, parce que normalement cela n'aurait pas dû !

Voici le prototype de coupDePoing  (il est le même dans la classe Personnage et dans la classe Guerrier  , rappelez-vous) :

void coupDePoing(Personnage &cible) const;

Quand on fait monGuerrier.coupDePoing(monPersonnage);  , on envoie bien en paramètre un Personnage  . Mais quand on fait monPersonnage.coupDePoing(monGuerrier);  , cela marche aussi et le compilateur ne hurle pas à la mort alors que, selon toute logique, il le devrait ! En effet, la méthode coupDePoing attend un Personnage , et on lui envoie un Guerrier.

Comment ça se fait que ça fonctionne ?

Ce qui veut dire qu'on peut faire cela :

Personnage *monPersonnage(0);
Guerrier *monGuerrier = new Guerrier();
 
monPersonnage = monGuerrier; // Mais… mais… Ça marche !?

Les deux premières lignes n'ont rien d'extraordinaire :

  1. On crée un pointeur Personnage mis à 0.

  2. Et un pointeur Guerrier initialisé avec l'adresse d'un nouvel objet de type Guerrier  .

Mais, la dernière ligne est assez surprenante : on ne devrait pas pouvoir donner à un pointeur de type Personnage un pointeur de type Guerrier  . Alors oui, en temps normal le compilateur n'accepte pas d'échanger des pointeurs (ou des références) de types différents. Mais Personnage et Guerrier ne sont pas n'importe quels types : Guerrier hérite de Personnage  .

Cela nous permet donc de placer un élément dans un pointeur (ou une référence) de type plus général. C'est très pratique dans notre cas lorsqu'on passe une cible en paramètre :

void coupDePoing(Personnage &cible) const;

Notre méthode coupDePoing est capable de faire mal à n'importe quel Personnage  ! Qu'il soit Guerrier  , Magicien  , MagicienBlanc  , MagicienNoir ou autre.

Cela fonctionne, puisque la méthode coupDePoing se contente d'appeler des méthodes de la classe Personnage  ( recevoirDegats  ), et que ces méthodes se trouvent forcément dans toutes les classes filles ( Guerrier  , Magicien  ).

Je ne vois pas pourquoi cela marche si on fait objetMere = objetFille;  . Là on affecte la fille à la mère ; or, la fille possède des attributs que la mère n'a pas. Cela devrait coincer ! L'inverse ne serait-il pas plus logique ?

J'ai mis des mois avant d'arriver à comprendre ce qui se passait vraiment…

La classe fille est constituée de deux morceaux :

  1. Les attributs et méthodes héritées de la mère d'une part.

  2. Et les attributs et méthodes qui lui sont propres d'autre part.

En faisant objetMere = objetFille;  , on dirige le pointeur objetMere vers les attributs et méthodes hérités uniquement :

La dérivation de type

Comprenez la relation entre l'héritage et les constructeurs

C'est le moment de s'intéresser aux constructeurs dans les classes filles ( Guerrier  , Magicien  … ). On sait que Personnage a un constructeur (par défaut) défini comme ceci dans le .hpp  :

Personnage();

… et son implémentation dans le .cpp  :

Personnage::Personnage() : m_vie(100), m_nom("Jack")
{
 
}

Lorsqu'on crée un objet de type Personnage  , le constructeur est appelé avant toute chose.

Mais que se passe-t-il lorsqu'on crée par exemple un Magicien qui hérite de Personnage  ? Le Magicien a le droit d'avoir un constructeur lui aussi ! Est-ce que cela ne risque pas d'interférer avec le constructeur de Personnage  ? Il faut pourtant appeler le constructeur de Personnage si on veut que la vie et le nom soient initialisés !

En fait, les choses se déroulent dans l'ordre suivant :

  1. Vous demandez à créer un objet de typeMagicien  .

  2. Le compilateur appelle d'abord le constructeur de la classe mère ( Personnage  ).

  3. Puis, il appelle le constructeur de la classe fille ( Magicien  ).

C'est d'abord le constructeur du parent qui est appelé, puis celui du fils, et éventuellement celui du petit-fils (s'il y a un héritage d'héritage, comme c'est le cas avec MagicienBlanc  ).

Appelez le constructeur de la classe mère

Pour appeler le constructeur de Personnage en premier, il faut y faire appel depuis le constructeur de Magicien  . C'est dans un cas comme cela qu'il est indispensable de se servir de la liste d'initialisation (vous savez, tout ce qui suit le symbole deux-points dans l'implémentation).

Magicien::Magicien() : Personnage(), m_mana(100)
{
 
}

Le premier élément de la liste d'initialisation indique de faire appel en premier lieu au constructeur de la classe parente Personnage  . Puis on réalise les initialisations propres au Magicien  (comme l'initialisation du mana à 100).

"Remontez" des paramètres d'un constructeur à un autre

L'avantage de cette technique est que l'on peut transmettre les paramètres du constructeur de Magicien au constructeur de Personnage  . Par exemple, si le constructeur de Personnage prend un nom en paramètre, il faut que le Magicien accepte lui aussi ce paramètre, et le fasse passer au constructeur de Personnage  :

Magicien::Magicien(string nom) : Personnage(nom), m_mana(100)
{
 
}

Bon, si on veut que cela marche, il faut aussi surcharger le constructeur de Personnage pour qu'il accepte un paramètre string  !

Personnage::Personnage(string nom) : m_vie(100), m_nom(nom)
{
 
}

Et voilà comment on fait remonter des paramètres d'un constructeur à un autre pour s'assurer que l'objet se crée correctement.

Ordre d'appel des constructeurs

Lisons ce schéma dans l'ordre :

  1. On demande à créer un Magicien  .

  2. "Oh, mais c'est un objet" se dit le compilateur, "il faut que j'appelle son constructeur". Or, le constructeur du Magicien indique qu'il faut d'abord appeler le constructeur de la classe parente Personnage  .

  3. Le compilateur va donc voir la classe parente et exécute son code.

  4. Il revient ensuite au constructeur du Magicien et exécute son code.

  5. De ce fait, notre objet merlin devient utilisable, et on peut enfin faire subir les pires sévices à notre cible.

La vidéo suivante résume tous points qui permettent d’implémenter le constructeur de la classe fille, et notamment de transmettre les paramètres pour appeler le constructeur de la classe mère :

Utilisez la portée protected

Il me serait impossible de vous parler d'héritage sans vous parler de la portée protected  .

Les portées (ou droits d'accès) que vous connaissez déjà sont :

  • public  : les éléments qui suivent sont accessibles depuis l'extérieur de la classe ;

  • private  : les éléments qui suivent ne sont pas accessibles depuis l'extérieur de la classe.

Je vous ai en particulier donné la règle fondamentale du C++, l'encapsulation, qui veut que l'on empêche systématiquement au monde extérieur d'accéder aux attributs de nos classes.

Cela veut dire, par exemple, que si l'on met des éléments en protected dans la classePersonnage  , on y aura accès dans les classes filles Guerrier et Magicien  . Avec la portée private  , on n'aurait pas pu y accéder !

class Personnage
{
   public:
       Personnage();
       Personnage(std::string nom);
       void recevoirDegats(int degats);
       void coupDePoing(Personnage &cible) const;

   protected: //Privé, mais accessible aux éléments enfants (Guerrier, Magicien)
      int m_vie;
      std::string m_nom;
};

On peut alors directement manipuler la vie et le nom dans tous les éléments enfants de Personnage  , comme Guerrier et Magicien  !

Utilisez le masquage

Terminons ce chapitre avec une notion qui nous servira dans la suite : le masquage.

Une fonction de la classe mère

Il serait intéressant pour notre petit jeu de rôle que nos personnages puissent se présenter. Quel que soit le rôle des personnages, la fonction sePresenter() va dans la classe Personnage  .

class Personnage
{
    public:
        Personnage();
        Personnage(std::string nom);
        void recevoirDegats(int degats);
        void coupDePoing(Personnage& cible) const;
        
        void sePresenter() const;
 
    protected:
        int m_vie;
        std::string m_nom;
};

Et dans le fichier .cpp  :

void Personnage::sePresenter() const
{
    cout << "Bonjour, je m'appelle " << m_nom << "." << endl;
    cout << "J'ai encore " << m_vie << " points de vie." << endl;
}

On peut donc écrire un main()  comme celui-ci :

int main()
{
   Personnage marcel("Marcel");
   marcel.sePresenter();

   return 0;
}

Ce qui nous donne le résultat suivant :

Bonjour, je m'appelle Marcel.
J'ai encore 100 points de vie.

La fonction est héritée dans les classes filles

Un Guerrier est un Personnage  ; par conséquent, il peut également se présenter.

int main(){

   Guerrier lancelot("Lancelot du Lac");
   lancelot.sePresenter();

   return 0;
}

Avec pour résultat :

Bonjour, je m'appelle Lancelot du Lac.
J'ai encore 100 points de vie.

La fonction écrite dans une version différente

Imaginons maintenant que les guerriers doivent en plus préciser qu'ils sont guerriers. Nous allons donc écrire une version différente de la fonction sePresenter()  , spécialement pour eux :

void Guerrier::sePresenter() const
{
    cout << "Bonjour, je m'appelle " << m_nom << "." << endl;
    cout << "J'ai encore " << m_vie << " points de vie." << endl;
    cout << "Je suis un Guerrier redoutable." << endl;
}

Mais il y aura deux fonctions avec le même nom et les mêmes arguments dans la classe ? C'est interdit !

Vous avez tort et raison. Deux fonctions ne peuvent avoir la même signature (nom et type des arguments). Mais, dans le cadre des classes, c'est différent. La fonction de la classe Guerrier remplace celle héritée de la classe Personnage  .

Si l'on exécute le même main() qu'avant, on obtient cette fois le résultat souhaité :

Bonjour, je m'appelle Lancelot du Lac.
J'ai encore 100 points de vie.
Je suis un Guerrier redoutable.

La fonction héritée de Personnage est masquée, cachée.

C'est pratique : quand on fait un héritage, la classe fille reçoit automatiquement toutes les méthodes de la classe mère. Si une de ces méthodes ne nous plaît pas, on la réécrit dans la classe fille, et le compilateur saura quelle version appeler.

Si c'est un Guerrier  , il utilise la version Guerrier de sePresenter()  ; et si c'est un Personnage ou un Magicien  , il utilise la version de base :

Principe du masquage

Économisez du code

La fonction sePresenter() de la classe Guerrier a deux lignes identiques à ce qu'il y a dans la même fonction de la classe Personnage  . On pourrait donc économiser des lignes de code en appelant la fonction masquée.

On aimerait donc écrire quelque chose du genre :

void Guerrier::sePresenter() const
{
    appel_a_la_fonction_masquee();
    //Cela afficherait les informations de base
    
    cout << "Je suis un Guerrier redoutable." << endl;
    //Et ensuite les informations spécifiques
}

Il faudrait donc un moyen d'appeler la fonction de la classe mère.

Le démasquage

On aimerait appeler la fonction Personnage::sePresenter() . Essayons donc :

void Guerrier::sePresenter() const
{
   Personnage::sePresenter();
    cout << "Je suis un Guerrier redoutable." << endl;
}

Cela donne exactement ce que l'on espérait.

Bonjour, je m'appelle Lancelot du Lac.
J'ai encore 100 points de vie.
Je suis un Guerrier redoutable.

On parle dans ce cas de démasquage : on a pu utiliser une fonction qui était masquée.

On a utilisé l'opérateur :: (opérateur de résolution de portée). Il sert à déterminer quelle fonction (ou variable) utiliser, quand il y a ambiguïté ou s'il y a plusieurs possibilités.

En résumé

  • L'héritage permet de spécialiser une classe.

  • Lorsqu'une classe hérite d'une autre classe, elle récupère toutes ses propriétés et ses méthodes.

  • Faire un héritage a du sens si on peut dire que l'objet A "est un" objet B  . Par exemple, une Voiture "est un" Vehicule  .

  • La classe de base est appelée classe mère, et la classe qui en hérite est appelée classe fille.

  • Les constructeurs sont appelés dans un ordre bien précis : classe mère, puis classe fille.

  • En plus de public et private  , il existe une portée protected , équivalente à private mais plus ouverte : les classes filles peuvent elles aussi accéder aux éléments.

  • Si une méthode a le même nom dans la classe fille et la classe mère, c'est la méthode la plus spécialisée, celle de la classe fille, qui est appelée.

Maintenant que nous en savons un peu plus sur l’héritage, nous allons voir la notion de polymorphisme. Un concept super intéressant qui vous permettra de faire de grandes choses !

Exemple de certificat de réussite
Exemple de certificat de réussite