Polymorphism


To translate this course in your native langage:

  • In Chrome: right-click anywhere on the page and select “Translate to…”

  • Or use this language selector:

Les mots colorés en anglais représentent des mots-clés de la norme du langage C++. Ils sont conservés à l’identique lors de l’utilisation d’une traduction automatique avec Google Translate. Exemple : l’héritage (inheritance) est un principe fondamental de C++.


Visibilité et encapsulation

Les structures nous ont permis de travailler sans avoir à gérer les problèmes de visibilité car tous les membres d’instance étaient publics par défaut. Le principe d’encapsulation (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

Les structures nous ont permis jusqu’ici de travailler sans nous préoccuper de la visibilité des membres, puisque ceux-ci sont publics par défaut. Le principe d` encapsulation (encapsulation) consiste au contraire à masquer les détails internes d’un objet et à ne rendre accessible depuis l’extérieur que ce qui est nécessaire à son utilisation. Certaines données peuvent en effet être sensibles : une modification incorrecte pourrait placer l’objet dans un état incohérent ou provoquer un dysfonctionnement. Il est donc généralement conseillé :

  • de rendre privées (private) les données et les fonctions destinées au fonctionnement interne de l’objet

  • de rendre publiques (public) les membres constituant l’interface accessible depuis l’extérieur

Dans le cadre de l’héritage, les membres privés (private) d’une classe de base ne sont pas accessibles depuis les classes dérivées. Le mot-clé protected permet de rendre ces membres inaccessibles depuis l’extérieur, tout en les laissant accessibles aux classes dérivées.

#include <iostream>
using namespace std;

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

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


        public :

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

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


                int getStamina() { return _stamina; }


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

La fonction CheckAndConsume() vérifie que le personnage possède suffisamment d’endurance avant d’en consommer. Comme elle sert uniquement au fonctionnement interne de la classe, elle n’a pas vocation à être appelée depuis l’extérieur. Elle est donc déclarée en protected, au même titre que l’attribut _stamina. Les autres membres sont déclarés en public car ils constituent l’interface de la classe. Ils représentent les actions et les informations que le reste du programme est autorisé à utiliser.

Héritage

L’héritage en C++ est un mécanisme fondamental de la programmation orientée objet (POO). Il permet de définir de nouvelles classes, appelées classes dérivées (derived classes), à partir de classes existantes appelées classes de base (base classes). Une classe dérivée peut ainsi réutiliser et spécialiser les caractéristiques et les comportements définis par sa classe de base. L’héritage favorise la réutilisation du code et permet de structurer plus clairement les 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 Derived dispose de sa propre fonction fnt2(), mais elle hérite aussi de la fonction fnt1() de sa classe mère.

Chaînage des constructeurs

Principe

Lorsqu’une classe dérivée est instanciée, son constructeur est appelé comme habituellement. Cependant, cet objet contient des données provenant de la classe de base qui doivent aussi être correctement initialisée. Pour cela, il faut donc qu’un constructeur de la classe de base soit appelé. Voici comment cela fonctionne :

Règles concernant le chaînage des constructeurs (constructor chaining) :

  • Si le constructeur par défaut de la classe de base suffit, il est appelé implicitement.

  • Si un constructeur paramétré de la classe de base doit être utilisé, le constructeur de la classe dérivée doit l’appeler explicitement.

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

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

Il est aussi possible d’utiliser la syntaxe suivante :

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

Dog(string name, string breed) : Animal(name), _breed(breed) { }   // beware of the order - chaining first

Les erreurs

Il est possible de ne pas chaîner et décrire l’initialisation des données membres de la classe de base dans le contructeur de la classe dérivée :

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

Certes, cela fonctionne, mais c’est une mauvaise pratique car une classe dérivée ne doit pas modifier des données internes de sa classe de base directement. En effet, le constructeur de la classe de base peut effectuer des vérifications, des conversions ou des initialisations supplémentaires qu’une affectation directe pourrait manquer. Le passage en private des données de la classe de base permet d’éviter cet écueil.

Polymorphisme d’héritage

Présentation

Dans une hiérarchie d’héritage, il est possible de mettre en place un polymorphisme polymorphism. Ce mécanisme permet à une fonction portant le même nom 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 dans la classe de base doit être qualifiée de virtual.

Les fonctions redéfinies (overridden) dans les classes dérivées doivent utiliser le mot clef override.

Par exemple, dans une hiérarchie d’objets géométriques : Triangle, Circle et Rectangle. Il serait maladroit de créer des fonctions drawTriangle(), drawCircle() et drawRectangle() pour afficher ces objets. Il est plus simple et plus lisible d’avoir une seule méthode draw() pour toutes les classes et que cette méthode s’adapte suivant la nature de l’objet :

 #include <iostream>
 #include <vector>

 using namespace std;

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

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

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


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

G.draw();     >> GraphicObject
T.draw();     >> Triangle
C.draw();     >> Circle
 }

Le début des problèmes

Si nous voulons manipuler différents objets géométriques, il serait pratique de pouvoir stocker tous nos cercles et rectangles dans un seul vector. Or, le template vector nécessite de préciser le type des éléments qu’il contient. Il semble donc naturel d’utiliser le type de la classe mère : vector<GraphicObject>. Voyons maintenant si cette approche permet de conserver le polymorphisme.

    #include <iostream>
    #include <vector>

    using namespace std;

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

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

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

int main()
{
    vector<GraphicObject> L;

    Triangle T;
    Circle C;

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

    L[0].draw();         L[1].draw();

    // GraphicObject     GraphicObject
}

Étrangement, tout compile correctement et le programme s’exécute, mais le polymorphisme ne fonctionne pas comme prévu. Quel que soit l’objet inséré dans le vector, tout se passe comme s’il était uniquement du type de la classe mère ObjGraphique. Pour comprendre ce phénomène, il faut se rappeler qu’un vector<T> stocke réellement des objets de type T. Ainsi, dans :

vector<GraphicObject> L;

Tous les éléments contenus dans ce vector sont des objets de type GraphicObject. Que se passe-t-il alors lorsque l’on écrit :

L.push_back( Circle(...) );

L’objet Circle être converti en ObjGraphique afin de pouvoir être stocké dans le vector. Seule la partie de l’objet correspondant à la classe mère est alors copiée : les données propres à l’objet Circle sont perdues.

Object slicing : Si B est une classe dérivée de A et que l’on stocke un objet B dans un vector<A> alors l’objet est converti vers le type A. Ce phénomène est appelé object slicing. Il empêche le polymorphisme de fonctionner correctement.

Autrement dit, une fois placé dans un vector<ObjGraphique>, le cercle n’est plus stocké comme un véritable objet Circle, mais comme un simple ObjGraphique. Le polymorphisme ne peut donc plus fonctionner comme attendu. Ce mécanisme est identique à celui que l’on connait avec les types fondamentaux. Si vous stockez un double dans un vector<int>, sa valeur sera tronquée et convertie en entier.

Polymorphisme et vector

La solution consiste à utiliser un vector de shared pointeurs. Dans ce cas précis, le langage vérifie dynamiquement à l’exécution le type réel de l’objet et sait gérer le polymorphisme.

Mise en place

Voici un exemple complet :

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

using namespace std;


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

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

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



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


        for (auto& obj : L)
                obj->draw();
}

>> Triangle
>> Circle

Dans cet exemple :

  • Différents objets sont créés ainsi que leur shared_ptr en utilisant la syntaxe make_shared<>.

  • Les smart pointeurs sont stockés dans le vector L.

  • La méthode polymorphe draw() donne un affichage correct pour chaque objet, le polymorphisme fonctionne !

Une fois la fonction main() terminée, les compteurs de chaque shared_ptr tombe à zéro et les objets sont automatiquement détruits.

Quizzz

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

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

  • 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.

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

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

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

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