V. Première conséquence : comment bien déclarer son destructeur▲
Prenons maintenant l'exemple suivant :
#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 :
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 :
#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 :
#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 :
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 :
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 :
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.
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 :
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 :
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 :
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 :
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 :
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 :
#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 :
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 :
#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 :
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 :
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 :
#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 :
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 :
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.
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 :
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 :
#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 :
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) :
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 :
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 :
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 :
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 :
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 :
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 :
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 :
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 :
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 :
#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<<
"
\n
appels pour derived d; :
\n
"
;
d.function();
d.call_function();
base &
rd =
d;
std::
cout<<
"
\n
appels 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 :
#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 :
#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électionnezstruct
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.
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 :
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)
#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) :
#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 :
#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.
#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 :
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 :
#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 :
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 :
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 ? :
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 :
#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 :
#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.