Aller au contenu

Programmation C++ : CM séance 04

8. Héritage et polymorphisme

Le polymorphisme est la capacité pour une classe d'effectuer une liaison dynamique, c'est-à-dire de déterminer au runtime quelle méthode virtuelle il faut appeler.

8.1. Hiérarchies

Une classe peut hériter d'une ou plusieurs autres classes :

  • la classe qui hérite est dite classe dérivée ou classe fille ;
  • une classé héritée est appelée classe de base, classe mère ou super-classe.

Une classe dérivée hérite de toutes les données membre et fonctions membre de ses classes mère, et ainsi de suite.

Exemple de hiérarchie d'héritage :

graph BT
E[Classe E] --> C[Classe C]
C --> A[Classe A]
D[Classe D] --> A
D --> B[Classe B]
  • La classe E dérive (hérite) de la classe C, qui elle-même dérive de la classe A.
  • C est la classe de base directe de E, A est une classe de base indirecte de E.
  • Les classes C et A sont les ancêtres de E.

On parle d'héritage simple ou multiple selon la cardinalité des classes de base directes :

  • E et C n'ont chacune qu'une seule classe de base directe, il s'agit d'un héritage simple ;
  • la classe D dérive de deux classes, A et B, ce qui constitue un héritage multiple.

Syntaxe de la déclaration :

class ClasseDérivée : public ClasseBase1, public ClasseBase2, .... { .... };

(On verra plus loin les alternatives à public que sont private et protected).

Dans notre exemple :

graph BT
E[Classe E] --> C[Classe C]
C --> A[Classe A]
D[Classe D] --> A
D --> B[Classe B]
class A { .... };
class B { .... };
class C : public A { .... };
class E : public C { .... };
class D : public A, public B { .... };

⚠  Il ne faut pas oublier public car par défaut elles sont private.

8.2. Héritage simple

Soit la hiérarchie suivante :

graph RL
E[Dog] --> C[Pet]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Pet {                         // Animal de compagnie
public:
    Pet() {}
    void say_name() { std::cout << "I'm a pet" << std::endl; }
};

class Dog: public Pet {             // Un chien est un animal de compagnie
public:
    Dog() {}
    void bark() { std::cout << "Bark!" << std::endl; }
};

int main()
{
    Pet p;                          // Affiche :
    p.say_name();                   //   I'm a pet
    Dog d;                          //
    d.say_name();                   //   I'm a pet
    d.bark();                       //   Bark!
}

Reprenons l'exemple en rajoutant des données membre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Pet {
public:
    std::string m_name;
    Pet(std::string name): m_name{name} {}
    void say_name() { std::cout << m_name << std::endl; }
};

class Dog: public Pet {
public:
    int m_count;
    Dog(std::string name, int count=1) 
        : Pet{name}, m_count{count} {}
    void bark ()
    {
        for (int i = 0; i < m_count; i++) std::cout << "Bark! ";
        std::cout << std::endl; 
    }
};

int main()
{
    Pet p {"I'm a pet"};            // Affiche :
    p.say_name();                   //   I'm a pet
    Dog d {"Odie", 2};              //
    d.say_name();                   //   Odie
    d.bark();                       //   Bark! Bark! 
}

Étant donné que le constructeur de la classe de base Pet demande un paramètre (ligne 4), il faut le fournir dans le constructeur de la classe dérivée Dog (ligne 11).

Comment le faire ? L'appel des constructeurs avec paramètres se fait de la même façon que pour les constructeurs des objets membres :

class ClasseDérivée: public ClasseBase1, .... {
public:
    ClasseDérivée (type_paramètre paramètre, ....)
        : ClasseBase1{valeur1}, ....                            // {} ou ()
          objet_membre{valeur2}, donnée_membre{valeur3}, ....
    { .... }
};

Remarque : toutes les références ou pointeurs vers des instances de classes dérivées sont implicitement convertis vers des référence ou pointeurs de classe de base lorsque c'est nécessaire. Les membres de la classe dérivée, toujours présents, seront alors inatteignables.

int main()
{
    Dog d {"Odie", 2};              // Affiche :
    d.say_name();                   //   Odie
    d.bark();                       //   Bark! Bark! 
    Pet& p = d;                     // conversion implicite de référence
    p.say_name();                   //   Odie
    p.bark();                       // erreur, p n'est pas un Dog
}

8.3. Héritage multiple

L'héritage multiple a du sens lorsque les classes de base sont totalement disjointes.

Par exemple on considère la hiérarchie suivante :

graph BT
E[Dog] --> C[Pet]
E --> B[Trainable]

(certains animaux de compagnie ne sont pas dressables, et certains animaux dressables ne sont pas des animaux de compagnie).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Pet {                     // Animal de compagnie
public:
    std::string m_name;
    Pet(std::string name): m_name{name} {}
    void say_name() { std::cout << m_name << std::endl; }
};

class Trainable {               // Animal dressable
public:
    bool m_trained;
    Trainable(bool trained): m_trained{trained} {}
    void say_trained() { std::cout << m_trained << std::endl; }
};

class Dog: public Pet, public Trainable {
public:
    int m_count;
    Dog(std::string name, int count=1, bool trained=false) 
        : Pet{name}, Trainable{trained}, m_count{count} {}
    void bark () { .... }
};

int main()
{
    Dog d {"Odie", 2, true};        // Affiche :
    d.say_name();                   //   Odie
    d.say_trained();                //   1
    d.bark();                       //   Bark! Bark!
}

Remarque 1 : l'ordre de déclaration des classes mère induit l'appel des constructeurs des classes mère ; les destructeurs sont appelés dans l'ordre inverse.

Remarque 2 : il est préférable d'éviter les problèmes de l'héritage en diamant.

8.4. Méthode virtuelle

Lors de l'héritage, une classe dérivée peut redéfinir une méthode membre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Pet {
public:
    std::string m_name;
    Pet(std::string name): m_name{name} {}
    void yell() { 
        std::cout << "The pet " << m_name << " is yelling!" << std::endl;
    }
};

class Dog: public Pet {
public:
    Dog(std::string name): Pet{name} {}
    void yell() {
        std::cout << "The dog " << m_name << " is barking!" << std::endl;
    }
};

int main()
{
    Pet p {"Kiwi"};                 // Affiche :
    p.yell();                       //   The pet Kiwi is yelling!
    Dog d {"Odie"};                 //
    d.yell();                       //   The dog Odie is barking!
}

Les fonctions membre à appeler sont déterminées lors de la compilation ; il s'agit d'une liaison statique. Mais il y a des situations où on souhaite que le choix des fonctions membre à appeler soit effectué à l'exécution ; on appelle cela une liaison dynamique.

Ainsi, l'exemple suivant compile parfaitement :

void yell (Pet& pet)
{
    pet.yell();
}

int main()
{
    Pet p {"Kiwi"};                 // Affiche :
    yell(p);                        //   The pet Kiwi is yelling!
    Dog d {"Odie"};                 //
    yell(d);                        //   The pet Odie is yelling!

}

mais l'effet escompté n'est pas atteint, car le compilateur sait uniquement que la fonction yell reçoit un Pet&, et il réalise une liaison statique.

Pour demander au compilateur de réaliser une liaison dynamique :

  • la fonction membre de la classe de base doit être déclarée virtuelle, avec le mot réservé virtual ;

  • la fonction membre de la classe dérivée doit avoir la même signature (nom et types des paramètres), et doit être décorée avec le mot réservé override.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Pet {
public:
    std::string m_name;
    Pet(std::string name): m_name{name} {}
    virtual void yell() { 
        std::cout << "The pet " << m_name << " is yelling!" << std::endl;
    }
};

class Dog: public Pet {
public:
    Dog(std::string name): Pet{name} {}
    void yell() override {
        std::cout << "The dog " << m_name << " is barking!" << std::endl;
    }
};

void yell (Pet& pet) { pet.yell(); }

int main()
{
    Pet p {"Kiwi"};                 // Affiche :
    yell(p);                        //   The pet Kiwi is yelling!
    Dog d {"Odie"};                 //
    yell(d);                        //   The dog Odie is barking!
}

Stockage dans un conteneur

Attention, si on stocke ces éléments dans un std::vector<Pet> il n'y aura plus de liaison dynamique, car l'insertion dans le conteneur est faite par recopie sur la classe de base (slicing).

La solution est de les stocker dans un vector de pointeurs :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int main()
{
    Pet p {"Kiwi"};
    Dog d {"Odie"};

    std::vector<Pet> pets1 {p, d};      // mauvais, liaison statique
    for (auto e : pets1) e.yell();

    std::vector<Pet*> pets2 {&p, &d};   // correct, liaison dynamique
    for (auto e : pets2) e->yell();
}

affiche :

The pet Kiwi is yelling!
The pet Odie is yelling!
The pet Kiwi is yelling!
The dog Odie is barking!

Classe polymorphe

Une classe qui déclare ou hérite d'au moins une fonction membre virtuelle est dite polymorphe.

En interne, chaque classe polymorphe est dotée d'une table de fonctions virtuelles (VTBL) pour effectuer la résolution au runtime. Le coût en mémoire et en durée d'exécution est très faible.

Un constructeur ne peut pas être virtuel, car à l'appel du constructeur, la VTBL est inachevée ; par contre un destructeur peut l'être.

Lorsqu'un constructeur ou destructeur appelle une méthode virtuelle, les sous-classes dérivées sont ignorées (VTBL inachevée).

8.5. Méthode virtuelle pure

Dans certains cas il est intéressant de rentre une méthode virtuelle pure, ce qui signifie qu'elle ne sera pas implémentée dans la classe de base, mais devra être implémentée dans une classe dérivée. La syntaxe consiste à remplacer le corps par = 0 :

class ClasseBase1 {
public:
    ....
    virtual ... méthode_virtuelle_pure (types paramètres....) = 0;
    ....
};

Une classe qui possède au moins une méthode virtuelle pure est qualifiée de classe abstraite ; une classe abstraite ne peut pas être instanciée. Par opposition, une classe concrète est une classe qui n'est pas abstraite et peut être instanciée.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Pet {
public:
    std::string m_name;
    Pet(std::string name): m_name{name} {}
    virtual void yell() = 0;                    // virtuelle pure
};

class Cat: public Pet {
public:
    Cat(std::string name): Pet{name} {}
};

class Dog: public Pet {
public:
    Dog(std::string name): Pet{name} {}
    void yell() override {
        std::cout << "The dog " << m_name << " is barking!" << std::endl;
    }
};

void yell (Pet& pet) { pet.yell(); }

int main()
{
    Pet p {"Kiwi"};             // Erreur, classe abstraite
    Cat c {"Felix"};            // Erreur, classe abstraite
    Dog d {"Odie"};             // ok, classe concrète
    d.yell();                   // Affiche : The dog Odie is barking!
    yell(d);                    // Affiche : The dog Odie is barking!
}

9. Encapsulation

Le but de l'encapsulation est de protéger le fonctionnement interne d'une classe en ne donnant l'accès qu'à sa partie publique, qui constitue l'API de la classe. Ce principe est essentiel pour la séparation et la maintenance du code.

9.1. Spécificateurs d'accès

Le C++ fournit les spécificateurs d'accès public, private et protected au niveau de la déclaration d'une classe. Lorsqu'un membre est déclaré :

  • public, il sera accessible de n'importe quelle fonction ;

  • private, il ne sera accessible que depuis la classe elle-même, ou des classes explicitement autorisées avec le mot réservé friend ;

  • protected, il aura les mêmes restrictions que private, mais pourra aussi être utilisé dans les classes dérivées.

Ces mots réservés peuvent être utilisés plusieurs fois dans n'importe quel ordre. On rappelle que par défaut les membres d'une class sont private et les membres d'un struct sont public.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Bird {
public:
    void sing() { tell ("Chirp!"); }
private:
    void tell (const char* s) { std::cout << s << std::endl; }
};

int main()
{
    Bird b;
    b.sing();               // affiche : Chirp!
    b.tell("Coocoo!");      // erreur, tell est private
}

On aura aussi une erreur ligne 12 si on met protected ligne 4. Quelle est la différence ? On l'observe en rajoutant une classe dérivée :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Bird {
public:
    void sing() { tell ("Chirp!"); }
protected:
    void tell (const char* s) { std::cout << s << std::endl; }
};

class Duck : public Bird {
public:
    void quack() { tell ("Quack!"); }
};

int main()
{
    Bird b;
    b.sing();               // affiche : Chirp!
    b.tell("Coocoo!");      // erreur, tell est protected
    Duck d;
    d.sing();               // affiche : Chirp!
    d.quack();              // affiche : Quack!
}

Si ligne 4 on met private, alors ligne 10 la méthode quack ne pourra pas appeler tell.

9.2. Limiter l'accès par héritage

À quoi sert le spécificateur public dans class Duck : public Bird ?

Il sert à indiquer pour la classe dérivée Duck quel est le plus fort niveau d'accès des membres de la classe de base Bird :

  • Comme public est le plus haut niveau, les niveaux d'accès des membres de la classe de base sont conservés pour la classe dérivée ;

  • Avec protected, les membres public de la classe de base deviennent protected pour la classe dérivée, les private restent private ;

  • Avec private, tous les membres deviennent private pour la classe dérivée : on ne pourra donc pas les appeler.

9.3. Accesseurs

Le principe consiste à placer toutes les données membre en private (ou à la rigueur en protected pour les classes dérivées éventuelles), et donner l'accès public avec des accesseurs, c'est-à-dire par des setters et des getters.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Time {
private:
    int m_s = 0;                    // donnée membre privée
public:
    int s() const { return m_s; }   // getter
    void s(int s) { m_s = s; }      // setter
};

int main()
{
    Time t;
    t.s(5);
    std::cout << t.s() << std::endl;     // ok, affiche : 5
    std::cout << t.m_s << std::endl;     // erreur car privé
}

En C++ moderne, les accesseurs sont réalisés en faisant une surcharge de fonction (au lieu d'écrire des méthodes get_.... et set_....). On peut réutiliser le nom de la variable (sans le préfixe m_) si c'est plus clair.

Méthode const: il convient de décorer les getter avec un const à droite pour dire au compilateur que la méthode ne modifie pas l'instance, cela évitera des warnings lors de l'utilisation lorsque l'instance doit rester const.

Avantages de l'encapsulation : elle permet par la suite de modifier le nom des données membre sans modifier l'API (par exemple m_sm_secondes). Elle permet aussi de valider les données :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Time {
private:
    int m_s = 0;
public:
    int s() const { return m_s; }           // getter
    void s(int s) {                         // setter
        if (s >= 0 && s < 60) m_s = s;      // validation
     }
};

int main()
{
    Time t;
    t.s(5);                                 // valide
    t.s(80);                                // invalide
    std::cout << t.s() << std::endl;        // affiche : 5
}

Fonctions inline :

Toutes les fonctions membre définies au niveau de la classe (autrement dit, non forward), sont automatiquement inlinées (déclarées inline), c'est-à-dire que chaque appel est remplacé par le code du corps de la fonction.

Par conséquent, le surcoût en temps d'exécution des accesseurs définis dans la classe est nul.

Pour inliner une fonction forward, il suffit de la décorer à gauche avec le mot réservé inline. Exemple :

inline int Time::s() const { return m_s; }      // getter

⚠  inline n'est qu'une indication (hint ) pour le compilateur.

Le compilateur g++ accepte des options d'optimisation : -O1 active -finline-functions-called-once, -O2 active -finline-functions et -finline-small-functions.

9.4. Fonctions et classes amies

On a vu que les membres protégés ou privés ne sont pas accessibles en dehors de la classe (éventuellement dérivée pour protected) où ils sont définis.

Le mot réservé friend permet d'assouplir cette règle.

La déclaration peut être faite n'importe où dans la classe (public, private ou protected), cela n'a pas d'incidence car ce n'est pas dans la portée de la classe.

On distingue les classes amies et les fonctions amies :

Classe amie : on peut donner à une classe B l'accès au membres protégés ou privés d'une classe A, en déclarant dans A que B est une classe amie : exemple ligne 9

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Point {
private:
    int m_x, m_y;
public:
    Point (int x, int y) : m_x{x}, m_y{y} {};
    void show() {
        std::cout << m_x << "," << m_y << std::endl;
    }
    friend class Segment;
};

class Segment {
private:
    Point m_p1, m_p2;
public:
    Segment (Point p1, Point p2) : m_p1 {p1}, m_p2 {p2} {}
    void show() {
        std::cout << m_p1.m_x << "," << m_p1.m_y << "  "
                  << m_p2.m_x << "," << m_p2.m_y << std::endl;
    }
};

int main()
{                                       // Affiche :
    Point p1 {2, 3}; p1.show();         //   2,3
    Point p2 {4, 5}; p2.show();         //   4,5
    Segment sA {p1, p2}; sA.show();     //   2,3  4,5
}

Si on ne met pas la ligne 9, la compilation échouera pour la fonction Segment::show qui a besoin d'accéder aux données membre des points.

Note : dans cet exemple on aurait pu faire des accesseurs.

Fonction amie : pour autoriser une fonction (non membre) à accéder à des membres protégés ou privés il suffit de la déclarer comme amie dans la classe : exemple lignes 12 et 24.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Segment;      // déclaration forward

class Point {
private:
    int m_x, m_y;
public:
    Point (int x, int y) : m_x{x}, m_y{y} {};
    void show() {
        std::cout << m_x << "," << m_y << std::endl;
    }
    friend class Segment;
    friend double length (Segment& s);
};

class Segment {
private:
    Point m_p1, m_p2;
public:
    Segment (Point p1, Point p2) : m_p1 {p1}, m_p2 {p2} {}
    void show() {
        std::cout << m_p1.m_x << "," << m_p1.m_y << "  "
                  << m_p2.m_x << "," << m_p2.m_y << std::endl;
    }
    friend double length (Segment& s);
};

double length (Segment& s)
{
    double dx = s.m_p2.m_x - s.m_p1.m_x;
    double dy = s.m_p2.m_y - s.m_p1.m_y;
    return sqrt (dx*dx + dy*dy);
}

int main()
{                                           // Affiche :
    Point p1 {2, 3}; p1.show();             //   2,3
    Point p2 {4, 5}; p2.show();             //   4,5
    Segment sA {p1, p2}; sA.show();         //   2,3  4,5
    std::cout << length(sA) << std::endl;   //   2.82843
}

La fonction length a besoin d'accéder aux données membre privées des classes Segment et Point, c'est pourquoi on les déclare amies avec la fonction. De plus ligne 12 on a besoin de la définition de Segment, donc on déclare la classe en avance ligne 1.

Note : dans cet exemple on aurait pu faire des accesseurs dans Point et une méthode length dans Segment, et il n'y a plus besoin d'amis :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Point {
private:
    int m_x, m_y;
public:
    Point (int x, int y) : m_x{x}, m_y{y} {};
    int x() const { return m_x; }       // getter
    int y() const { return m_y; }       // getter
    void show() {
        std::cout << m_x << "," << m_y << std::endl;
    }
};

class Segment {
private:
    Point m_p1, m_p2;
public:
    Segment (Point p1, Point p2) : m_p1 {p1}, m_p2 {p2} {}
    double length() {
        double dx = m_p2.x() - m_p1.x();
        double dy = m_p2.y() - m_p1.y();
        return sqrt (dx*dx + dy*dy);
    }
    void show() {
        std::cout << m_p1.x() << "," << m_p1.y() << "  "
                  << m_p2.x() << "," << m_p2.y() << std::endl;
    }
};


int main()
{                                           // Affiche :
    Point p1 {2, 3}; p1.show();             //   2,3
    Point p2 {4, 5}; p2.show();             //   4,5
    Segment sA {p1, p2}; sA.show();         //   2,3  4,5
    std::cout << sA.length() << std::endl;  //   2.82843
}