Polymorphism

  • Your native language: with Chrome, right-click on the page and select « Translate into … »

  • English version:

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 Derived : Base
{
        void fnt2() { cout << "fnt2" << endl; }
};

int main()
{
        Derived 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 les paramètres internes ainsi que le fonctionnement d’un objet de 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. Ainsi, il est conseillé :

  • de rendre inaccessible depuis l’extérieur les données et les fonctions internes (protected)

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

#include <iostream>
using namespace std;

class Character
{
        protected :
                int _stamina = 100;  // stamina


        public :

                void bash() // powerful attack
                {
                        if ( CheckAndConsume(25) ) return;
                        ...
                }

                void jump()
                {
                        if ( CheckAndConsume(10) ) return;
                        ...
                }

                int getStamina() { return _stamina; }

                bool CheckAndConsume(int amout)
                {
                        if ( _stamina < amount ) return false;
                        _stamina -= amount;
                        return true;
                }

                void setStamina(int v)
                {
                   if (v < 0) return;
                   _stamina = v;
                }
};

Dans cet exemple, l’endurance d’un personnage diminue lorsqu’il court ou saute. Si elle devient insuffisante, le personnage ne peut plus attaquer ni sauter et doit récupérer. Pour garantir le bon fonctionnement du programme, cet attribut doit rester strictement positif : c’est précisément le rôle du setter setStamina. En effet, une endurance négative pourrait entraîner divers dysfonctionnements, notamment lors de l’affichage de la barre de stamina à l’écran. L’attribut _stamina constitue donc un paramètre interne ; il doit être rendu inaccessible depuis l’extérieur et est déclaré en privé.

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 _name;

        public :
                Animal(string n) { _name = n; }
};

class Dog : public Animal
{
        protected :
                string _breed;

        public :
                Dog(string name, string breed) : Animal(name)   // constructor chaining
                {
                        _breed = breed;
                }

                void Info()
                {
                        cout << "Name: " << _name << " Breed: " << _breed << endl;
                }
};

int main()
{
        Dog d("Rex", "German Shepherd");
        d.Info();
}

Il existe une autre syntaxe que vous pourrez rencontrer :

Animal(string n) { _name = n; }    <====>       Animal(string n) : _nom(n) { }



Dog(string name, string breed) : Animal(name)   { _breed = breed;  }
                <=====>
Dog(string name, string breed) : Animal(name), _breed(breed) { }   // beware of the order - Animal ctr first

Les erreurs

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

Dog(string name, string breed) { _name = name; _breed = breed; }

Dans cette version, le constructeur du parent n’est plus appelé : les données du parent sont directement initialisées dans le constructeur de l’enfant. Certes, cela fonctionne dans ce petit exemple, mais ce n’est pas une bonne pratique et il faut l’éviter. En effet, si le constructeur de la classe parente contenait des traitements supplémentaires — par exemple l’initialisation d’un conteneur de données —, alors en le contournant, l’objet Chien risquerait de ne pas fonctionner correctement. Pour éviter cette situation, le concepteur doit déclarer l’attribut _nom de la classe Animal en private, afin d’éviter ce genre de 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 GraphicObject
{
        virtual void display() { cout << "GraphicObject" << endl; }
};

struct Triangle : public GraphicObject
{
        void display() override { cout << "Triangle" << endl; }
};

struct Circle : public GraphicObject
{
        void display() override { cout << "Circle" << endl; }
};

Le début des problèmes

Testons le code suivant pour voir si le polymorphisme fonctionne :

#include <iostream>
#include <vector>
using namespace std;

struct GraphicObject
{
    virtual void display() { cout << "GraphicObject" << endl; }
};

struct Triangle : public GraphicObject
{
    void display() override { cout << "Triangle" << endl; }
};

struct Circle : public GraphicObject
{
    void display() override { cout << "Circle" << endl; }
};

int main()
{
    GraphicObject O;    Triangle T;         Circle C;

    O.display();        T.display();        C.display();

    // GraphicObject    Triangle            Circle

    ////////////////////////////////////////////////////////////

    GraphicObject A[3];

    A[0] = O;            A[1] = T;           A[2] = C;   // slicing occurs here

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

    // GraphicObject     GraphicObject       GraphicObject

    ////////////////////////////////////////////////////////////

    vector<GraphicObject> L;   // by-value => slicing again

    L.push_back(O);
    L.push_back(T);   // sliced to GraphicObject
    L.push_back(C);   // sliced to GraphicObject

    L[0].display();      L[1].display();        L[2].display();

    // GraphicObject     GraphicObject          GraphicObject
}

É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 contenus dans ces containers sont forcément du type ObjGraphique. Ainsi, lorsque l’on écrit :

  • A[1] = T;

  • L[1] = T;

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 découpés/convertis pour être stockés sous forme d'ObjGraphiques. Ce mécanisme est bien évidemment irréversible.

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 dans un même container, on ne peut utiliser un vector<T> comme on vient de le voir. Le langage C++ ne garantit le fonctionnement du polymorphisme que pour les pointeurs.

Mais, apparaît alors le problème de la destruction des objets. En effet, si l’on détruit l’objet vector, on ne détruit pas les objets liés à ses pointeurs, mais juste les pointeurs ! 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

Voici un exemple complet :

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

using namespace std;

struct GraphicObject
{
        GraphicObject()          { cout << "Ctor GraphicObject" << endl; }
        virtual void display()   { cout << "GraphicObject" << endl;      }
        virtual ~GraphicObject() { cout << "Dest GraphicObject" << endl; }
};

struct Triangle : public GraphicObject
{
        Triangle() : GraphicObject()  { cout << "Ctor Triangle" << endl; }
        void display() override       { cout << "Triangle" << endl; }
        ~Triangle()    override       { cout << "Dest Triangle" << endl; }
};

struct Circle : public GraphicObject
{
        Circle() : GraphicObject()    { cout << "Ctor Triangle" << endl; }
        void display() override       { cout << "Circle" << endl; }
        ~Circle()      override       { cout << "Dest Circle" << endl; }
};

int main()
{
        vector<shared_ptr<GraphicObject>> L;
        L.push_back(make_shared<Triangle>());           // Ctor GraphicObject - Ctor Triangle
        L.push_back(make_shared<Circle>());             // Ctor GraphicObject - Ctor Circle
        L.push_back(make_shared<GraphicObject>());      // Ctor GraphicObject

        for (const auto& o : L)
                o->display();                   // Triangle - Circle - GraphicObject

        // Dest Triangle - Dest GraphicObject
        // Dest Circle   - Dest GraphicObject
        // Dest GraphicObject
}

Dans cet exemple, différents objets sont créés ainsi que leur shared pointers en utilisant la syntaxe make_shared<>. Les smart pointeurs sont stockés dans le vector L. L’affichage indique que pour l’objet Triangle et Circle, le constructeur de la classe parent est d’abord exécuté suivi du constructeur de la classe fille.

La méthode polymorphe affiche() donne un affichage correct pour chaque objet, le polymorphisme fonctionne.

Une fois le programme terminé, les compteurs des shared_ptr tombent à zéro et les objets sont automatiquement détruits. L’affichage indique que pour l’objet Triangle et Circle, le destructeur de l’objet enfant est d’abord exécuté et ensuite celui du parent. C’est l’ordre inverse des constructeurs.

Note

En C++, les destructeurs doivent être déclarés comme virtuels dans une hiérarchie. Les destructeurs sont automatiquement chaînés, vous n’avez pas à gérer ce point.

Avertissement

Pourquoi à partir d’une const référence, peut-on appeler la méthode display() connue pour être non const ?

for (const auto& o : L)
        o->display();

La variable o est un shared ptr et c’est lui qui reçoit le qualificatif const, il est donc non modifiable, on ne peut changer ce smart pointeur. Il va toujours pointer vers le même objet. Cependant, cela n’implique rien pour l’objet pointé, on peut tout de même appeler des méthodes non const depuis ce pointeur const. Ce mécanisme peut être troublant car il est différent de celui des références. En effet, depuis une const référence, on ne peut appeler que les méthodes const de l’objet.

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 ?