IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Les fonctions virtuelles en C++ : Types statiques et types dynamiques

Les fonctions virtuelles en C++ :
Types statiques et types dynamiques


précédentsommairesuivant

V. Première conséquence : comment bien déclarer son destructeur

Prenons maintenant l'exemple suivant :

Erreur : un héritage public sans destructeur virtuel !
Sélectionnez
#include <iostream>

struct base
{
   ~base()
   {
     std::cout<<"destructeur de base\n";
   }
};
struct derived :public base
{
   ~derived()
   {
     std::cout<<"destructeur de derived\n";
   }
};

int main()
{
   base *p_b = new derived;
   std::cout<<"Destruction de l'objet :\n";
   delete p_b;

   return 0;
}

Que va-t-il se passer ? Le type statique de p_b est base. Son type dynamique est derived. Cependant, le destructeur n'étant pas virtuel, l'appel est résolu à la compilation en utilisant le type statique. Le destructeur appelé est donc base::~base. Ainsi, le code précédent produit dans les compilateurs les plus courants le résultat suivant :

Un résultat parmi d'autres :
Sélectionnez
Destruction de l'objet :
destructeur de base

Le destructeur de la classe derived n'est pas appelé ! Conclusion : le destructeur d'une classe de base pouvant être détruit de façon polymorphique DOIT être virtuel.

En fait, la norme est beaucoup plus sévère : l'appel d'un destructeur non virtuel sur un pointeur dont le type dynamique est différent du type statique provoque un comportement indéterminé(7) . Si les compilateurs se contentent de résoudre l'appel statiquement à la compilation, il est fort possible qu'un jour ou l'autre le même code fasse tout autre chose car vous aurez changé de compilateur (portage vers une autre cible) ou parce que la nouvelle version de votre compilateur favori aura décidé de gérer cet aspect d'une façon toute différente. Le code présenté ci-dessus a un comportement indéterminé.

La règle précédente s'étend de façon plus globale :

Un destructeur d'une classe utilisée pour l'héritage public doit être virtuel s'il est public ou protégé s'il est non virtuel.

L'entrée de la F.A.Q. reprend cette problématique : Pourquoi le destructeur d'une classe de base doit être public et virtuel ou protégé et non virtuel ?Pourquoi le destructeur d'une classe de base doit être public et virtuel ou protégé et non virtuel ?

Le code précédent peut alors soit se décliner :

Interdire la destruction polymorphe :
Sélectionnez
#include <iostream>

struct base
{
protected:
   ~base()
   {
     std::cout<<"destructeur de base\n";
   }
};
struct derived :public base
{
   ~derived()
   {
     std::cout<<"destructeur de derived\n";
   }
};

int main()
{
   base *p_b = new derived;
   std::cout<<"Destruction de l'objet :\n";
   delete p_b; // -> Erreur de compilation

   return 0;
}

Et une erreur de compilation signale alors le problème.
S'il faut pouvoir détruire des objets de la classe derived depuis un pointeur de base, le destructeur doit être virtuel :

Autoriser la destruction polymorphe :
Sélectionnez
#include <iostream>

struct base
{
   virtual ~base()
   {
     std::cout<<"destructeur de base\n";
   }
};
struct derived :public base
{
   virtual ~derived()
   {
     std::cout<<"destructeur de derived\n";
   }
};

int main()
{
   base *p_b = new derived;
   std::cout<<"Destruction de l'objet :\n";
   delete p_b;

   return 0;
}

Le résultat est bien celui attendu :

Un résultat correct :
Sélectionnez
Destruction de l'objet :
destructeur de derived
destructeur de base

Suivre cette règle permet de garantir qu'une erreur de compilation signalera tout bug potentiel.
Corrollaire : si une bibliothèque définit des classes ou des structures dont le destructeur est public et non virtuel alors cette classe ne doit pas être utilisée comme classe de base pour un héritage public.
Si on prend l'exemple de la STL, le destructeur de std::vector n'étant pas virtuel, cette classe ne doit pas servir de base à un héritage :

Erreur potentiellement dramatique :
Sélectionnez
class mon_vecteur : public std::vecteur<int>
{/*[...]*/
};
// code DANGEREUX et à éviter !

A l'inverse, std::iostream possède un destructeur virtuel. Cette classe peut servir comme classe de base pour un héritage public.

Dans le cadre d'un héritage privé, la problématique ne se pose pas. Il n'est pas possible d'avoir une référence (ou un pointeur) d'un type de base hérité en privé sur un objet dérivé (8) . On ne peut donc pas vouloir détruire polymorphiquement un type dérivé par une de ses interfaces privées :

Héritage privée :
Sélectionnez
struct base
{
public:
   ~base(){}
};
struct derived : private base
{
};
int main()
{
   base *pb = new derived; // Erreur à la compilation !
   
   return 0;
}

Si une classe ne définit pas explicitement de destructeur, alors le compilateur créé un destructeur implicite qui se contente d'appeler le destructeur de chaque membre de la classe ainsi que le destructeur de chaque classe de base. Le destructeur implicitement créé est public et non virtuel. Conclusion, il est potentiellement dangereux si la classe doit servir de classe de base pour un héritage. L'héritage est donc un des rares cas où il faut définir un constructeur explicite même si celui-ci est trivial (c'est à dire vide).

Le destructeur implicite créé par le compilateur étant public et non virtuel, une classe destinée à l'héritage doit définir un destructeur explicite public et virtuel ou protégé et non virtuel.

Un destructeur public DOIT être virtuel :
Sélectionnez
struct base
{
   virtual ~base(){} // définition obligatoire si base va servir pour un héritage public !
};

C++0x : le mot-clé =default est introduit dans la future norme C++0x. Cela permet de définir une fonction dans la classe mais sans lui donner d'implémentation explicite. Le compilateur génère une implémentation implicite telle que définie par la norme. Pour les destructeurs triviaux (c'est à dire, ne faisant explicitement rien), cette nouveauté est l'idéal :

Destructeur virtuel par défaut en C++0x :
Sélectionnez
struct base
{
   virtual ~base()=default;// utilise la définition par défaut 
         // pour le destructeur virtuel de base
};

struct derived : public base
{
   virtual ~derived()=default; // utilise la définition par défaut 
         // pour le destructeur virtuel de derived
};


Destructeur et constructeur sont les deux seules fonctions en C++ dont l'appel vers la classe de base est automatique. Pour prendre l'exemple du destructeur, lorsque le destructeur d'une classe dérivée est terminé, le destructeur de la classe de base est automatiquement appelé sans avoir à l'expliciter dans l'implémentation du destructeur :

Appel en cascade des destructeurs virtuels ou pas :
Sélectionnez
struct base
{
   virtual ~base()
   {
      std::cout<<"~base\n";
   }
};
struct derived: public base
{
   virtual ~derived()
   {
      std::cout<<"~derived\n";
   } // A la sortie, base::~base est appelé
};

int main()
{
   derived d;
   
   return 0;
} // A la sortie, le destructeur derived::~derived est appelé

Le fait que le destructeur soit virtuel ne change pas ce comportement. Le destructeur de la classe de base continue d'être appelé. La seule chose qui change est le premier appel dans le cadre d'une destruction polymorphe : celui du type dynamique de l'objet si le destructeur est virtuel (OK), indéterminé sinon.

VI. Seconde conséquence : inlining de fonctions et fonctions virtuelles

Le compilateur peut choisir d'inliner une fonction, c'est à dire de remplacer l'appel de celle-ci en recopiant directement le code correspondant à la fonction appelée au niveau de la fonction appelante (9) . C'est une optimisation qui permet d'accélérer le code puisqu'un appel de fonction (qui est toujours coûteux) est évité (10) . En C++, deux façons permettent de définir une fonction inline : en ajoutant le mot-clé inline devant la fonction ou en la définissant dans la déclaration de la classe :

Fonctions inline :
Sélectionnez
struct my_struct
{
  inline void function_1();
  void function_2()
  {
    // ...
  }
  protected : // ne plus oublier maintenant de définir son destructeur
     // selon la politique souhaitée !
  ~my_struct()
  {}    
};

void my_struct::function_1()
{
  // ...
}

int main()
{
   my_struct var;
   var.function_1(); // l'appel est remplacé par le corps de la fonction
   var.function_2(); // l'appel est remplacé par le corps de la fonction
   
   return 0;
}

Quel rapport avec le type statique, dynamique et les fonctions virtuelles ? Et bien, c'est le compilateur qui inline une fonction donc seuls les appels résolus à la compilation peuvent faire l'objet de cette optimisation. Si nous reprenons le tableau indiquant le moment de la résolution d'un appel, les cas où le compilateur peut inliner apparaissent immédiatement :

inlining possible ?
  fonction non virtuelle fonction virtuelle
appel sur un objet COMPILATION : inline possible COMPILATION : inline possible
appel sur une référence ou un pointeur COMPILATION : inline possible EXÉCUTION : inline impossible


Deux conséquences apparemment contradictoires :

Il est inutile de déclarer inline une fonction virtuelle.

En effet, cela ne sert à rien si la fonction est appelée avec une référence ou un pointeur puisque la résolution de l'appel se fait à l'exécution. Outre que c'est inutile, il est recommandé de ne pas le faire pour ne pas laisser croire à un relecteur du code que cela pourrait l'être et accessoirement pour montrer à ce relecteur que vous avez compris ce mécanisme.

Une fonction virtuelle peut être inlinée par le compilateur si elle est appelée dans un contexte non polymorphe.

Cela correspond à la première ligne et la seconde colonne de notre tableau : l'appel d'une fonction virtuelle sur un objet par valeur peut très bien être inliné puisque l'appel est résolu à la compilation :

Fonction virtuelle inlinée :
Sélectionnez
struct base
{
   virtual ~base(){}
   virtual void function()
   {
      // ...
   }
};
struct derived : public base
{
   virtual void function()
   {// spécialisation
      // ...
   }
};

int main()
{
   base b;
   b.function(); // peut être inliné

   derived d;
   d.function(); // peut être inliné

   base &rd = d;
   rd.function(); // NE peut PAS être inliné

   derived &rd2 = d;
   rd2.function(); // NE peut PAS être inliné

   return 0;
}

VII. Quand est construit le type dynamique ou quel est l'impact des appels de fonctions virtuelles dans un constructeur ?

Le type dynamique d'un objet en cours de construction est celui du constructeur en cours d'exécution et non de l'objet réellement construit.
Reprenons l'ordre de construction d'un objet tel que nous le trouvons présenté dans la F.A.Q. : Dans quel ordre sont construits les différents composants d'une classe ?Dans quel ordre sont construits les différents composants d'une classe ?

  • le constructeur des classes de base héritées virtuellement en profondeur croissante et de gauche à droite ;
  • le constructeur des classes de base héritées non virtuellement en profondeur croissante et de gauche à droite ;
  • le constructeur des membres dans l'ordre de leur déclaration ;
  • le constructeur de la classe.

Déroulons l'exemple suivant :

Type dynamique et construction d'un objet :
Sélectionnez
#include <iostream>

struct base
{
   base()
   {
      std::cout<<"constructeur de base\n";
   }
   virtual ~base(){}
};

struct derived_1 : public base
{
   derived_1()
   {
      std::cout<<"constructeur de derived_1\n";
   }
};

struct derived_2 : public derived_1
{
   derived_2()
   {
      std::cout<<"constructeur de derived_2\n";
   }
};

int main()
{
   derived_2 d2;
   return 0;
}

L'ordre de construction de d2 donne comme appels :

Types statiques et dynamiques pendant la construction
Constructeur en cours d'exécution type statique type dynamique
base::base base base
derived_1::derived_1 derived_1 derived_1
derived_2::derived_2 derived_2 derived_2


Ce tableau fait tout de suite apparaître le problème des appels de fonctions virtuelles dans le constructeur : la fonction effectivement appelée sera celle définie dans la spécialisation la plus basse en cours de construction.
Illustrons avec un peu de code :

Appel de fonctions virtuelles dans un constructeur :
Sélectionnez
#include <iostream>

struct base
{
   base()
   {
      init();
   }
   virtual void init()
   {
      std::cout<<"base::init\n";
   }
   
   protected:
   ~base()
   {
   }
};

struct derived_1 : public base
{
   derived_1()
   {
      init();
   }
   virtual void init()
   {
      std::cout<<"derived_1::init\n";
   }
};

struct derived_2 : public derived_1
{
   derived_2()
   {
      init();
   }
   virtual void init()
   {
      std::cout<<"derived_2::init\n";
   }
};

int main()
{
   derived_2 d2;
   return 0;
}

Ce code produit comme sortie :

 
Sélectionnez
base::init
derived_1::init
derived_2::init

En effet, en reprenant le tableau sur le type dynamique en cours de construction, le déroulement s'explique aisément :

Types statiques et dynamiques pendant la construction
Constructeur en cours d'exécution type dynamique Spécialisation appelée
base::base base base::init
derived_1::derived_1 derived_1 derived_1::init
derived_2::derived_2 derived_2 derived_2::init


Appeler une fonction virtuelle dans un constructeur n'appelle pas la fonction la plus spécialisée de l'objet en cours de construction mais celle disponible pour le constructeur en cours d'exécution !

VIII. Que devient le type dynamique lors de la destruction de l'objet ou peut-on appeler des fonctions virtuelles dans un destructeur ?

Le problème est symétrique lors de l'appel du destructeur car les destructeurs sont appelés dans l'ordre inverse de l'appel des constructeurs : le type dynamique dans le destructeur coïncide, comme dans le constructeur, avec le type statique du destructeur en train d'être déroulé. Et donc la fonction virtuelle appelée est celle disponible pour le destructeur en cours d'exécution.
Soit en partant du code suivant :

Type dynamique et destruction d'un objet :
Sélectionnez
#include <iostream>

struct base
{
   virtual ~base()
   {
      exit();
   }
   virtual void exit()
   {
      std::cout<<"base::exit\n";
   }
};

struct derived_1 : public base
{
   virtual ~derived_1()
   {
      exit();
   }
   virtual void exit()
   {
      std::cout<<"derived_1::exit\n";
   }
};

struct derived_2 : public derived_1
{
   virtual ~derived_2()
   {
      exit();
   }
   virtual void exit()
   {
      std::cout<<"derived_2::exit\n";
   }
};

int main()
{
   derived_2 d2;
   return 0;
}

Le code produit la sortie suivante :

 
Sélectionnez
derived_2::exit
derived_1::exit
base::exit

Cette sortie s'explique grâce à notre tableau indiquant le type dynamique en cours d'exécution :

Types statiques et dynamiques pendant la destruction
Destructeur en cours d'exécution type dynamique Spécialisation appelée
derived_2::~derived_2 derived_2 derived_2::exit
derived_1::~derived_1 derived_1 derived_1::exit
base::~base base base::exit


Nous pouvons donc tirer une conclusion similaire pour la destruction d'un objet à celle de la construction :

Appeler une fonction virtuelle dans un destructeur n'appelle pas la fonction la plus spécialisée de l'objet en cours de destruction mais celle disponible pour le destructeur en cours d'exécution !

IX. Construction, destruction, fonctions virtuelles et multithreading

Les constructions et les destructions ne sont pas des opérations atomiques. Par conséquent, commencer à utiliser un objet dans un fil d'exécution T1 pendant qu'il est construit dans un fil d'exécution T2 peut révéler des surprises. En particulier, l'appel d'une fonction virtuelle peut ne pas correspondre à l'appel attendu si le type dynamique n'est pas correctement initialisé. Il en va de même pendant la phase de destruction. Un objet en train d'être détruit voit son type dynamique modifié lors de la remonté des appels des destructeurs. Ces situations de compétitions doivent être anticipées lors de l'utilisation de l'objet sous peine de comportement indéterminé.

X. Une vision plus avancée des appels de fonctions virtuelles dans les constructeurs et destructeurs

En fait, si l'approche type dynamique/type statique permet de facilement comprendre que l'appel d'une fonction virtuelle dans un constructeur ou un destructeur ne provoque pas l'appel de la spécialisation de la classe la plus dérivée de l'objet en cours de construction (resp. de destruction), la réalité est plus subtile.
En effet, la norme dit juste que l'appel d'une fonction virtuelle dans un constructeur (resp. destructeur) doit être résolu par l'appel de la spécialisation de la classe en train d'être construite (et non de l'objet en train d'être construit) ou d'une de ses classes de bases.
Cette petite nuance permet à certain compilateur d'optimiser les appels directs des fonctions virtuelles dans les constructeurs et les destructeurs. En effet, au moment de la compilation du constructeur (resp. destructeur), le compilateur connaît la classe en train d'être construite (resp. détruite) et toutes ses classes de base. Cet appel peut dès lors être traité par le compilateur comme il le ferait avec une fonction non virtuelle en utilisant une résolution statique. Et c'est bien le comportement observé avec un compilateur comme Visual C++ Express 2008 ou GCC 4.4.1 (MinGW) (11) . Les appels directs des fonctions virtuelles dans le constructeur et le destructeur sont identiques avec ces compilateurs à ceux de fonctions non virtuelles. Il s'agit d'une optimisation de certains compilateurs et non de la règle générale !

Certains compilateurs optimisent les appels directs des fonctions virtuelles dans le constructeur ou le destructeur en utilisant une résolution statique de cet appel.

Optimisation de l'appel direct d'une fonction virtuelle dans le constructeur/destructeur :
Sélectionnez
struct base
{
   base()
   {
      do_init(); // certains compilateurs traitent cet appel comme une fonction non virtuelle
   }
   virtual void do_init()
   {
      std::cout<<"base::do_init\n";
   }

   virtual ~base(){}
};

Bien sûr ceci n'est pas possible lorsque la fonction virtuelle est appelée dans le constructeur (ou le destructeur) de façon indirecte :

Optimisation non réalisée pour un appel indirect :
Sélectionnez
struct base
{
   base()
   {
      init();
   }
   void init()
   {
      do_init();
   }
   virtual void do_init()
   {
      std::cout<<"base::do_init\n";
   }

   virtual ~base(){}
};

Les appels indirects ne sont pas traités comme des fonctions non virtuelles. En effet, la fonction init peut être appelée dans un autre contexte que le constructeur (ou le destructeur). Cependant, le type utilisé dans le constructeur (ou le destructeur) pour la résolution de l'appel indirect d'une fonction virtuelle reste toujours le type du constructeur (resp. destructeur) en cours d'exécution et non le type de l'objet en cours de construction (resp. destruction). Nous verrons plus loin dans cet article (Comment ça marche ?), comment le compilateur arrive à respecter la norme dans ce cas.
Un peu de code pour montrer que l'appel indirect a bien le comportement attendu :

Différences dans la résolution dynamique entre la construction/destruction et pendant la vie 'normale' de l'objet :
Sélectionnez
#include <iostream>
struct base
{
   base()
   {
      init();
   }
   void init()
   {
      do_init();
   }
   virtual void do_init()
   {
      std::cout<<"base::do_init\n";
   }

   virtual ~base(){}
};

struct derived : public base
{
   derived()
   {
      init();
   }
   virtual void do_init()
   {
      std::cout<<"derived::do_init\n";
   }
};
int main()
{
   std::cout<<"construction de derived :\n";
   derived d;
   std::cout<<"appel de init apres la construction de l'objet :\n";
   d.init();
   
   return 0;
}

Ce code produit toujours la sortie :

 
Sélectionnez
construction de derived :
base::do_init
derived::do_init
appel de init apres la construction de l'objet :
derived::do_init

On revient à notre tableau sur le type dynamique courant lors d'une construction (resp. destruction) :

Types statiques et dynamiques pendant la construction
Constructeur en cours d'exécution type dynamique Spécialisation appelée dans base::init
base::base base base::do_init
derived::derived derived derived::do_init


Comme nous le verrons dans la section 'Comment ça marche ?', la résolution dynamique de l'appel des fonctions virtuelles a un coût. Pouvoir dans des cas précis comme ceux-ci dessus remplacer la résolution dynamique par une résolution statique permet de ne pas payer ce coût.

XI. Et pour les fonctions virtuelles pures ?

XI-A. Fonctions virtuelles pures, classes abstraites, classes concrètes

Revenons sur les fonctions virtuelles pures brièvement évoquées en début d'article. Syntaxiquement, une fonction virtuelle pure est une fonction virtuelle à laquelle est accolée =0 à la fin de la déclaration :

Fonction virtuelle pure :
Sélectionnez
struct base
{
   void pure_function()=0;
   virtual ~base(){}
};

Une fonction virtuelle pure définit une classe abstraite. Une classe abstraite ne peut pas être instanciée :

Une classe abstraite n'est pas instanciable :
Sélectionnez
struct abstract
{
   virtual void pure_function()=0;
   virtual ~abstract(){}
};
int main()
{
   abstract b; // erreur
   return 0;
}

Une classe définit une fonction virtuelle pure quand elle souhaite indiquer que les classes dérivées DOIVENT spécialiser cette fonction virtuelle pour pouvoir être instanciée. Une classe dérivant d'une classe abstraite et spécialisant les fonctions virtuelles pures est appelée classe concrète. Une classe concrète peut alors être instanciée :

Seule une classe concrète peut être instanciée :
Sélectionnez
struct abstract
{
   virtual void pure_function()=0;
   virtual ~abstract(){}
};
struct concrete : public abstract
{
   virtual void pure_function() {}
};
int main()
{
   concrete b; // OK
   return 0;
}

Comme les fonctions virtuelles pures doivent être spécialisées par les classes concrètes, il est possible de ne pas définir d'implémentation pour une fonction virtuelle pure dans la classe abstraite :

Les fonctions virtuelles pures peuvent ne pas être définies dans la classe abstraite :
Sélectionnez
struct abstract
{
   virtual void pure_function()=0; // pas de définition
   virtual void function(); // doit être définie
   
   virtual ~abstract(){}
};
struct concrete : public abstract
{
   virtual void pure_function() {} // la définition dans la classe concrète peut suffire
                                   // A noter que la spécialisation de la fonction
                                   // dans la classe concrète DOIT être définie
   virtual void function(){} // la définition dans la classe ne suffit pas pour
                             // une classe virtuelle non pure. Elle doit aussi être définie dans la 
                             // classe de base
};

int main()
{
   concrete c;
   return 0;
}//Erreur à l'édition de lien : abstract::fonction non définie

Enfin, si une classe abstraite veut définir une fonction virtuelle pure alors elle doit le faire en dehors de la déclaration de la fonction :

Définition d'une fonction virtuelle pure à l'extérieur de la déclaration de la classe :
Sélectionnez
struct abstract
{
   virtual void pure_function()=0;
   
   virtual ~abstract(){}
};
void abstract::pure_function()
{// définition de la fonction
}

Visual C++ compile une fonction virtuelle pure définie dans la classe abstraite mais c'est un écart à la norme. Le code suivant compile avec Visual C++ mais pas GCC :

Ecart de Visual C++ :
Sélectionnez
struct abstract
{
   virtual void pure_function()=0
   {// Compile avec Visual C++ mais pas avec GCC
   }
   
   virtual ~abstract(){}
};

S'il est impossible d'instancier une classe abstraite, rien en revanche n'interdit d'utiliser un pointeur ou une référence vers une classe abstraite :

Pointeur et référence vers une classe abstraite :
Sélectionnez
struct abstract
{
   virtual void function()=0;
   
   virtual ~abstract(){}
};
struct concrete : public abstract
{
   virtual void function(){}
};

int main()
{
   concrete c;
   abstract &ra = c;
   abstract *pa = new concrete; // On n'oublie pas notre destructeur virtuel...
   delete pa;
   return 0;
}

Enfin, les plus attentifs auront déjà déduit cette conséquence de l'impossibilité d'instancier une classe abstraite :

Le type dynamique d'une variable ne peut jamais être une classe abstraite en dehors du constructeur ou du destructeur.

En effet, le type dynamique est le type de l'objet réellement utilisée à l'exécution du programme. Or ne pouvant instancier de classe abstraite, l'objet réellement utilisée sera toujours une classe concrète dérivée de la classe abstraite. Nous avons vu que les deux seuls cas échappant à cette règle sont le constructeur et le destructeur dans lesquels types dynamique et statique coïncident.
En revanche, le type statique d'une variable peut être celui d'une classe abstraite. this à l'intérieur d'une fonction de la classe abstraite est un cas trivial où le type statique est celui de la classe abstraite. Mais il en va de même pour les références et les pointeurs de classe abstraite :

Types statiques et dynamiques dans les classes abstraites et concrètes :
Sélectionnez
struct abstract
{
   virtual void pure_function()=0;
   void a_function()
   {// type statique == abstract
    // type dynamique == type de la classe concrète dérivée
   }
   
   virtual ~abstract() {}
};
struct concrete : public abstract
{
   virtual void pure_function() {}
};
int main()
{
   concrete b;
   abstract &rb = b; // le type statique de rb est abstract, le type dynamique est concrete
   abstract *pb = &b; // le type statique de pb est abstract, le type dynamique est concrete
   return 0;
}

XI-B. Appel d'une fonction virtuelle pure

L'appel d'une fonction virtuelle pure suit toujours les mêmes règles que celle d'une fonction virtuelle classique. Il est possible de l'appeler depuis n'importe quelle autre fonction :

Résolution dynamique de l'appel d'une fonction virtuelle pure :
Sélectionnez
#include <iostream>

struct base
{
   void call_function()
   {
      function();
   }
   virtual void function()=0;
   
   virtual ~base(){}

};
struct derived :public base
{
   virtual void function()
   {
      std::cout<<"derived::function\n";
   }
};

int main()
{
   derived d;
   std::cout<<"\nappels pour derived d; : \n";
   d.function();
   d.call_function();

   base &rd = d;
   std::cout<<"\nappels pour base &rd = d; : \n";
   rd.function();
   d.call_function();

   return 0;
}

La résolution de l'appel utilise le type dynamique pour choisir la fonction à dérouler. Il s'agit donc de la version la plus spécialisée dans la classe la plus dérivée.

Comme nous avons vu que le type dynamique d'un objet ne peut jamais être celui d'une classe abstraite, il est aisé de comprendre la conclusion suivante :

La définition d'une fonction virtuelle pure dans une classe abstraite ne sera jamais exécutée par une résolution dynamique d'un appel.

Le seul moyen pour que cette définition soit appelée est de forcer l'appel en indiquant explicitement la classe abstraite dans l'appel :

Forcer l'appel à la définition de la classe de base :
Sélectionnez
#include <iostream>

struct base
{
   virtual void function() const =0;
   virtual ~base(){}
};
void base::function() const
{
   std::cout<<"Appel de base::function\n";
}

struct derived :public base
{
   virtual void function() const
   {
      std::cout<<"Appel de derived::function\n";
      base::function(); // appel explicite de la classe parent
   }
};

void call_function(base const &b_)
{
   b_.function();
}

int main()
{
   derived d;
   call_function(d);
   return 0;
}

Si la fonction virtuelle pure de la classe abstraite de base n'a pas de définition, alors la compilation échoue sur une erreur de lien :

Echec à l'édition de lien si la classe abstraite n'a pas de définition :
Sélectionnez
#include <iostream>

struct base
{
   virtual void function() const =0;
   
   virtual ~base(){}
};

struct derived :public base
{
   virtual void function() const
   {
      std::cout<<"Appel de derived::function\n";
      base::function(); // appel explicite de la classe parent : erreur de lien
   }
};

void call_function(base const &b_)
{
   b_.function();
}

int main()
{
   derived d;
   call_function(d);
   return 0;
}

Le code précédent produit une erreur de lien.

On peut se demander pourquoi devrait-on avoir besoin de définir une fonction virtuelle pure dans la classe abstraite si elle n'est jamais implicitement appelée par la résolution dynamique. Mon opinion est qu'une telle définition est souvent une erreur de conception. Voyons les différents cas qui semblent nécessiter une définition dans la classe abstraite et regardons comment les contourner :

  • La classe de base propose un comportement par défaut : si tel est le cas, alors c'est que la classe dérivée peut très bien n'utiliser que ce comportement par défaut et ne pas avoir de comportement spécifique. Dans ce cas, la fonction peut être uniquement virtuelle (pas pure), les classes dérivées n'ayant pas de comportement spécifique ne spécialisent pas la fonction virtuelle.
    Avec une fonction virtuelle pure : Une autre approche :
    Une fonction virtuelle pure avec un comportement par défaut : pourquoi faire ?
    Sélectionnez
    #include <iostream>
    
    struct base
    {
       virtual void function()=0;
       
       virtual ~base(){}
    };
    void base::function()
    {
       std::cout<<"Comportement par defaut\n";
    }
    
    struct derived :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise\n";
       }
    };
    
    struct derived_2 :public base
    {
       virtual void function()
       {
          base::function();
       }
    };
    Une fonction virtuelle suffit !
    Sélectionnez
    #include <iostream>
    
    struct base
    {
       virtual void function()
       {
          std::cout<<"Comportement par defaut\n";
       }
       
       virtual ~base(){}
    };
    
    struct derived :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise\n";
       }
    };
    
    struct derived_2 :public base
    {
       // pas de spécialisation de function : utilise la version par défaut
    };


  • La classe de base factorise une partie du comportement : je préfère alors sortir le comportement factorisé dans une autre fonction spécifique protégée et que seules les classes dérivée peuvent alors appeler. Les responsabilités sont séparées et le code est plus clair.
    Avec une fonction virtuelle pure : Une autre approche :
    Une fonction virtuelle pure avec un comportement factorisé : pourquoi faire ?
    Sélectionnez
    #include <iostream>
    
    struct base
    {
       virtual void function()=0;
       
       virtual ~base(){}
    };
    void base::function()
    {
       std::cout<<"Comportement frequent\n";
    }
    
    struct derived :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise ET \n";
          base::function();
       }
    };
    
    struct derived_2 :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise ET \n";
          base::function();
       }
    };
    
    struct derived_3 :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise uniquement \n";
       }
    };
    Une fonction virtuelle suffit !
    Sélectionnez
    #include <iostream>
    
    struct base
    {
       virtual void function()=0;
       protected :
       void specific_recurrent_point()
       {
          std::cout<<"Comportement frequent\n";
       }
       
       virtual ~base(){}
    };
    
    struct derived :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise ET \n";
          specific_recurrent_point();
       }
    };
    
    struct derived_2 :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise ET \n";
          specific_recurrent_point();
       }
    };
    
    struct derived_3 :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise uniquement \n";
       }
    };


  • Une partie doit toujours être déroulée lors de l'exécution de la fonction : si une partie doit toujours être déroulée, et que cette partie est dans le code d'une fonction virtuelle, alors on prend le risque qu'un jour une classe dérivée 'oublie' de faire l'appel vers la classe de base. Ce genre de bug arrive vite dès qu'un projet est conséquent : on oublie ce petit détail lorsqu'on doit intervenir dans l'urgence dans le code plusieurs mois après son développement, un nouveau venu n'aura pas forcément l'information (surtout si l'héritage est complexe, même une documentation dans le code pourra échapper au développeur). Je préfère pour plus de tranquillité définir une fonction non virtuelle qui déroule le code qui doit être appelée et utilise une fonction virtuelle pure et sans définition pour les parties qui doivent être spécialisée (voir le pattern N.V.I.) :
    Avec une fonction virtuelle pure : Une autre approche :
    Une fonction virtuelle pure avec un comportement obligatoire : le pattern N.V.I. ?
    Sélectionnez
    struct base
    {
       virtual void function()=0;
    
       virtual ~base(){}
    };
    void base::function()
    {
       std::cout<<"Comportement necessaire\n";
    }
    
    struct derived :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise ET \n";
          base::function();
       }
    };
    
    struct derived_2 :public base
    {
       virtual void function()
       {
          std::cout<<"Comportement specialise ET \n";
          base::function();
       }
    };
    Une fonction virtuelle suffit !
    Sélectionnez
    #include <iostream>
    
    struct base
    {
       void function()
       {
          pre_function();
          do_function();
          post_function();
       }
    
       virtual ~base(){}
    
       private:
       void pre_function()
       {
          std::cout<<"Comportement necessaire avant\n";
       }
       void post_function()
       {
          std::cout<<"Comportement necessaire apres\n";
       }
       virtual void do_function()=0;
    
    };
    
    struct derived :public base
    {
       private :
       virtual void do_function()
       {
          std::cout<<"Comportement specialise\n";
       }
    };
    
    struct derived_2 :public base
    {
       virtual void do_function()
       {
          std::cout<<"Comportement specialise 2\n";
       }
    };


Il existe une seule bonne raison de déclarer une fonction virtuelle pure avec une définition : un destructeur virtuel pur.

XI-C. Un destructeur virtuel pur

Le destructeur comme toute fonction virtuelle peut être une fonction virtuelle pure. La différence est qu'un destructeur virtuel pur DOIT avoir une définition. En effet, le destructeur étant toujours appelé lors de la destruction de l'objet, il doit avoir un corps même vide. Ou dit autrement, les destructeurs doivent toujours avoir une définition qu'ils soient virtuels ou non, cette définition étant soit donnée par le code soit implicitement construite par le compilateur dans le cas des destructeurs implicites.
Définir un destructeur virtuel pur est parfois la seule façon de définir une classe abstraite si les autres fonctions n'ont pas lieu d'être virtuelle pure. Comme la définition d'un corps est obligatoire et que son appel est implicite, cela ne pose pas de problème.

Un destructeur virtuel pur pour une classe abstraite :
Sélectionnez
struct base
{
   virtual ~base()=0; // la classe est abstraite car le destructeur est virtuel pur
};
base::~base()
{ // la définition du destructeur est obligatoire même s'il est pur.
}

En C++0x, le mot-clé =default est une définition de fonction. Comme une fonction virtuelle pure ne peut être définie dans la classe, cela doit se faire à l'extérieur :

Destructeur virtuel par défaut en C++0x :
Sélectionnez
struct base
{
   virtual ~base()=0;
};
base::~base()=default; // définition à l'extérieur de la classe

XI-D. Appel d'une fonction virtuelle pure dans le constructeur/destructeur d'une classe abstraite

La norme est catégorique pour les appels des fonctions virtuelles pures dans le constructeur ou le destructeur :

L'appel d'une fonction virtuelle pure dans le constructeur ou le destructeur d'une classe abstraite produit un comportement indéterminé !

Cette règle tombe sous le bon sens. Rappelez-vous une fonction virtuelle pure peut ne pas avoir de définition dans une classe abstraite. Dès lors, la règle pour les fonctions virtuelles stipulant que c'est la fonction définie dans le constructeur en cours de construction qui est appelée ne peut être étendue puisque cette définition peut ne pas exister. Afin de ne pas créer d'ambiguïté, la règle ci-dessus qui a l'avantage de la simplicité a été introduite dans la norme : l'appel d'une fonction virtuelle pure dans une classe abstraite provoque un comportement indéterminé.
Si cette règle a l'avantage de la simplicité est à l'inconvénient qu'un comportement indéterminé peut révéler des surprises. Regardons ainsi comment cette règle est mise en oeuvre dans 2 compilateurs différents : Visual C++ et GCC.

Prenons un premier exemple simple : une fonction virtuelle pure sans définition dans la classe abstraite appelée directement dans le constructeur (le comportement est le même si l'appel a lieu dans le destructeur)

Appel direct d'une fonction virtuelle pure sans définition dans un constructeur
Sélectionnez
#include <iostream>
struct base
{
   base()
   {
      do_init();
   }
   virtual void do_init()=0;
   virtual ~base(){}
};

struct derived : public base
{
   derived()
   {
   }
   virtual void do_init()
   {
      std::cout<<"derived::do_init\n";
   }
};
int main()
{
   derived d;
   return 0;
}

A la compilation, Visual C++ est muet comme une carpe, mais GCC nous met la puce à l'oreille :

warning : abstract virtual 'virtual void base::do_init()' called from constructor

Mais le point le plus intéressant et sur lesquels les deux compilateurs se rejoignent est l'édition de lien : Visual C++ et GCC provoquent une erreur de lien :

Visual C++ : symbole externe non résolu "public: virtual void __thiscall base::do_init(void)"
GCC : undefined reference to `base::do_init()'

Il est donc impossible de produire un code avec un tel comportement indéterminé !
Cette erreur à l'édition de lien se comprend au regard de l'optimisation des appels des fonctions virtuelles dans le constructeur (resp. destructeur). Cette optimisation permet une résolution statique de l'appel par le compilateur. Il insère donc tout simplement un appel vers base::do_init dans le constructeur. Or comme cette fonction n'est définie nulle part, l'édition de lien échoue. C'est bien un appel vers base::do_init qui est tenté et non vers derived::do_init car la règle stipule bien que le compilateur doit considérer le constructeur en cours d'exécution.

Un second exemple plus embêtant. Considérons maintenant l'appel indirect d'une fonction virtuelle pure dans le constructeur (comme toujours, le comportement est le même dans le destructeur) :

Appel indirect d'une fonction virtuelle pure sans définition dans un constructeur
Sélectionnez
#include <iostream>
struct base
{
   base()
   {
      init();
   }
   void init()
   {
      do_init();
   }
   virtual void do_init()=0;
   virtual ~base(){}
};

struct derived : public base
{
   derived()
   {
   }
   virtual void do_init()
   {
      std::cout<<"derived::do_init\n";
   }
};
int main()
{
   derived d;
   return 0;
}

Effet démo garanti : ce code provoque une erreur à l'exécution avec les deux compilateurs. Ils ont l'amabilité d'indiquer l'erreur de façon très explicite :

GCC : pure virtual method called
Visual C++ : R6025
- pure virtual function call

L'appel indirect d'une fonction virtuelle pure dans un constructeur ou un destructeur d'une classe abstraite provoque une erreur à l'exécution avec Visual C++ et GCC.

Un troisième exemple : appelons directement une fonction virtuelle pure dans une classe abstraite mais en ayant défini cette fonction :

Appel direct d'une fonction virtuelle pure avec définition dans un constructeur
Sélectionnez
#include <iostream>
struct base
{
   base()
   {
      do_init();
   }
   virtual void do_init()=0;
   virtual ~base(){}
};
void base::do_init()
{
   std::cout<<"base::do_init\n";
}

struct derived : public base
{
   derived()
   {
   }
   virtual void do_init()
   {
      std::cout<<"derived::do_init\n";
   }
};

int main()
{
   derived d;
   return 0;
}

Surprise ! Ce code compile avec Visual C++ et GCC (modulo l'avertissement de l'appel d'une fonction virtuelle pure dans le constructeur) sans problème et s'exécute sans planter.
L'explication est analogue à notre premier exemple : les compilateurs ont résolu l'appel statiquement et donc inséré un appel directement vers base::do_init. Cette fonction étant cette fois-ci bien définie, l'édition de lien a pu aboutir. A l'exécution, la fonction a tout simplement été déroulée.

Enfin, un dernier exemple : considérons l'appel indirect d'une fonction virtuelle pure avec une définition dans la classe abstraite.

Appel indirect d'une fonction virtuelle pure avec définition dans un constructeur
Sélectionnez
#include <iostream>
struct base
{
   base()
   {
      init();
   }
   void init()
   {
      do_init();
   }
   virtual void do_init()=0;
   virtual ~base(){}
};
void base::do_init()
{
   std::cout<<"base::do_init\n";
}

struct derived : public base
{
   derived()
   {
   }
   virtual void do_init()
   {
      std::cout<<"derived::do_init\n";
   }
};

int main()
{
   derived d;
   return 0;
}

Ici, nous retrouvons le comportement observé dans le second exemple à savoir un plantage à l'exécution indiquant l'appel d'une fonction virtuelle pure dans le constructeur :

GCC : pure virtual method called>
Visual C++ : R6025
- pure virtual function call

Soit pour résumer, nous avons avec Visual C++ et GCC les comportements suivants pour un appel de fonction virtuelle pure dans un constructeur ou un destructeur :

Comportement des compilateurs GCC et Visual C++ lors de l'appel d'une fonction virtuelle pure dans un constructeur ou un destructeur de la classe abstraite
  Fonction virtuelle pure sans définition Fonction virtuelle pure avec définition
appel direct erreur de lien OK
appel indirect erreur à l'exécution erreur à l'exécution


Ceci avait pour seul but de montrer qu'un comportement déclaré comme indéterminé par la norme se révèle effectivement varié et subtil. Vos codes ne doivent pas transiger avec cette règle élémentaire :

Une classe abstraite ne doit pas appeler de fonction virtuelle pure directement ou indirectement dans son constructeur ou son destructeur.

XI-E. Fonctions virtuelles pures et constructeur/destructeur des classes concrètes

A partir du moment où une fonction virtuelle pure reçoit une spécialisation dans une classe concrète, alors elle se comporte comme une fonction virtuelle classique. La fonction appelée est celle du constructeur en train de s'exécuter et non de l'objet en train d'être construit :

Appel d'une fonction virtuelle pure dans une classe concrète l'ayant spécialisée
Sélectionnez
#include <iostream>
struct base
{
   base()
   {}
   void init()
   {
      do_init();
   }
   virtual void do_init()=0;
   virtual ~base(){}
};

struct derived : public base
{
   derived()
   {
      init();
   }
   virtual void do_init()
   {
      std::cout<<"derived::do_init\n";
   }
};

struct derived_2 : public derived
{
   derived_2()
   {
      init();
   }
   virtual void do_init()
   {
      std::cout<<"derived_2::do_init\n";
   }
};

int main()
{
   derived_2 d;
   return 0;
}

Si init est bien une fonction de la classe abstraite base, l'appel effectif à init se fait bien dans le constructeur des classes concrètes derived et derived_2. Il ne faut pas tenir compte de la classe qui fait l'appel mais bien du constructeur (resp. destructeur) qui est en train de se dérouler.

Il est possible d'appeler une fonction virtuelle pure dans le constructeur ou le destructeur d'une classe concrète l'ayant spécialisée. Le comportement est le même que pour une fonction virtuelle classique : c'est la version du constructeur/destructeur en train d'être exécuté qui est appelée et non celle de l'objet en train d'être construit.

XII. L'operateur d'affectation : operator=

L'opérateur d'affectation peut être déclaré virtuel comme toutes les autres fonctions. On distingue deux opérateurs d'affectation : l'opérateur de 'conversion' et l'opérateur de 'copie'.
Le premier prend en paramètre un type différent de la classe pour laquelle il est défini :

Opérateur = avec sémantique de conversion :
Sélectionnez
struct a_type
{
   a_type& operator=(int i_);
};

Il permet d'initialiser tout ou partie d'un objet d'un type donné avec la valeur d'un autre type. Définir comme virtuel et le spécialiser au besoin dans les classes dérivées ne posent pas de problèmes particuliers pour ce type d'affectation.
L'opérateur d'affectation avec une sémantique de copie prend en argument le même type que la classe pour laquelle il est défini :

Opérateur = avec sémantique de copie :
Sélectionnez
struct a_type
{
   a_type& operator=(a_type rhs_);
};

D'ailleurs, pour ne laisser aucune confusion sur le fait qu'il s'agit bien d'un opérateur de copie, on utilise souvent l'idiome copy and swapComment écrire un opérateur d'affectation correct ? :

Copy and swap :
Sélectionnez
struct a_type
{
   a_type& operator=(a_type rhs_) // la copie est fait lors du passage de l'argument
   {
      std::swap(membre_1, rhs_.membre_1); // swap des membres 
      std::swap(membre_2, rhs_.membre_2);
      /*[...] */      
      std::swap(membre_n, rhs_.membre_n);
      
      return *this;
   }
};

L'opérateur d'affectation de copie d'une classe virtuelle ne spécialise pas celui de la classe de base :

Ceci n'est pas une spécialisation :
Sélectionnez
#include <iostream>

struct base
{
   virtual base& operator=(base)
   {
      std::cout<<"base::operator=\n";
      return *this;
   }
   virtual ~base(){}
};

struct derived : public base
{
   virtual derived& operator=(derived)
   {
      std::cout<<"derived::operator=\n";
      return *this;
   }
};

int main()
{
   base b1;
   base b2;
   b1 = b2; // base::operator=
   derived d1;
   derived d2;
   d1 = d2; // derived::operator=

   base &rd1 = d1;
   base &rd2 = d2;
   rd1 = rd2; // base::operator=
   rd1 = d1;  // base::operator=
   return 0;
}

Pourquoi ? Tout simplement car les signatures sont différentes dans la classe dérivée et la classe de base. La première prend en argument un type base. La seconde prend en argument un type derived. La résolution dynamique ne se fait que sur l'objet appelant l'opérateur, pas sur l'argument.
Pour avoir une spécialisation, il faut que la classe dérivée prenne en argument un base :

Spécialiser l'opérateur de base ?
Sélectionnez
#include <iostream>

struct base
{
   virtual base& operator=(base)
   {
      std::cout<<"base::operator=\n";
      return *this;
   }
   virtual ~base(){}
};

struct derived : public base
{
   virtual derived& operator=(derived)
   {
      std::cout<<"derived::operator=(derived)\n";
      return *this;
   }
   virtual derived& operator=(base) // spécialisation la version de la classe de base
   {
      std::cout<<"derived::operator=(base)\n";
      return *this;
   }
};

int main()
{
   base b1;
   base b2;
   b1 = b2; // base::operator=
   derived d1;
   derived d2;
   d1 = d2; // derived::operator=  (1)

   base &rd1 = d1;
   base &rd2 = d2;
   rd1 = rd2; // base::operator= (2)
   rd1 = d1;  // base::operator= (3)
   return 0;
}

Certes, maintenant la classe spécialise bien l'opérateur défini dans la classe de base. Mais cette solution ne peut être satisfaisante. En effet, dans le cas (1) et dans les cas (2) et (3), on souhaite affecter à un objet derived un autre objet derived. Mais passer par une référence de la classe de base nous fait perdre l'information sur le type effectif de l'argument. On a donc deux appels différents selon (1) ou selon (1) et (2).
Quelle conclusion tirer ? En fait l'opérateur d'affectation de copie met en évidence un problème plus fondamental en conception objet concernant les classes servant à l'héritage et utilisant les fonctions virtuelles. Ces classes ont souvent une sémantique d'entitéQuand est-ce qu'une classe a une sémantique de d'entité ?. Et une sémantique d'entité, comme on le voit, se marie mal avec une sémantique de copie.


précédentsommairesuivant
Un comportement indéterminé est un comportement non spécifié par la norme. En conséquence, les compilateurs peuvent le gérer comme ils le souhaitent : par une erreur ou un avertissement à la compilation, par une erreur à l'exécution ou un fonctionnement à priori conforme. Il ne faut pas invoquer de comportements indéterminés même s'ils ont l'air de fonctionner car un tel comportement peut changer silencieusement (au moins jusqu'au bug) dès que l'on change de compilateur ou de version de compilateur.
En fait, on peut : mais au prix de transtypage sauvage, d'opérateurs de conversion ou de retour de fonction. Toute solution qui doit mettre la puce à l'oreille du développeur : ceci est potentiellement dangereux donc probablement une erreur !
Pour être exact, en l'absence de directive de compilation explicite, le compilateur décide seul si l'appel est inliné ou pas éventuellement sans tenir compte des desiderata du codeur. En fait le mot clé inline a une autre utilité plus technique : détourner la règle O.D.R. (One Definition Rule) en particulier pour les templates.
inline et rapidité ne sont pas aussi mécaniques qu'il y paraît : voir l'entrée de la F.A.Q. Les fonctions inline améliorent-elles les performances ?Les fonctions inline améliorent-elles les performances ?
Les codes de cet article ont été testés avec Visual C++ Express 2008 et GCC 4.4.1. (MinGW) sous Windows. Quand on parlera de Visual C++, ce sera sauf mention complémentaire pour cette édition Express. Et quand on parlera de GCC toujours sauf mention complémentaire, cela désignera GCC 4.4.1. (MinGW).

Copyright © 2009 3DArchi. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.