Fonctions virtuelles

Décembre 2016

Fonctions virtuelles

Une fonction virtuelle est une fonction membre dont le prototype commence par le mot clé virtual. Ce mot clé ne peut s’appliquer à des fonctions n’appartenant pas à une classe. Ce mot clé n’est pas répété dans la définition de fonction et il n’a pas non plus besoin d’être répété pour les fonctions de classes dérivées. En effet, le compilateur sait qu’une fonction membre est virtuelle dans une classe dérivée si elle l’est dans une classe de base. Nous vous conseillons cependant de l’indiquer de sorte que tout utilisateur de la classe dérivée comprenne bien son utilisation.

En quoi une fonction substituée virtuelle est-elle différente d’une fonction substituée non virtuelle ?

Quand une fonction est substituée dans une classe dérivée, le mécanisme d’exécution n’utilise pas les mêmes critères pour sélectionner la bonne version de la fonction selon que cette dernière est virtuelle ou non.
  • Dans le cas d’une fonction virtuelle, la version est choisie en fonction du type de l’objet et non de sa référence. La ligne objet_derivee->Afficher(); (voir code 7.1) va donc appeler la fonction Afficher() de la classe de l’objet : Derivee.

Dans le cas d’une fonction non virtuelle, la version est choisie en fonction de la référence. C’est pourquoi la ligne objet_derivee->Afficher(); appelle la fonction Afficher() de la classe Base dans notre exemple du code 7.1.

Code 7.1 : affectation d’un pointeur de type Base à un objet de type Deriveexe "affectation:d’un pointeur de type Base à un objet de type Derivee"

1 :#include <iostream.h>  
2 :/*** Insérer ici les déclarations des classes Base et Derivee du Code 6.1*/  
3 :  
4 : int main()  
5 : {  
6 : //on affecte un pointeur de Base à un objet Derivee  
7 :    Base *objet_derivee = new Derivee;  
8 :    objet_derivee->Afficher();  //on accède à une fonction de la classe de base  
9 :    //objet_derivee->Lire();  //erreur, Lire n’est pas membre de Base  
10:    delete objet_derivee;  
11: }


Voici ce que nous obtenons en exécutant le programme :
Le constructeur de Base s’exécute  
Le constructeur de Derivee s’exécute  
La fonction Afficher() de Base s’exécute  
Le destructeur de Base s’exécute


Ce résultat montre que le destructeur de Derivee n’a pas été appelé. Pour éviter de telles pertes de mémoire, vous devez déclarer le destructeur de la classe de base virtuel.

Destructeur virtuel

En règle générale, définissez un destructeur comme virtuel dès que vous prévoyez de dériver sa classe ou si cette dernière possède au moins une fonction virtuelle, sinon le mauvais destructeur pourrait être appelé en cas d’héritage comme illustré dans l’exemple précédent.

Pour comprendre l’enchaînement des appels, vous ne devez pas oublier (troisième règle de la dérivation, voir chapitre précédent) qu’en C++ vous pouvez affecter le pointeur d’une classe de base à un objet d’une classe dérivée (ligne 7). La création de objet_derivee (ligne 7) renvoie un pointeur que l’on peut associer à un pointeur sur un objet Base. Le résultat de cette affectation est un « casting vers le haut » (parce que les classes de base sont toujours représentées en haut et les classes dérivées en bas) et le pointeur obtenu permet d’appeler n’importe quelle méthode de la classe Base.

Or, l’opérateur delete recherche le destructeur à appeler dans la classe de l’objet le plus dérivé. De plus, il restitue la mémoire de l’objet complet, et pas seulement celle du sous-objet référencé par le pointeur utilisé dans l’expression delete.
Lorsqu’on utilise la dérivation, il est donc très important de déclarer les destructeurs virtuels pour que l’opérateur delete utilise le vrai type de l’objet à détruire.

Ajoutez le mot clé virtual devant le prototype du destructeur de la classe Base et vous obtiendrez le résultat approprié :
Le constructeur de Base s’exécute  
Le constructeur de Derivee s’exécute  
La fonction Afficher() de Base s’exécute  
Le destructeur de Derivee s’exécute  
Le destructeur de Base s’exécute


Examinons maintenant la ligne 9. La fonction Lire() étant uniquement définie dans la classe dérivée, vous ne pouvez pas y accéder via le pointeur de type Base objet_derivee.
Vous avez la possibilité de convertir ce pointeur en pointeur de la classe dérivée si la situation l’exige (fonction dynamic_cast), mais ce type d’opération est fortement déconseillé. Signalons que le besoin de convertir un objet vers un autre type est souvent le signe d’une mauvaise conception.

Problème des méthodes masquées

Examinez le code 7.2 qui présente une version modifiée de nos deux classes Base et Derivee.

Code 7.2 : attention aux méthodes masquées
1 :#include <iostream>  
2 :using namespace std;  
3 :  
4 :/* Déclaration de la classe de base */  
5 :class Base  
6 :{  
7 :private:  
8 :   int val_privee;  
9 :public:  
10:   Base(){}  
11:   virtual ~Base(){}  
12:  void Afficher() const;  //cette fonction est surchargée  
13:   void Afficher(int);     //dans la classe de base  
14:};  
15:  
16:/* Déclaration de la classe dérivée */  
17:class Derivee : public Base  
18:{  
19:private:  
20:  
21:public:  
22:   Derivee(){}  
23:   ~Derivee(){}  
24:   //Méthodes d’accès  
25:   void Afficher() const;  //une seule version définie  
26:};                         // dans la classe dérivée  
27:  
28:/* Définition des 2 fonctions Afficher de Base **/  
29:  
30:void Base::Afficher() const   
31:  { cout<<"La fonction Afficher() de Base s’exécute\n"; }  
32:void Base::Afficher(int i)  
33:   {  
34:     val_privee=i;  
35:     cout<<"Valeur de val_privee avec Afficher(int) de Base: ";  
36:      cout << val_privee << endl; }  
37:  
38:/* Définition de la fonction Afficher de Derivee **/  
39:  
40:void Derivee::Afficher() const   
41:{  
42:   cout << "La fonction Afficher() de Derivee s’exécute\n";  
43:}  
44:int main()  
45:{  
46:   //on déclare un objet de type Derivee  
47:   Derivee objet_derivee;   
48:   //on accède à la version sans argument  
49:   objet_derivee.Afficher();  
50:   //objet_derivee.Afficher(5); //erreur  
51:   objet_derivee.Base::Afficher();  
52:   objet_derivee.Base::Afficher(5);  
53:}


L’exécution donne le résultat suivant :

La fonction Afficher() de Derivee s’exécute  
La fonction Afficher() de Base s’exécute  
Valeur de val_privee avec Afficher(int) de Base: 5


La fonction Afficher() est ici surchargée dans la classe de base puis redéfinie dans la classe dérivée mais en un seul exemplaire : la version sans argument. De ce fait, la version définie avec un argument dans la classe de base se trouve masquée. Elle n’est plus disponible par le mécanisme de l’héritage et le compilateur va signaler une erreur à la ligne 48 en précisant que la fonction ne reçoit pas d’argument. Pour accéder explicitement aux fonctions de la classe de base, vous devez les préfixer avec Base: (lignes 49 et 50).

Pour illustrer la puissance du polymorphisme, les fonctions virtuelles et les classes abstraites, nous allons étudier une structure de données que tout programmeur doit connaître : une liste chaînée orientée objet. Une liste chaînée est comparable à un tableau, puisqu’elle est constituée de « cases » (les nœuds) dans lesquelles on stocke des données, mais sa taille évolue dynamiquement selon la quantité de données enregistrées comme dans le cas d’une classe tableau. Vous choisirez la plupart du temps d’utiliser une classe tableau parce qu’elle fournit des fonctions de manipulation des données (des fonctions de tri, par exemple) plus efficaces que celles des listes chaînées. Cependant, si vous prévoyez de supprimer fréquemment des éléments au sein de la structure, une liste chaînée sera plus appropriée. Dans le cas d’un tableau, la suppression d’un élément entraîne le décalage en mémoire de tous les éléments suivants, ce qui représente une opération coûteuse en termes de ressources.

Liste chaînée orientée objet

Quand un programme a besoin de stocker des données, il peut utiliser un tableau. Cette structure offre un accès direct aux données mais il est indispensable de réserver sa place en mémoire, c’est-à-dire de connaître le nombre d’éléments qu’il est susceptible de stocker. Par conséquent, elle offre une solution efficace quand le nombre de données traitées dans le programme peut être évalué et que ce programme doit pouvoir accéder rapidement à n’importe laquelle des données enregistrées.
Une liste chaînée est une chaîne d’éléments généralement nommés nœuds, chacun d’eux pointant sur un objet (les données) et sur le nœud suivant sauf le dernier, qui pointe sur le caractère null (voir fig. 7.1).
L’accès à une telle structure s’effectue donc plutôt de façon séquentielle : vous parcourez la liste en partant du premier élément ou nœud, puis vous accédez aux éléments suivants l’un après l’autre. Vous ne pouvez donc pas accéder directement à une donnée mais vous bénéficiez en revanche d’une grande souplesse dans la gestion des nœuds de la liste.
Vous pouvez facilement :
  • trier les nœuds ;
  • insérer un nœud dans la liste ;
  • insérer une seconde liste à un emplacement précis d’une première ;
  • supprimer un nœud ;
  • supprimer plusieurs nœuds consécutifs.

Ces opérations sont possibles dans le cas d’un tableau mais elles sont beaucoup plus coûteuses.

Figure 7.1 : exemple de liste chaînée.

À savoir

Il existe des listes doublement chaînées, dans lesquelles chaque nœud pointe aussi sur le nœud précédent. Nous limiterons notre étude à une liste simplement chaînée.

Nous pouvons facilement identifier trois types de nœuds : le premier, un nœud « interne », et le dernier. Nous allons donc créer une classe de base Nœud qui décrira la fonctionnalité générale d’un nœud, et trois classes dérivées de cette dernière : ListeChainee pour décrire le nœud de tête (par analogie avec les tableaux C++ dont le nom représente l’adresse du premier élément), NœudInterne pour les nœuds internes et Queue pour les nœuds de fin de liste.

Il reste une dernière classe à définir, celle des objets que nous allons placer dans la liste chaînée : Donnees. Ces classes sont représentées à la figure 7.2.

Figure 7.2 : hiérarchie des classes pour l’implémentation d’une liste chaînée orientée objet.

Examinez attentivement le code de la déclaration de ces classes. Les commentaires indiquent le rôle de chaque donnée membre et de chaque fonction.
Pour faciliter votre compréhension, les déclarations sont immédiatement suivies des définitions correspondantes. Dans la pratique, les définitions devront apparaître avant ou après la fonction main().
Pour comprendre le polymorphisme, étudiez avec soin l’appel de la fonction Inserer() associée à chaque type de nœud (lignes 11, 35, 63 et 95). Les nœuds disposent simplement de deux pointeurs : un sur leur objet Donnees et l’autre sur le nœud suivant (sauf pour le nœud de queue). Ils n’ont aucune idée de la nature des nœuds situés avant ou après eux. Lorsqu’ils appellent la fonction Inserer(), ils ne savent pas laquelle sera exécutée mais le résultat obtenu est correct. Cette fonction illustre particulièrement bien le polymorphisme. Comparez celle du nœud interne (ligne 63) et celle du nœud de queue (ligne 95), elles sont très différentes. Vous comprendrez pourquoi certaines fonctions sont définies comme virtuelles (mot clé virtual) en étudiant le chapitre suivant.

Code 7.3 : implémentation d’une liste chaînéexe "liste chaînée:implémentation"

1 : #include <iostream>  
2 :   
3 : /**************Classe Donnees***********************/  
4 : class Donnees         //classe des objets à enregistrer  
5 : {                     //dans la liste  
6 : public:  
7 :    Donnees (int val):valeur_objet(val){}   //constructeur  
8 :             //avec initialisation du membre valeur_objet  
9 :    ~Donnees (){}  
10: /*Comparer() permet à deux objets Donnees de se comparer  
11: afin de respecter un certain ordre dans la liste*/  
12:    int Comparer(const Donnees &);  
13: /*Afficher() affiche la valeur de l'objet*/  
14:    void Afficher() { std::cout << valeur_objet << std::endl; }  
15: private:  
16:    int valeur_objet;  
17: };  
18: enum { plus_petit, plus_grand, egal};  
19:   
20: int Donnees::Comparer(const Donnees  &autre_objet)  
21: { /*cette fonction permet de comparer les valeurs de 2 objets Donnees*/  
22:    if (valeur_objet < autre_objet.valeur_objet)  
23: /*si la valeur du noeud à insérer est inférieure à celle  
24:   du noeud appelant :*/  
25:       return plus_petit;  
26:    if (valeur_objet > autre_objet.valeur_objet)  
27: /*si la valeur du noeud à insérer est supérieure  
28:    à celle du noeud appelant : */  
29:       return plus_grand;  
30:    else  
31:       return egal;  
32:}  
33:   
34://Déclarations préalables  
35: class Noeud;  
36: class PremierNoeud;  
37: class Queue;  
38: class NoeudInterne;  
39:   
40: /****************Classe Noeud***************************/  
41: class Noeud  
42: {  
43: private:  
44: public:  
45:    Noeud(){}          //constructeur par défaut  
46:    virtual ~Noeud(){} //destructeur virtuel  
47: /*Inserer() reçoit un objet Donnees et le place dans la liste*/  
48: //fct virtuelle pure  
49:    virtual Noeud * Inserer(Donnees * un_objet) = 0;   
50: /*Afficher() affiche la valeur d'un objet Donnees dans la liste*/  
51:  //fonction virtuelle pure  
52: virtual void Afficher() = 0;   
53: };  
54: /****************Classe Noeud Interne********************/  
55: class NoeudInterne: public Noeud  
56: {  
57: private:  
58:    Donnees *mes_donnees;  
59:    Noeud *noeud_suivant;    //pointeur du noeud  
60:                             //suivant dans la liste  
61:   
62: public:  
63:    NoeudInterne(Donnees *un_objet, Noeud *suivant);  
64:    virtual ~NoeudInterne(){ delete noeud_suivant; delete mes_donnees; }  
65:    virtual Noeud * Inserer(Donnees  *un_objet);  
66:    virtual void Afficher()  
67:       { mes_donnees->Afficher(); noeud_suivant->Afficher(); }  
68: };  
69:   
70: NoeudInterne::NoeudInterne(Donnees  * un_objet, Noeud * suivant):  
71: mes_donnees(un_objet),noeud_suivant(suivant)  
72: {  
73: }  
74:   
75: Noeud * NoeudInterne::Inserer(Donnees  * un_objet)  
76: {  
77:   int resultat = mes_donnees->Comparer(*un_objet);  
78: /*le noeud interne compare la valeur du nouveau noeud avec la sienne*/  
79:   switch(resultat)  
80:   {  
81:     case egal:       //si plus grand ou égal  
82:     case plus_grand: //on insère avant le noeud courant  
83:     {  
84:        NoeudInterne * noeud_donnee =   
85:          new NoeudInterne(un_objet, this);  
86:        return noeud_donnee;  
87:     }  
88:   
89:     case plus_petit: //si plus petit on transmet au noeud suivant  
90:        noeud_suivant = noeud_suivant->Inserer(un_objet);  
91:        return this;  
92:   }  
93:     return this;  
94: }  
95:   
96: /*******************Classe Queue*********************/  
97: class Queue : public Noeud  
98: {  
99: private:  
100:  
101:public:  
102:   Queue(){}   //constructeur par défaut  
103:   virtual ~Queue(){}  //destructeur virtuel  
104:   virtual Noeud * Inserer(Donnees * un_objet);  
105:   virtual void Afficher() { }  
106:};  
107:  
108:Noeud * Queue::Inserer(Donnees * un_objet)  
109:{  /*création d'un nouveau NoeudInterne immédiatement   
110:      avant le noeud de queue*/  
111:   NoeudInterne * noeud_donnee = new NoeudInterne(un_objet, this);  
112:   return noeud_donnee;  //on renvoie le pointeur du nouveau noeud  
113: }  
114:  
115: /**************Classe ListeChainee********************/  
116: class ListeChainee : public Noeud  
117: {  
118: private:  
119:   Noeud * noeud_suivant;   //pointeur du noeud  
120:                           //suivant dans la liste  
121:   
122: public:  
123:    ListeChainee();        //constructeur par défaut  
124:    virtual ~ListeChainee() { delete noeud_suivant; }  
125:    virtual Noeud * Inserer(Donnees  * un_objet);  
126:    virtual void Afficher() { noeud_suivant->Afficher(); }  
127:   
128: };  
129: /***Définitions des fonctions de la classe ListeChainee***/  
130: ListeChainee::ListeChainee() //constructeur du noeud de tête  
131: {  
132:    noeud_suivant = new Queue; //le noeud de fin est créé  
133: }   //en même temps que le noeud de tête:   
134:     //on obtient une liste chaînée vide  
135:   
136: Noeud * ListeChainee::Inserer(Donnees  * un_objet)  
137: /*la fonction inserer() reçoit le pointeur de l'objet à insérer dans 138: la liste...*/  
139: {  
140:   noeud_suivant = noeud_suivant->Inserer(un_objet);  
141: /*... et le transmet à la fonction Inserer associée au noeud suivant*/  
142:    return this;  
143: }  
144: /***************Fonction principale*******************/  
145: int main()  
146: {  
147:    Donnees  *p_donnee;   //pointeur d'un objet Donnees  
148:    int val;            //une valeur  
149:    ListeChainee lc;    //et on crée la liste chaînée lc  
150:   
151:   for (;;)       //tant que l'utilisateur ne tape pas 0  
152:   {  
153:    std::cout << "Quelle valeur voulez-vous ajouter \x85 la liste ?";  
154:     std::cout << " (0 si fin): ";  
155:     std::cin >> val;      //on lit la valeur saisie  
156:     if (!val)        //si val est nulle  
157:     break;           //on interrompt la boucle  
158:     p_donnee = new Donnees (val);  /*sinon on crée un nouvel  
159:             objet Donnees de valeur val dans le tas*/  
160:    lc.Inserer(p_donnee);  //et on insère cet objet  
161:   }                       //dans la liste  
162:  
163:   std::cout << "\n\n";  
164:   lc.Afficher(); //on affiche les valeurs de la liste  
165:   std::cout << "\n\n";  
166:   return 0;  
167: }


L’exécution de ce programme donne le résultat suivant :

Quelle valeur voulez-vous ajouter à la liste ? (0 si fin): 10  
Quelle valeur voulez-vous ajouter à la liste ? (0 si fin): 5  
Quelle valeur voulez-vous ajouter à la liste ? (0 si fin): 15  
Quelle valeur voulez-vous ajouter à la liste ? (0 si fin): 0  
5  
10  
15

À savoir

L’opérateur -> d’indirection permet d’accéder aux membres d’un objet à partir de son pointeur.
Si p est le pointeur d’un objet possédant un membre m, la notation p->m est équivalente à (*p).m, c’est-à-dire à objet.m. Vous allez donc interpréter :

noeud_suivant = noeud_suivant->Inserer(un_objet);


comme : on appelle la fonction membre Inserer() du nœud sur lequel noeud_suivant pointe puis on affecte le pointeur renvoyé par la fonction (celui du nouveau NoeudInterne créé) à noeud_suivant.

Voici la logique de fonctionnement de ce programme :
1. On déclare le pointeur p_donnee, un entier val puis on crée l’instance lc de la classe ListeChainee (ligne 148). Le constructeur par défaut de cet objet crée le nœud de tête et le nœud de queue dont il enregistre l’adresse dans le pointeur noeud_suivant de la liste lc (ligne 131). On obtient une liste vide uniquement constituée d’un nœud de tête et d’un nœud de queue.

2. On demande à l’utilisateur de saisir une valeur (ligne 152).

3. Si cette valeur est non nulle, on crée un nouvel objet de type Donnees dans le tas en transmettant la valeur saisie à son constructeur (ligne 156). Le membre valeur_objet de l’objet prend donc la valeur val (ligne 7).

4. On appelle la fonction Inserer() de la liste lc avec le pointeur de l’objet nouvellement créé (ligne 157). Cette fonction transmet le pointeur à la fonction Insérer() associée au nœud sur lequel pointe le membre noeud_suivant de la liste : le nœud de queue.

5. Quand la fonction Insérer() de la classe Queue est appelée, c’est obligatoirement parce que le nœud dont elle reçoit le pointeur est le plus petit de la liste, sinon il aurait été inséré avant. Elle crée donc un nouvel objet NoeudInterne (ligne 110). Le constructeur de ce nœud reçoit deux pointeurs (ligne 110) : celui de l’objet à insérer et celui du nœud qui l’a créé (dans ce cas le nœud de queue). Il enregistre l’adresse de l’objet à insérer dans son pointeur mes_donnees et il enregistre l’adresse du nœud suivant dans son membre pointeur suivant (ligne 71).

6. Enfin, la fonction Inserer() de Queue renvoie le pointeur du nouveau NoeudInterne qui vient d’être inséré à l’objet appelant, dans ce cas le nœud de tête lc. La fonction Inserer() de ce dernier se poursuit en affectant l’adresse du nœud inséré à son propre pointeur noeud_suivant (ligne 138) puis elle renvoie le pointeur de l’objet : this (ligne 140).

7. De retour dans la fonction principale main(), la boucle for s’exécute une nouvelle fois. Si l’utilisateur saisit une autre valeur, l’étape 3 est exécutée de nouveau, puis l’étape 4. Cette fois, le pointeur du nouvel objet n’est pas transmis à la fonction Inserer() du nœud de queue mais à celle du nœud interne créé précédemment, donc à NoeudInterne::Inserer() (ligne 75). Celui-ci compare la valeur de l’objet avec la sienne (ligne 77).
  • Si cette valeur est inférieure, il transmet le pointeur du nouvel objet au nœud sur lequel pointe son noeud_suivant, c’est-à-dire au nœud suivant dans la liste (ligne 89).
  • Sinon (supérieur ou égal), il réalise exactement ce que le nœud de queue exécute lors de l’étape 5 : il crée un nouvel objet NoeudInterne (ligne 84). Le constructeur de ce nœud reçoit deux pointeurs (ligne 110) : celui de l’objet à insérer et celui du nœud qui l’a créé (dans ce cas le nœud interne courant). Il enregistre l’adresse de l’objet à insérer dans son pointeur mes_donnees et il enregistre l’adresse du nœud suivant dans son membre pointeur suivant (ligne 71).


8. Le nœud interne courant renvoie le pointeur du nouveau nœud inséré de sorte que l’appelant de la fonction Inserer() (le nœud situé maintenant avant le nœud inséré) puisse redéfinir correctement son pointeur noeud_suivant.

9. Le programme se poursuit jusqu’à ce que l’utilisateur saisisse la valeur 0. Dans ce cas, nous demandons au nœud lc d’afficher les données (ligne 161). Celui-ci transmet la demande au nœud sur lequel pointe son noeud_suivant (ligne 125).
  • Si la liste est vide, c’est Queue::Afficher() qui est appelée, donc il ne se passe rien.
  • Sinon, c’est la fonction Afficher() associée au premier nœud interne (ligne 66) : elle affiche sa propre valeur (mes_donnees->Afficher()) et transmet la demande d’affichage au nœud suivant (noeud_suivant->Afficher()).

Cette dernière opération se poursuit jusqu’à ce que la demande atteigne le nœud de queue qui met fin à l’affichage.

10. Enfin, pour libérer les ressources, il suffit de supprimer le nœud de tête. L’appel de son destructeur (ligne 123) supprime aussi le nœud suivant dans la liste, qui supprime à son tour le nœud suivant (ligne 64) et ainsi de suite jusqu’à l’appel du destructeur par défaut de Queue.

Le texte original de cette fiche pratique est extrait de
«Tout sur le C++» (Christine EBERHARDT, Collection
CommentCaMarche.net, Dunod, 2009)

A voir également :

Ce document intitulé «  Fonctions virtuelles  » issu de CommentCaMarche (www.commentcamarche.net) est mis à disposition sous les termes de la licence Creative Commons. Vous pouvez copier, modifier des copies de cette page, dans les conditions fixées par la licence, tant que cette note apparaît clairement.