Héritage & polymorphisme ✱

Héritage

L’héritage en C++ est un concept fondamental de la programmation orientée objet (POO). Cette approche permet de créer de nouvelles classes appelées classes dérivées ou classes filles héritant des caractéristiques d’une classe pré-existante appelée classe mère ou classe de base. Cela permet une meilleure réutilisation du code et une structuration plus claire des classes appartenant à une même hiérarchie.

Dans cet exemple, la classe fille hérite des fonctions de sa classe parent :

#include <iostream>
using namespace std;

struct Base
{
        void fnt1() {  cout << "fnt1" << endl;    }
};

struct Derivee : Base
{
        void fnt2() {  cout << "fnt2" << endl; }
};

int main()
{
        Derivee obj;
        obj.fnt1();
        obj.fnt2();
}

>> fnt1
>> fnt2

La classe Derivee dispose de sa propre fonction fnt2() et elle hérite aussi de la fonction fnt1 de sa classe mère.

Visibilité et encapsulation

Les structs nous ont permis de travailler sans avoir à gérer les problèmes de visibilité car tous les membres d’instance étaient publics. Le principe d’encapsulation nous conseille de cacher le fonctionnement interne d’un objet depuis l’extérieur. En effet, certaines variables peuvent être plus sensibles que d’autres et si on venait à les modifier par mégarde, l’objet pourrait se mettre à dysfonctionner complètement. Dans ce cas, il est conseillé :

  • de rendre privées les données/fonctions internes (protected)

  • de rendre publiques les membres qui servent d’interface vers l’extérieur (public)

#include <iostream>
using namespace std;

class Personnage
{
        protected :
                int _stamina = 100;  // Endurance, forme...

        public :
                void bash() // attaque puissante
                {
                        if (_stamina < 25 ) return;
                        _stamina -= 25;
                        ...
                }
                void jump()
                {
                        if (_stamina < 10 ) return;
                        _stamina -= 10;
                        ...
                }
                int getStamina() { return _stamina; }
                void setStamina(int v)
                {
                   if ( v<0 ) return;
                   _stamina = v;
                }
};

L’endurance (stamina) du personnage est un paramètre important. En effet, si son endurance est insuffisante, il ne peut plus attaquer ou sauter et il doit se reposer. L’attribut _stamina est un paramètre interne et doit être masqué de l’extérieur, il est donc privé. En effet, pour le bon fonctionnement du programme, cet attribut doit rester positif, sinon cela pourrait provoquer diverses bugs notamment lors de l’affichage de la barre de stamina à l’écran. On accède au paramètre de stamina à travers une paire de getter, setter. On remarque que setStamina en plus de mettre à jour la valeur de stamina vérifie aussi que cette valeur est positive. Avec cette approche, on est sûr que toute manipulation de l’objet ne vient pas donner une valeur négative à l’attribut stamina ce qui viendrait perturber le fonctionnement interne de l’objet.

La gestion de la stamina est une mécanique fine à mettre en place, elle va énormément influer sur le gameplay et la qualité finale du jeu. Cela sous-entend qu’elle va générer plusieurs centaines de lignes de code. Ainsi, la gestion de la stamina doit être entièrement codée dans la classe Personnage (principe d’encapsulation). Ne pas regrouper les lignes de code associées à cette thématique est une erreur de conception : en effet, en l’absence d’organisation, on va voir apparaître un peu partout dans le programme des bouts de code gérant la stamina et il sera très dur par la suite de savoir qui fait quoi, où, quand et pourquoi :)

Note

Il existe un mot clef supplémentaire : private qui permet de rendre privé un membre relativement aux classes dérivées cette fois. Le mot clef protected laisse visible les membres depuis les classes enfants.

Chaînage des constructeurs

Principe

Lorsque qu’une classe fille est instanciée, son constructeur est appelé. Cependant, une partie de ce nouvel objet provient de la classe mère et pour fonctionner correctement, la partie de l’objet correspondant au parent doit être initialisé par le constructeur du parent spécifiquement. Il n’existe pas de mécanisme gérant automatiquement cela et vous devez chaîner explicitement l’appel vers le constructeur parent depuis le constructeur de l’enfant. Voici la syntaxe associée :

#include <iostream>
using namespace std;

class Animal
{
        protected : string _nom;

        public :

        Animal(string n) : { _nom = n; }
};

class Chien : public Animal
{
        protected : string _race;

        public :

        Chien(string nom, string race) : Animal(nom) { _race = race }    // syntaxe de chaînage

        void Infos()
        {
                cout << "Nom :" << _nom << "Race : " << _race << endl;
        }
};

int main()
{
        Chien C("Rex", "Berger allemand");
        C.Infos();
}

Il existe une autre syntaxe que vous pourrez rencontrer fréquemment :

Animal(string n) : _nom(n) { }

et

Chien(string nom, string race) : Animal(nom), _race(race) { }   // attention à l ordre

Les erreurs

Il est possible que pour contourner le chaînage, vous écriviez finalement le code suivant :

Chien(string nom, string race) : { _nom = nom; _race = race; }

Dans cette version, il n’y a plus d’appel au constructeur du parent et les données du parent sont directement initialisées au niveau du constructeur de l’enfant. OK, cela fonctionne dans cet exemple assez court, mais ce n’est pas une bonne approche et il faut l’éviter. En effet, si dans le constructeur de la classe parent des traitements supplémentaires avaient été effectués, comme l’initialisation d’un container de données, alors en bypassant le constructeur du parent, l’objet Chien ne pourrait pas fonctionner correctement. D’ailleurs, le concepteur aurait du rendre private le paramètre _nom de la classe Animal pour éviter tout problème.

Polymorphisme

Présentation

Dans une hiérarchie d’héritage, il est possible de mettre en place un polymorphisme d’héritage. Ce mécanisme permet à une fonction portant le même dans diverses classes de la hiérarchie d’avoir un comportement différent suivant la classe considérée. Pour cela on utilise les mots-clefs suivants :

La fonction en haut de la hiérarchie doit être qualifiée de virtual.

Les fonctions surchargées (redéfinies) dans les classes filles doivent utiliser le mot clef override.

Par exemple, dans une hiérarchie d’objets géométriques (Triangle Cercle Rectangle), il serait maladroit de créer des fonctions AfficheTriangle, AfficheCercle et AfficheRectangle pour demander l’affichage de ces objets. Il est plus simple et plus lisible d’avoir une seule méthode Affiche pour toutes ces classes et que cette méthode varie suivant la nature de l’objet :

#include <iostream>
#include <vector>

using namespace std;

struct ObjGraphique
{
        virtual void affiche() { cout << "ObjGraphique" << endl; }
};

struct Triangle : public ObjGraphique
{
        void affiche() override { cout << "Triangle" << endl; }
};

struct Cercle : public ObjGraphique
{
        void affiche() override { cout << "Cercle" << endl; }
};

Le début des problèmes

Testons le code suivant pour voir si le polymorphisme fonctionne :

...

int main()
{
        ObjGraphique O;    Triangle T;        Cercle C;

        O.affiche();       T.affiche();       C.affiche();

        cout << "----------\n";

        ObjGraphique A[2];

        A[0] = O;          A[1] = T;          A[2] = C;

        A[0].affiche();    A[1].affiche();    A[2].affiche();

        cout << "----------\n";

        vector<ObjGraphique> L;

        L.push_back(O);    L.push_back(T);    L.push_back(C);

        L[0].affiche();    L[1].affiche();    L[2].affiche();
}

>> ObjGraphique
>> Triangle
>> Cercle
>> ----------
>> ObjGraphique
>> ObjGraphique
>> ObjGraphique
>> ----------
>> ObjGraphique
>> ObjGraphique
>> ObjGraphique

Étrangement tout compile correctement, le programme se lance et pourtant rien ne fonctionne correctement ! Quel que soit l’objet instancié, tout se passe comme s’ils étaient du type de la classe mère : ObjGraphique. Pour comprendre cette situation, il faut se rappeler comment est construit le langage C++. En effet, que ce soit pour un tableau ou un vector, les éléments sont stockés de manière contiguë en mémoire et ils doivent tous faire la même taille et donc tous être du même type. Ainsi, lorsque vous écrivez ObjGraphique A[3]; ou vector<ObjGraphique>, tous les objets insérés dans ces containers vont être convertis en ObjGraphique. Ainsi, lorsque l’on écrit :

  • A[1] = T;

  • L[1] = T;

A gauche, se trouve une lvalue vers un ObjGraphique et à droite un objet Triangle. Comme la classe Triangle appartient à la hiérarchie des ObjGraphique, le compilateur autorise sa troncature/conversion vers le type ObjGraphique, un peu comme lorsque l’on écrit :

  • int a = 2.14;

Ainsi, l’objet Triangle et l’objet Cercle sont copiés et convertis pour être stockés sous forme d'ObjGraphiques.

Faire fonctionner le polymorphisme

Résumé des contraintes

Supposons que l’on dispose d’une hiérarchie d’objets dont la classe mère est T. Si l’on veut stocker plusieurs objets d’une même hiérarchie, on ne peut utiliser un vector<T> comme on vient de le voir. En effet, le langage C++ ne garantit le fonctionnement du polymorphisme que pour les pointeurs. Il suffit donc de créer un vector de pointeurs sur des objets T.

Mais, apparaît alors le problème de la destruction des objets. En effet, lorsque l’objet vector, lorsqu’il est détruit, ne détruit pas les objets liés à ses pointeurs ! D’où un risque de fuite mémoire ou de bugs à gérer. La solution consiste donc à combiner toutes les nouveautés vues jusqu’ici :

  • Utiliser des objets vector comme container.

  • Utiliser des shared_ptr pour s’assurer que les objets soient libérés une fois qu’ils ne sont plus utilisés.

  • Mettre en place le polymorphisme d’héritage pour permettre à chaque objet de la liste d’agir suivant sa nature.

Mise en place

Si vous avez suivi ce cours point par point, voici la mise en place complète de notre exemple :

#include <iostream>
#include <vector>
#include <memory>

using namespace std;

struct ObjGraphique
{
        virtual void affiche()   { cout << "ObjGraphique" << endl; }
        virtual ~ObjGraphique()  { cout << "Dest ObjGraphique" << endl; }
};

struct Triangle : public ObjGraphique
{
        void affiche() override { cout << "Triangle" << endl; }
        ~Triangle()    override { cout << "Dest Triangle" << endl; }
};

struct Cercle : public ObjGraphique
{
        void affiche() override { cout << "Cercle" << endl; }
        ~Cercle()      override { cout << "Dest Cercle" << endl; }
};


int main()
{
        vector<shared_ptr<ObjGraphique>> L;

        L.push_back(make_shared<Triangle>());
        L.push_back(make_shared<Cercle>());
        L.push_back(make_shared<ObjGraphique>());

        for(auto o : L)
                o->affiche();
}

>> ObjGraphique
>> Triangle
>> Cercle
>> Dest Triangle
>> Dest ObjGraphique
>> Dest Cercle
>> Dest ObjGraphique
>> Dest ObjGraphique

Dans cet exemple, un vector de shared_ptr L a été créé. Différents objets sont créés ainsi que leur shared pointers en utilisant la syntaxe make_shared<>, les pointeurs sont stockés dans l’objet vector présent. Ensuite, les objets de la liste sont parcourus l’un après l’autre et, pour chacun, on appelle successivement la méthode polymorphe : affiche() qui donne un affichage correct pour chaque objet. Une fois le programme terminé, les compteurs des shared_ptr tombent à zéro et les objets sont automatiquement détruits.

Avertissement

En C++, les destructeurs doivent être déclarés comme virtuels dans une hiérarchie.

Avertissement

Les destructeurs sont automatiquement chaînés, vous n’avez pas à gérer ce point. D’ailleurs vous pouvez remarquer que le chaînage automatique des destructeurs est visible dans l’affichage : « Destruction Triangle » suivi de « Destruction ObjGraphique » en fin de programme.

Quizzz

  • En C++, le chaînage des constructeurs est automatique.

  • Pour masquer un membre de ses enfants, on utilise le mot clef ?

  • Le polymorphisme consiste pour une méthode d’instance à pouvoir changer de définition durant l’exécution du programme.

  • Quel mot clef faut-il insérer pour un destructeur s’il s’insère dans une hiérarchie d’héritage ?

  • Le C++ gère automatiquement le système de chaînage.

  • En C++, la conversion d’un objet vers le type de sa classe mère est autorisée.

  • Quel mot clef faut-il utiliser pour redéfinir une fonction polymorphe ?

  • Le chaînage des constructeurs a pour objectif d’initialiser correctement les membres des classes parents.

  • Pour masquer un membre de l’extérieur mais pas de ses enfants, on utilise le mot clef ?