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 :
Le schéma se lit de bas en haut, c'est-à-dire que Guerrier
hérite de Personnage
:
Guerrier
est la classe fille.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 :
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 :
Ceux qui font de la magie blanche, ils envoient des sorts de guérison.
Et ceux qui font de la magie noire, ils jettent des mauvais sorts.
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 :
On crée un pointeur
Personnage
mis à 0.Et un pointeur
Guerrier
initialisé avec l'adresse d'un nouvel objet de typeGuerrier
.
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 :
Les attributs et méthodes héritées de la mère d'une part.
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 :
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 :
Vous demandez à créer un objet de type
Magicien
.Le compilateur appelle d'abord le constructeur de la classe mère (
Personnage
).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.
Lisons ce schéma dans l'ordre :
On demande à créer un
Magicien
."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 parentePersonnage
.Le compilateur va donc voir la classe parente et exécute son code.
Il revient ensuite au constructeur du
Magicien
et exécute son code.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 :
É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" objetB
. Par exemple, uneVoiture
"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
etprivate
, il existe une portéeprotected
, é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 !