Les fonctions virtuelles en C++ :
Types statiques et types dynamiques
Date de publication : 19 novembre 2009
Par
3DArchi (Ressources proposées par 3DArchi)
Les fonctions virtuelles sont un des piliers de la programmation
orientée objet. En favorisant l'abstraction, elles permettent
la construction d'architectures logicielles stables et évolutives. Cet
article se propose d'explorer les fonctions virtuelles dans le langage
C++ en abordant aussi bien les problèmes syntaxiques que les conséquences
sémantiques de leur utilisation.
Vous pouvez lire cet article sur
une seule page.
Vous pouvez lire cet article sur
une version multi-page.
Commentaires, conseils et réactions dans cette discussion :
18 commentaires
I. Les fonctions membres en C++
I-A. Les fonctions membres statiques
I-B. Les fonctions normales
I-C. Les fonctions virtuelles
I-D. La surcharge de fonction
I-E. Quand l'héritage chamboule tout !
II. Type statique et type dynamique
III. Types et appel de fonction
IV. A quoi servent les fonctions virtuelles ?
V. Première conséquence : comment bien déclarer son destructeur
VI. Seconde conséquence : inlining de fonctions et fonctions virtuelles
VII. Quand est construit le type dynamique ou quel est l'impact
des appels de fonctions virtuelles dans un constructeur ?
VIII. Que devient le type dynamique lors de la destruction de l'objet ou
peut-on appeler des fonctions virtuelles dans un destructeur ?
IX. Construction, destruction, fonctions virtuelles et multithreading
X. Une vision plus avancée des appels de fonctions virtuelles
dans les constructeurs et destructeurs
XI. Et pour les fonctions virtuelles pures ?
XI-A. Fonctions virtuelles pures, classes abstraites, classes concrètes
XI-B. Appel d'une fonction virtuelle pure
XI-C. Un destructeur virtuel pur
XI-D. Appel d'une fonction virtuelle pure dans le constructeur/destructeur d'une classe abstraite
XI-E. Fonctions virtuelles pures et constructeur/destructeur des classes concrètes
XII. L'operateur d'affectation : operator=
XIII. Le retour covariant des fonctions virtuelles
XIV. Forcer un appel spécifique d'une fonction virtuelle
XV. Fonctions virtuelles et visibilité
XVI. Fonction virtuelle et masquage de fonction
XVI-A. Masquage d'une fonction virtuelle par une fonction non virtuelle
XVI-B. Masquage d'une fonction non virtuelle par une fonction virtuelle
XVI-C. Des fonctions pas totalement masquée.
XVI-D. Ramener un symbole : using
XVI-E. Que conclure ?
XVII. Fonctions virtuelles et fonctions génériques (template)
XVII-A. Fonctions template
XVII-B. Fonctions virtuelles dans des classes génériques
XVIII. Fonctions virtuelles et amitié (friend)
XIX. Fonctions virtuelles et spécification d'exceptions
XIX-A. Rappel sur les exceptions
XIX-B. Exceptions et hiérarchie de classes
XIX-C. Les exceptions d'une fonction virtuelle
XX. Fonctions virtuelles et programmation par contrat
XX-A. Un rapide rappel
XX-B. Le principe de substitution de Liskov
XX-C. Impact sur les invariants pour une fonction virtuelle
XX-D. Impact sur les préconditions pour une fonction virtuelle
XX-E. Impact sur les postconditions pour une fonction virtuelle
XXI. Le pattern N.V.I.
XXII. Informations sur les types (dynamiques et statiques) et conversions
XXII-A. Types polymorphes
XXII-B. Comment connaître le type dynamique d'une variable ?
XXII-B-1. L'opérateur typeid
XXII-B-2. Evaluation de l'expression
XXII-B-3. La classe type_info
XXII-B-4. Pourquoi récupérer le type dynamique ?
XXII-C. Comment connaître le type statique d'une variable ?
XXII-D. Conversions entre type de base et type dérivé
XXII-D-1. Conversion du type dérivé vers le type de base
XXII-D-2. Conversion du type de base vers le type dérivé
XXII-D-3. Pourquoi faire une conversion d'un type de base vers un type dérivé
XXIII. Comment ça marche ?
XXIII-A. Qui définit la mise en oeuvre ?
XXIII-B. Résoudre l'appel dynamiquement : les tables virtuelles
XXIII-C. Quelle entrée pour les fonctions virtuelles pures ?
XXIII-D. Comment sont construites les tables virtuelles ?
XXIII-E. Comment sont détruites les tables virtuelles ?
XXIII-F. Qu'est-ce qu'un pointeur de fonction virtuelle ?
XXIV. A retenir
XXV. Un peu de lecture
XXVI. Remerciements
I. Les fonctions membres en C++
Trois types de fonctions peuvent être définis dans une classe
(1)
en C++ :
- les fonctions membres statiques ;
- les fonctions membres normales ;
- les fonctions membres virtuelles.
I-A. Les fonctions membres statiques
Le mot-clé static utilisé en début du prototype
de la fonction permet de déclarer une fonction membre
statique ou encore fonction de classe :
Exemple de fonction membre statique : |
struct my_type
{
static void s_function ();
} ;
|
Une fonction membre statique n'est pas liée à un objet. Elle n'a donc
pas de paramètre implicite this et ne peut donc accéder
à d'autres membres d'instances de la classe sinon les membres statiques :
Appel d'une fonction membre statique : |
# include <iostream>
struct my_type
{
static void s_function ();
static int mi_class_variable;
int mi_member_variable;
} ;
int my_type:: mi_class_variable= 0 ;
void my_type:: s_function ()
{
std:: cout< < " je suis une fonction membre statique !\n " ;
mi_class_variable = 5 ;
}
int main ()
{
my_type:: s_function ();
my_type var;
var.s_function ();
return 0 ;
}
|
|
L'appel d'une fonction membre statique d'une classe
ne dépend pas d'une instance de ce type.
|
Par conséquent, les fonctions membres statiques ne
nous intéresseront pas par la suite car elles ne
dépendent nullement d'un objet.
I-B. Les fonctions normales
Les fonctions normales n'ont pas de mot-clé spécifique
pour leur déclaration : c'est le comportement par défaut
d'une fonction membre d'une classe.
Exemple de fonction membre normale : |
# include <iostream>
struct my_type
{
void a_function ();
} ;
void my_type:: a_function ()
{
std:: cout< < " je suis une fonction normale !\n " ;
}
int main ()
{
my_type var;
var.a_function ();
return 0 ;
}
|
Ces fonctions membres ont un pointeur this
désignant l'objet au départ duquel elles ont été invoquées et peuvent
accéder aux membres de cet objet :
Appel d'une fonction normale : |
# include <iostream>
struct my_type
{
void a_function ();
int mi_member;
} ;
void my_type:: a_function ()
{
std:: cout< < " je suis une fonction normale !\n " ;
this - > mi_member = 41 ;
mi_member+ + ;
}
int main ()
{
my_type var;
var.a_function ();
std:: cout< < " valeur de mi_member de var : " < < var.mi_member< < " \n " ;
return 0 ;
}
|
I-C. Les fonctions virtuelles
Les
fonctions virtuelles précèdent leur déclaration du mot
clé
virtual
(2)
:
Exemple d'une fonction virtuelle : |
# include <iostream>
struct my_type
{
virtual void a_function ();
} ;
void my_type:: a_function ()
{
std:: cout< < " je suis une fonction virtuelle !\n " ;
}
int main ()
{
my_type var;
var.a_function ();
return 0 ;
}
|
Comme les fonctions membres normales, les fonctions virtuelles
ont un pointeur this et peuvent accéder aux
membres de cet objet :
Appel d'une fonction virtuelle : |
# include <iostream>
struct my_type
{
virtual void a_function ();
int mi_member;
} ;
void my_type:: a_function ()
{
std:: cout< < " je suis une fonction normale !\n " ;
this - > mi_member = 41 ;
mi_member+ + ;
}
int main ()
{
my_type var;
var.a_function ();
std:: cout< < " valeur de mi_member de var : " < < var.mi_member< < " \n " ;
return 0 ;
}
|
Soit en résumé :
|
L'appel d'une fonction membre non statique d'une classe nécessite
une instance du type.
|
|
Par défaut, en C++, les fonctions membres ne sont pas virtuelles.
Le mot-clé virtual est nécessaire pour définir
une fonction virtuelle.
|
|
En C++, les fonctions virtuelles doivent être membres d'une
classe.
|
Anticipons en signalant l'existence d'une catégorie particulière
de fonctions virtuelles en C++ : les fonctions virtuelles
pures. Une fonction virtuelle pure est une fonction virtuelle
à laquelle est rajoutée =0 à la fin de sa
déclaration :
Exemple d'une fonction virtuelle pure : |
struct my_type
{
virtual void a_function ()= 0 ;
} ;
|
Nous reviendrons un peu plus loin sur les fonctions virtuelles
pures, pour l'instant il suffit de savoir que ça existe et qu'avant
d'être "pures", ce sont avant tout des fonctions virtuelles.
Toutes les fonctions d'une classe peuvent-elles être
virtuelles ? Oui, efin presque : seul les constructeurs (et les
fonctions statiques) ne peuvent pas être virtuels. Toutes les
autres fonctions le peuvent : que ce soit le destructeur,
les opérateurs, ou des fonctions quelconques. Nous verrons
par la suite ce que cela signifie et l'intérêt dans chaque
cas.
Toutes (ou presque) les fonctions peuvent être virtuelles |
struct my_type
{
virtual ~ my_type ();
virtual void function ();
virtual my_type& operator + (my_type const & );
} ;
|
La virtualité s'hérite : une fonction virtuelle dans
la classe de base reste virtuelle dans la classe dérivée
même si le mot clé virtual n'est pas
accolé :
La virtualité s'hérite : |
struct base
{
void function_1 ();
virtual void function_2 ();
void function_3 ();
} ;
struct derived : public base
{
void function_1 ();
void function_2 ();
virtual void function_3 ();
} ;
|
Nous avons avec cet exemple :
|
base |
derived |
function_1 |
non virtuelle |
non virtuelle |
function_2 |
virtuelle |
virtuelle |
function_3 |
non virtuelle |
virtuelle |
Autant comme le montre la troisième ligne, il est possible
de masquer une fonction non virtuelle d'une classe
de base par une fonction virtuelle dans une classe
dérivée, autant il est impossible de s'en débarrasser.
Une fonction est et sera virtuelle pour toutes les classes
dérivant de la classe l'ayant définie comme telle.
Cependant, afin d'éviter toute confusion, il est fortement
recommandé d'utiliser le mot-clé virtual
dans les classes dérivées :
|
Les classes dérivées devraient utiliser le mot-clé
virtual pour les fonctions définies
comme virtuelles dans la classe de base.
|
I-D. La surcharge de fonction
Introduisons un dernier point pour poser notre problématique :
la surcharge de fonction. Il est aussi possible
de surcharger une fonction dans une classe, c'est à dire
définir plusieurs fonctions avec le même nom,
à condition qu'elles diffèrent par :
- leur nombre d'arguments ;
- et/ou le type d'au moins un des arguments ;
- et/ou leur constance.
La signature d'une fonction en C++ désigne son nom,
le nombre de ses arguments, leur type et la constance de
la fonction. Surcharger une fonction F1 revient alors à proposer
une nouvelle fonction F2, telle que la signature de F1 est
différente de celle de F2 autrement que par le nom qu'elles
partagent.
Par exemple, le code suivant présente différentes surcharges
d'une fonction membre :
Surcharges d'une fonction membre : |
struct my_type
{
void function (double ,double ){ }
void function (double ){ }
void function (int ){ }
void function (int ) const { }
void function (char & ){ }
void function (char const & ){ }
} ;
|
La surcharge est un cas de polymorphisme en C++
(3). Cela permet d'adapter la fonction à appeler
selon les arguments en paramètre :
Appels des différentes surcharges d'une fonction membre : |
# include <iostream>
struct my_type
{
void function (double ,double )
{
std:: cout< < " (1)\n " ;
}
void function (double )
{
std:: cout< < " (2)\n " ;
}
void function (int )
{
std:: cout< < " (3)\n " ;
}
void function (int ) const
{
std:: cout< < " (4)\n " ;
}
void function (char & )
{
std:: cout< < " (5)\n " ;
}
void function (char const & )
{
std:: cout< < " (6)\n " ;
}
} ;
int main ()
{
my_type var;
var.function (1 .,1 .);
var.function (1 .);
var.function (1 );
my_type const c_var= my_type ();
c_var.function (1 );
char c (' a ' );
var.function (c);
char const & rc = c;
var.function (rc);
return 0 ;
}
|
La surcharge a ses limites. En particulier, une même
classe ne peut pas redéfinir une fonction avec le
même nom d'une fonction existante si :
- elles ne diffèrent que par leur type retour ;
- elles ont les mêmes arguments, le même type retour
mais l'une d'elles est statique ;
- elles ont les mêmes arguments, le même type retour,
la même constance mais l'une d'elles est virtuelle (ou virtuelle pure)
et l'autre normale ;
- elles ont les mêmes arguments, le même type retour,
la même constance mais l'une d'elles est virtuelle et
l'autre virtuelle pure ;
- un argument ne diffère qu'à cause d'un typedef ;
- elles ne diffèrent que par un const
non significatif sur le type d'un argument.
- elles ne diffèrent que par les valeurs par défaut
de leur(s) argument(s).
Ainsi les surcharges suivantes sont interdites dans une même
classe :
Surcharges interdites : |
struct my_type
{
void function_1 ();
int function_1 ();
static void function_2 ();
void function_2 ();
void function_2 ()const ;
virtual void function_3 ();
void function_3 ();
virtual void function_3_bis ();
void function_3_bis ()const ;
virtual void function_4 ();
virtual void function_4 ()= 0 ;
void function_5 (int );
typedef int t_int;
void function_5 (t_int);
void function_6 (char );
void function_6 (char const );
void function_6_bis (char * );
void function_6_bis (char * const );
void function_6_ter (char * );
void function_6_ter (char const * );
void function_7 (int );
void function_7 (int = 42 );
} ;
|
I-E. Quand l'héritage chamboule tout !
L'héritage importe dans la classe dérivée toutes les
déclarations des classes de bases
(4)
:
Héritage des membres de la classe parent : |
struct base
{
void function (){ }
} ;
struct derived : base
{
} ;
int main ()
{
derived d;
d.function ();
return 0 ;
}
|
Cependant, une fonction déclarée dans une classe dérivée ayant
le même nom qu'une fonction de la classe de base mais avec
une signature différente masque la fonction de la classe
de base dans la classe dérivée :
Masquage des fonctions de base dans la classe dérivée : |
struct base
{
void function (){ }
} ;
struct derived : base
{
void function (int ){ }
void call_a_function ()
{
function ();
base:: function ();
function (1 );
}
} ;
int main ()
{
derived d;
d.function ();
d.base:: function ();
d.function (1 );
return 0 ;
}
|
|
La surcharge dans une classe dérivée d'une fonction définie
dans une classe de base avec une signature différente
masque la fonction de la classe de base dans et pour
la classe dérivée.
|
Nous avons vu à la section précédente qu'il n'était pas
possible dans une même classe de redéfinir une nouvelle
fonction avec la même signature qu'une fonction existante
(même nom, même paramètres, même constance). Et, nous en
arrivons au point qui va nous intéresser, à savoir :
|
Une classe dérivée peut redéfinir une fonction d'une classe
de base ayant la même signature !
|
Ainsi, en reprenant dans la section précédente
l'exemple des surcharges
interdites et en recopiant les surcharges
interdites vers la classe dérivée, nous obtenons le
code valide suivant :
Tout redevient possible avec l'héritage : |
struct base
{
void function_1 ();
static void function_2 ();
virtual void function_3 ();
virtual void function_4 ();
void function_5 (int );
void function_6 (char );
void function_7 (int );
} ;
struct derived : public base
{
int function_1 ();
void function_2 ();
void function_2 ()const ;
void function_3 ();
virtual void function_4 ()= 0 ;
typedef int t_int;
void function_5 (t_int);
void function_6 (char const );
void function_7 (int = 42 );
} ;
|
L'objectif est maintenant de savoir
quelles fonctions sont appelées lorsqu'une même signature
est disponible dans une classe dérivée et une classe de base
selon l'expression utilisée pour l'appel :
Quelle est la fonction appelée ? |
struct base
{
void function_1 ()
{
}
virtual void function_2 ()
{
}
void call_function_1 ()
{
function_1 ();
}
void call_function_2 ()
{
function_2 ();
}
} ;
struct derived : public base
{
void function_1 ()
{
}
virtual void function_2 ()
{
}
void call_function_1 ()
{
function_1 ();
}
void call_function_2 ()
{
function_2 ();
}
} ;
int main ()
{
base b;
b.function_1 ();
b.function_2 ();
derived d;
d.function_1 ();
d.function_2 ();
base & rb = b;
rb.function_1 ();
rb.function_2 ();
base & rd = d;
rd.function_1 ();
rd.function_2 ();
return 0 ;
}
|
Pour arriver à répondre à toutes ces questions, il nous
faut introduire une nouvelle notion : le type
statique et le type dynamique d'une variable.
II. Type statique et type dynamique
Une variable possède deux types : un type statique et un type
dynamique.
Le type statique d'une variable est celui déterminé à la
compilation. Le type statique est le plus évident : c'est
celui avec lequel vous avez déclaré votre variable. Il est sous
votre nez lorsque vous regardez le code.
Le type dynamique d'une variable est celui déterminé à
l'exécution. Le type dynamique quant à lui n'est pas
immédiat en regardant le code. En effet, il va pouvoir varier
à l'exécution selon ce que la variable va effectivement désigner
pendant le déroulement du programme.
Le type dynamique et le type statique coïncident pour les variables
utilisées par valeur :
Type statique et type dynamique de variables par valeur : |
int a;
char c;
std:: string s;
class a_class{ } ;
a_class an_object;
enum E_enumeration{ } ;
E_enumeration e;
|
Avec cet exemple, on a :
variable |
type statique |
type dynamique |
a |
int |
int |
c |
char |
char |
s |
std::string |
std::string |
an_object |
a_class |
a_class |
e |
E_enumeration |
E_enumeration |
Type statique et dynamique d'une variable par valeur
Encore une fois, l'héritage introduit une différence entre
un type dynamique et un type statique. Cette différence apparaît
avec les pointeurs et les références quand le type déclaré du
pointeur ou de la référence n'est pas le type de l'objet
effectivement pointé (resp. référencé) à l'exécution. Le type
statique est celui défini dans le code, le type dynamique
d'une référence ou d'un pointeur est celui de l'objet
référencé (resp. pointé) :
Type statique, type dynamique, héritage, pointeurs et références : |
struct base { } ;
struct derived : public base { } ;
int main ()
{
base b;
derived d;
base & rb = b;
base & rd = d;
base * pb = & b;
base * pd = & d;
return 0 ;
}
|
Avec l'exemple, ci-dessus, les types des variables
(5)
sont :
variable |
type statique |
type dynamique |
base b |
base |
base |
derived d |
derived |
derived |
base &rb = b |
base |
base |
base &rd = d |
base |
derived |
base *pb = &b |
base |
base |
base *pd = &d |
base |
derived |
Type statique et dynamique d'une variable par référence
|
Seul les pointeurs et les références vers des
instances de classe ou de structure ont des types
dynamiques et des types statiques pouvant diverger.
|
Le type statique d'un objet dans une fonction membre est celui
où se déroule la fonction. Le type dynamique, this
étant un pointeur, dépend de l'objet effectif sur lequel
s'applique la fonction. Type statique et type dynamique
peuvent alors être différents :
Type de this : |
class base
{
public :
void function ()
{
}
} ;
class derived : public base
{
public :
void function_2 ()
{
}
} ;
int main ()
{
base b;
b.function ();
derived d;
d.function ();
d.function_2 ();
return 0 ;
}
|
fonction |
type statique de this |
type dynamique de this |
Pour l'appel b.fonction,
dans la fonction base::fonction
|
base |
base |
Pour l'appel de d.fonction,
dans la fonction base::fonction
|
base |
derived |
Pour l'appel de d.fonction_2
dans la fonction derived::fonction_2
|
derived |
derived |
Type statique et dynamique dans une fonction membre
|
Le type statique d'une variable est soit le type dynamique
de cette variable soit une classe de base directe ou indirecte
du type dynamique.
|
Toute autre combinaison est une erreur pouvant aboutir à un
plantage ou un comportement indéterminé.
Attention, si nous avons dit que le pointeur a un type statique
différent de son type dynamique, cela s'applique aussi bien
au pointeur non déréférencé qu'au pointeur déréférencé :
Types statiques et dynamiques d'un pointeur : |
struct base { } ;
struct derived : public base { } ;
int main ()
{
base b;
derived d;
base * pd = & d;
return 0 ;
}
|
variable |
type statique |
type dynamique |
pd |
base* |
derived* |
*pd |
base |
derived |
Type statique et dynamique d'une variable par référence
III. Types et appel de fonction
Lorsqu'une expression contient un appel d'une fonction
sur un objet donné, la fonction effectivement appelée dépend
de plusieurs paramètres :
- la fonction est-elle virtuelle ou non ?
- la fonction est-elle appelée sur un objet par valeur ou sur une référence/pointeur ?
Selon la réponse, la résolution de l'appel est faite à la
compilation ou à l'exécution :
|
fonction non virtuelle |
fonction virtuelle |
appel sur un objet |
COMPILATION |
COMPILATION |
appel sur une référence ou un pointeur |
COMPILATION |
EXÉCUTION |
Quand est résolu l'appel ?
La résolution à la compilation utilise le type
statique car c'est le seul connu à ce moment.
La résolution à l'exécution se base sur le type
dynamique.
Ce qui donne :
|
fonction non virtuelle |
fonction virtuelle |
appel sur un objet |
type statique
|
type statique
|
appel sur une référence ou un pointeur |
type statique
|
type dynamique
|
Type utilisé pour la résolution de l'appel
Un peu de code pour illustrer tout cela :
Résolution à la compilation ou à l'exécution des appels : |
# include <iostream>
struct base
{
void function_1 ()
{
std:: cout< < " base::function_1\n " ;
}
virtual void function_2 ()
{
std:: cout< < " base::function_2\n " ;
}
} ;
struct derived :public base
{
void function_1 ()
{
std:: cout< < " derived::function_1\n " ;
}
virtual void function_2 ()
{
std:: cout< < " derived::function_2\n " ;
}
} ;
int main ()
{
base b;
std:: cout< < " \nappels pour base b; : \n " ;
b.function_1 ();
b.function_2 ();
derived d;
std:: cout< < " \nappels pour derived d; : \n " ;
d.function_1 ();
d.function_2 ();
base & rb = b;
std:: cout< < " \nappels pour base &rb = b; : \n " ;
rb.function_1 ();
rb.function_2 ();
base & rd = d;
std:: cout< < " \nappels pour base &rd = d; : \n " ;
rd.function_1 ();
rd.function_2 ();
return 0 ;
}
|
Ce code produit comme sortie :
Sortie : |
appels pour base b; :
base::function_1
base::function_2
appels pour derived d; :
derived::function_1
derived::function_2
appels pour base &rb = b; :
base::function_1
base::function_2
appels pour base &rd = d; :
base::function_1
derived::function_2
|
Les types statiques et dynamiques sont :
variable |
type statique |
type dynamique |
base b |
base |
base |
derived d |
derived |
derived |
base &rb = b |
base |
base |
base &rd = d |
base |
derived |
Type statique et dynamique des différentes variables
fonction_1 est une fonction non virtuelle et
fonction_2 est une fonction virtuelle. Soit en
reprenant le tableau précédent précisant la fonction appelée :
Appels sur b |
fonction non virtuelle (fonction_1)
|
fonction virtuelle (fonction_2)
|
appel sur un objet |
type statique
: base::fonction_1
|
type statique
: base::fonction_2
|
Appels sur d |
|
|
appel sur un objet |
type statique
: derived::fonction_1
|
type statique
: derived::fonction_2
|
Appels sur rb |
|
|
appel sur une référence |
type statique
: base::fonction_1
|
type dynamique
: base::fonction_2
|
Appels sur rd |
|
|
appel sur une référence |
type statique
: base::fonction_1
|
type dynamique
: derived::fonction_2
|
Fonction appelée selon le moment de résolution de l'appel
Cette résolution dynamique présentée avec des références
fonctionne de la même façon avec un pointeur qu'il soit
utilisé en tant que tel ou déréférencé :
Références et pointeurs : même combat ! |
# include <iostream>
struct base
{
virtual void function ()
{
std:: cout< < " base::function\n " ;
}
} ;
struct derived :public base
{
virtual void function ()
{
std:: cout< < " derived::function\n " ;
}
} ;
int main ()
{
derived d;
base & rd = d;
base * pd = & d;
pd- > function ();
(* pd).function ();
rd.function ();
return 0 ;
}
|
Le comportement est identique si l'expression utilise un
pointeur de fonction :
Appel de fonction avec un pointeur de fonction membre : |
# include <iostream>
struct base
{
virtual ~ base (){ }
virtual void function_1 ()
{
std:: cout< < " base::function_1\n " ;
}
void function_2 ()
{
std:: cout< < " base::function_2\n " ;
}
} ;
struct derived : public base
{
virtual void function_1 ()
{
std:: cout< < " derived::function_1\n " ;
}
void function_2 ()
{
std:: cout< < " base::function_2\n " ;
}
} ;
void call_a_function (void (base:: * pf_)())
{
base b;
(b.* pf_)();
derived d;
(d.* pf_)();
base & rd = d;
(rd.* pf_)();
}
int main ()
{
std:: cout< < " function_1 :\n " ;
call_a_function (& base:: function_1);
std:: cout< < " function_2 :\n " ;
call_a_function (& base:: function_2);
return 0 ;
}
|
La sortie produite est bien :
function_1 :
base::function_1
derived::function_1
derived::function_1
function_2 :
base::function_2
base::function_2
base::function_2
|
La liaison tardive prenant appui sur le type dynamique telle
que nous la décrivons ici est valable presque tout le temps.
Comme nous allons le voir par la suite, ce mécanisme
présente quelque subtilité en particulier lors des phases
sensibles que sont la construction ou la destruction d'un objet.
IV. A quoi servent les fonctions virtuelles ?
|
L'utilisation du type dynamique pour résoudre l'appel d'une
fonction virtuelle est une des grandes forces de la programmation
orientée objet (POO). Elle permet d'adapter et de faire évoluer
un comportement défini dans une classe de base en spécialisant
les fonctions virtuelles dans les classes dérivées. La substitution
d'un objet de type dynamique dérivant du type statique présent
dans l'expression contenant l'appel vers une fonction virtuelle
s'inscrit dans le cadre du
polymorphisme d'inclusion.
|
Ce polymorphisme d'inclusion permet l'abstraction dans un
logiciel orienté objet. Ainsi, un objet peut être manipulé
à partir d'un pointeur ou d'une référence vers la classe de base.
Les membres publics de la classe de base déterminent
les services proposés et la mise en oeuvre est déléguée
aux classes dérivées apportant des points de variations
ou spécialisant des comportements dans les fonctions
virtuelles :
Le polymorphisme d'inclusion : un principe fondamental de l'objet ! |
# include <iostream>
struct shape
{
virtual void draw () const
{
std:: cout< < " une forme amorphe\n " ;
}
} ;
void draw_a_shape (shape const & rs)
{
rs.draw ();
}
struct square : public shape
{
virtual void draw () const
{
std:: cout< < " un carre\n " ;
}
} ;
struct circle : public shape
{
virtual void draw () const
{
std:: cout< < " un cercle\n " ;
}
} ;
int main ()
{
shape sh;
draw_a_shape (sh);
square sq;
draw_a_shape (sq);
circle c;
draw_a_shape (c);
return 0 ;
}
|
L'abstraction permet de mieux isoler les différents
composants les uns des autres en ne les rendant dépendants
que des services dont ils ont vraiment besoin. Elle favorise
la modularité et l'évolutivité des architectures logicielles.
Notre fonction draw_a_shape peut dessiner
tout type d'objet non prévu lors de son écriture du moment
que ces objets sont d'un type dynamique héritant de
shape et spécialisant ses fonctions
virtuelles. Il devient
possible de rajouter de nouveaux types et de pouvoir les dessiner
sans avoir à réécrire la fonction draw_a_shape.
|
Les fonctions virtuelles réduisent le couplage entre une
classe ou une fonction cliente et une classe fournisseur en
déléguant aux classes dérivées la réalisation d'une partie
des services proposés par la classe de base.
|
|
Les fonctions virtuelles sont un mécanisme pour la mise en
oeuvre du principe ouvert/fermé
(6)
en permettant de faire évoluer une application par l'ajout
de nouvelle classe dérivée (ouvert) sans avoir à toucher
le code existant utilisant l'interface de la classe de base (fermé).
|
|
Les fonctions virtuelles favorisent la réutilisation. Toutes
les fonctions ou les classes s'appuyant sur des références
ou des pointeurs de la classe de base peuvent être directement
utilisées avec des objets d'un nouveau type dérivé.
|
Si le terme complet est polymorphisme d'inclusion, beaucoup
de documents en C++ (articles - et celui-ci n'échappe pas à la
règle - , cours, livres, etc.) omettent de préciser
d'inclusion. Polymorphe, polymorphique,
polymorphisme, polymorphiquement s'emploient
souvent seuls dès qu'il s'agit de parler d'héritage et donc
de la manipulation d'un objet d'une classe dérivée
à partir d'une référence ou d'un pointeur d'une de ses classes
de bases avec en arrière plan le mécanisme des fonctions
virtuelles. C'est un raccourci qui peut faire oublier
les autres formes de polymorphisme qui ne s'appuient pas
sur les fonctions virtuelles et le mécanisme d'héritage. Il faut
juste se souvenir que le polymorphisme ne se réduit pas
au polymorphisme d'inclusion.
V. Première conséquence : comment bien déclarer son destructeur
Prenons maintenant l'exemple suivant :
Erreur : un héritage public sans destructeur virtuel ! |
# 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 : |
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.
|
Le code précédent peut alors soit se décliner :
Interdire la destruction polymorphe : |
# 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;
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 : |
# 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 : |
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 : |
class mon_vecteur : public std:: vecteur< int >
{
} ;
|
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 : |
struct base
{
public :
~ base (){ }
} ;
struct derived : private base
{
} ;
int main ()
{
base * pb = new derived;
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 : |
struct base
{
virtual ~ base (){ }
} ;
|
|
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 : |
struct base
{
virtual ~ base ()= default ;
} ;
struct derived : public base
{
virtual ~ derived ()= default ;
} ;
|
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 : |
struct base
{
virtual ~ base ()
{
std:: cout< < " ~base\n " ;
}
} ;
struct derived: public base
{
virtual ~ derived ()
{
std:: cout< < " ~derived\n " ;
}
} ;
int main ()
{
derived d;
return 0 ;
}
|
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 : |
struct my_struct
{
inline void function_1 ();
void function_2 ()
{
}
protected :
~ my_struct ()
{ }
} ;
void my_struct:: function_1 ()
{
}
int main ()
{
my_struct var;
var.function_1 ();
var.function_2 ();
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
|
inlining possible ?
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 : |
struct base
{
virtual ~ base (){ }
virtual void function ()
{
}
} ;
struct derived : public base
{
virtual void function ()
{
}
} ;
int main ()
{
base b;
b.function ();
derived d;
d.function ();
base & rd = d;
rd.function ();
derived & rd2 = d;
rd2.function ();
return 0 ;
}
|
VII. Quand est construit le type dynamique ou quel est l'impact
des appels de fonctions virtuelles dans un constructeur ?
- 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 : |
# 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 |
Types statiques et dynamiques pendant la construction
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 : |
# 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 |
Types statiques et dynamiques pendant la construction
|
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 : |
# 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 |
Types statiques et dynamiques pendant la destruction
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 : |
struct base
{
base ()
{
do_init ();
}
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 : |
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 : |
# 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 |
Types statiques et dynamiques pendant la construction
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 : |
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 : |
struct abstract
{
virtual void pure_function ()= 0 ;
virtual ~ abstract (){ }
} ;
int main ()
{
abstract b;
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 : |
struct abstract
{
virtual void pure_function ()= 0 ;
virtual ~ abstract (){ }
} ;
struct concrete : public abstract
{
virtual void pure_function () { }
} ;
int main ()
{
concrete b;
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 : |
struct abstract
{
virtual void pure_function ()= 0 ;
virtual void function ();
virtual ~ abstract (){ }
} ;
struct concrete : public abstract
{
virtual void pure_function () { }
virtual void function (){ }
} ;
int main ()
{
concrete c;
return 0 ;
}
|
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 : |
struct abstract
{
virtual void pure_function ()= 0 ;
virtual ~ abstract (){ }
} ;
void abstract:: pure_function ()
{
}
|
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++ : |
struct abstract
{
virtual void pure_function ()= 0
{
}
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 : |
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;
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 : |
struct abstract
{
virtual void pure_function ()= 0 ;
void a_function ()
{
}
virtual ~ abstract () { }
} ;
struct concrete : public abstract
{
virtual void pure_function () { }
} ;
int main ()
{
concrete b;
abstract & rb = b;
abstract * pb = & b;
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 : |
# 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 : |
# 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 ();
}
} ;
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 : |
# 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 ();
}
} ;
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 ? | # 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 ! | # 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
{
} ;
|
|
- 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 ? | # 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 ! | # 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. ? | 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 ! | # 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 : |
struct base
{
virtual ~ base ()= 0 ;
} ;
base:: ~ base ()
{
}
|
|
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 : |
struct base
{
virtual ~ base ()= 0 ;
} ;
base:: ~ base ()= default ;
|
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 |
# 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 |
# 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 |
# 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 |
# 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 |
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
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 |
# 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 : |
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 : |
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 swap :
Copy and swap : |
struct a_type
{
a_type& operator = (a_type rhs_)
{
std:: swap (membre_1, rhs_.membre_1);
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 : |
# 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;
derived d1;
derived d2;
d1 = d2;
base & rd1 = d1;
base & rd2 = d2;
rd1 = rd2;
rd1 = d1;
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 ? |
# 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)
{
std:: cout< < " derived::operator=(base)\n " ;
return * this ;
}
} ;
int main ()
{
base b1;
base b2;
b1 = b2;
derived d1;
derived d2;
d1 = d2;
base & rd1 = d1;
base & rd2 = d2;
rd1 = rd2;
rd1 = d1;
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é. Et une sémantique d'entité, comme
on le voit, se marie mal avec une sémantique de copie.
XIII. Le retour covariant des fonctions virtuelles
Une fonction virtuelle dans la classe dérivée doit présenter
la même signature (même nom, même nombre de paramètres, même
type des paramètres, même constance de la fonction) pour
spécialiser la fonction définie dans la classe de base. Or le
type de retour de la fonction ne fait pas partie de la signature de la
fonction. Il devient dès lors légitime de se poser la question
suivante : une fonction virtuelle peut elle avoir un type
de retour différent de la classe de base ? La réponse
est en deux temps : non mais oui.
Non : si les deux types de retour n'ont rien en commun alors
c'est une erreur :
La spécialisation d'une fonction virtuelle ne peut retourner un type vraiment différent : |
struct base {
virtual int function ()
{
return 0 ;
}
} ;
struct derived : public base
{
virtual double function ()
{
return 0 .0 ;
}
} ;
|
Mais oui à condition de respecter les contraintes suivantes :
- le retour doit être par pointeur ou par référence ;
- ET le type retourné dans la classe dérivée doit
dériver directement ou indirectement et sans ambiguïté
du type retourné dans la classe de base ;
- ET les types retournés doivent avoir la même constance
ou celui de la classe dérivée doit avoir une constance plus
faible.
On parle alors d'un retour
covariant car le retour de la fonction virtuelle varie
en même temps que l'héritage :
Retour covariant : |
struct base_return
{
} ;
struct base {
virtual base_return& function ();
} ;
struct derived_return : public base_return
{ } ;
struct derived : public base
{
virtual derived_return& function ();
} ;
|
Le retour est dit covariant car une spécialisation dans une
classe C ne peut retourner qu'un type égal ou dérivant de la
fonction virtuelle la plus spécialisée de son arbre d'héritage :
Retour covariant : types retour et types de la classe suivent un même se spécialisent en même temps
Ce qui se traduit en code :
Retours covariants corrects : |
struct base_return
{ } ;
struct derived_1_return : public base_return
{ } ;
struct derived_2_return : public derived_1_return
{ } ;
struct base {
virtual base_return& function ();
} ;
struct derived_1 : public base
{
virtual derived_1_return& function ();
} ;
struct derived_2 : public derived_1
{
virtual derived_2_return& function ();
} ;
|
Erreur : le type retour n'est pas covariant
Ce qui se traduit en code :
Un type de retour non covariant est une erreur : |
struct base_return
{ } ;
struct derived_1_return : public base_return
{ } ;
struct derived_2_return : public derived_1_return
{ } ;
struct base {
virtual base_return& function ();
} ;
struct derived_1 : public base
{
virtual derived_2_return& function ();
} ;
struct derived_2 : public derived_1
{
virtual derived_1_return& function ();
virtual base& function ();
virtual derived_2_return& function ();
} ;
|
Erreur : le type retour n'est pas covariant
Ce qui se traduit en code :
Un type de retour non covariant est une erreur : |
struct base_return
{ } ;
struct derived_1_return : public base_return
{ } ;
struct derived_3_return : public base_return
{ } ;
struct base {
virtual base_return& function ();
} ;
struct derived_1 : public base
{
virtual derived_1_return& function ();
} ;
struct derived_2 : public derived_1
{
virtual derived_3_return& function ();
virtual derived_1_return& function ();
} ;
|
La constance du retour peut être perdue en chemin mais pas
rajoutée :
La constance du retour peut être perdue : |
struct base_return
{ } ;
struct base {
virtual base_return const & function_1 ();
virtual base_return & function_2 ();
} ;
struct derived_1 : public base
{
virtual base_return & function_1 ();
virtual base_return const & function_2 ();
} ;
|
Pour comprendre ces règles de covariance, il faut se rappeler
que la fonction de la classe dérivée peut être appelée avec
un pointeur ou une référence d'un type statique de la classe
de base. Pour que le compilateur puisse correctement
vérifier les types à la compilation (donc pour les classes
de base), les types retournés à l'exécution (donc par les
classes dérivées) doivent présenter une certaine cohérence.
C'est pour cela qu'une fonction spécialisée ne peut retourner
qu'un type qui puisse se substituer à la version la plus
spécialisée de ses classes de base.
Nécessité du retour covariant : |
struct base {
virtual base_return const & function_1 ();
virtual base_return & function_2 ();
} ;
void a_function_handling_base (base& b_)
{
base_return const & cr = b_function_1 ();
base_return & r2 = b_function_2 ();
}
|
XIV. Forcer un appel spécifique d'une fonction virtuelle
Il est possible d'utiliser le type statique pour appeler
une fonction virtuelle d'un pointeur, d'une référence ou dans
une classe (qui utilise d'habitude le type dynamique du pointeur
this) à condition d'indiquer explicitement
au compilateur la version à appeler. Cela se fait en ajoutant
le nom de la classe de la spécialisation qui doit être
appelée :
Appel statique d'une fonction virtuelle |
# include <iostream>
struct base
{
virtual void function ()
{
std:: cout< < " base::function\n " ;
}
void call_base_function ()
{
base:: function ();
}
} ;
struct derived :public base
{
virtual void function ()
{
std:: cout< < " derived::function\n " ;
}
void call_derived_function ()
{
derived:: function ();
}
void call_parent_function ()
{
base:: function ();
}
} ;
int main ()
{
derived d;
base & rd = d;
rd.function ();
rd.base:: function ();
rd.call_base_function ();
d.derived:: function ();
d.base:: function ();
d.call_base_function ();
d.call_derived_function ();
d.call_parent_function ();
return 0 ;
}
|
Avec un appel explicite, la résolution se fait à la compilation
avec le type statique qui préfixe l'appel. La fonction n'est
plus vue comme virtuelle.
Si une classe dérivée veut appeler la définition
de la classe parente dans son implémentation d'une fonction
virtuelle, elle le fait donc en spécifiant explicitement
cet appel :
Forcer un appel statique : |
# include <iostream>
struct base
{
virtual void who_am_i () const
{
std:: cout< < " base\n " ;
}
} ;
struct derived :public base
{
virtual void who_am_i () const
{
std:: cout< < " derived avec comme parent :\n " ;
base:: who_am_i ();
}
} ;
void who_are_you (base const & b_)
{
b_.who_am_i ();
}
int main ()
{
base b;
std:: cout< < " Qui est b ?\n " ;
who_are_you (b);
std:: cout< < " Qui est d ?\n " ;
derived d;
who_are_you (d);
return 0 ;
}
|
Si la classe de base doit effectuer certaines actions
avant ou après l'exécution de la spécialisation, alors
il est plus judicieux d'utiliser le pattern N.V.I. (Non Virtual
Interface) présenté plus loin.
XV. Fonctions virtuelles et visibilité
Jusqu'à présent les exemples proposés montrent des classes
dont les fonctions virtuelles sont publiques et leur spécialisation
dans les classes dérivées aussi. En fait, les notions de visibilité
et de redéfinition sont indépendantes les unes des autres comme
nous allons le voir.
|
La visibilité d'une fonction virtuelle peut changer dans
la classe dérivée sans que cela ait d'impact sur le mécanisme
d'appel dynamique.
|
Une fonction virtuelle déclarée publique dans l'interface de base
peut être spécialisée avec une portée privée ou protégée par la
classe dérivée, l'appel avec une référence ou un pointeur sur
la classe de base va chercher la bonne spécialisation :
Virtuel et visibilité sont indépendants : |
# include <iostream>
struct base
{
public :
virtual void function ()
{
std:: cout< < " base::function\n " ;
}
virtual ~ base (){ }
} ;
struct derived : public base
{
private :
virtual void function ()
{
std:: cout< < " derived::function\n " ;
}
} ;
int main ()
{
derived d;
base & rd = d;
rd.function ();
return 0 ;
}
|
Le C++ a choisi de différencier les deux notions. La visibilité
d'une fonction est vérifiée par le compilateur avec le type
statique d'une variable. La résolution de l'appel dynamiquement
à l'exécution ne tient pas compte de la visibilité.
La conséquence est :
|
Une classe dérivée peut spécialiser une fonction virtuelle
déclarée comme privée par la classe de base.
|
Le code suivant ne pose donc aucun problème :
Spécialisation d'une fonction virtuelle privée : |
# include <iostream>
struct base
{
public :
void function ()
{
do_function ();
}
virtual ~ base (){ }
private :
virtual void do_function ()
{
std:: cout< < " base::function\n " ;
}
} ;
struct derived : public base
{
private :
virtual void do_function ()
{
std:: cout< < " derived::function\n " ;
}
} ;
struct derived_2 : public base
{
void function_2 ()
{
do_function ();
function ();
}
} ;
int main ()
{
derived d;
base & rd = d;
rd.function ();
return 0 ;
}
|
Cela est vrai aussi si l'héritage mis en oeuvre est privé :
Spécialisation d'une fonction virtuelle héritée indirectement en privée : |
# include <iostream>
struct base
{
public :
void function ()
{
do_function ();
}
virtual ~ base (){ }
private :
virtual void do_function ()
{
std:: cout< < " base::function\n " ;
}
} ;
struct derived : private base
{
public :
void function_2 ()
{
function ();
}
} ;
struct derived_2 : public derived
{
private :
virtual void do_function ()
{
std:: cout< < " derived_2::function\n " ;
}
} ;
int main ()
{
derived_2 d;
d.function_2 ();
return 0 ;
}
|
Bien sûr, la fonction dans la classe de base étant privée,
celle-ci ne peut être directement appelée depuis l'extérieur
ou par les classes dérivées :
Les appels nécessitent les droits d'accès adéquats : |
# include <iostream>
struct base
{
public :
virtual ~ base (){ }
void function ()
{
do_function ();
}
private :
virtual void do_function ()
{
std:: cout< < " base::function\n " ;
}
} ;
struct derived : public base
{
void function_2 ()
{
base:: do_function ();
}
private :
virtual void do_function ()
{
std:: cout< < " derived::function\n " ;
}
} ;
int main ()
{
derived d;
d.do_function ();
base & rd = d;
rd.do_function ();
rd.function ();
d.function ();
return 0 ;
}
|
Nous verrons plus loin, lorsque nous évoquerons le pattern
N.V.I. que cette différenciation entre la résolution
dynamique et la visibilité statique est loin d'être
anecdotique et apporte des avantages.
XVI. Fonction virtuelle et masquage de fonction
XVI-A. Masquage d'une fonction virtuelle par une fonction non virtuelle
Nous avons dit en début d'article qu'une fonction définie
dans une classe dérivée avec une signature différente de
celle de la classe de base masque la fonction de la classe
de base. Et en particulier, son aspect virtuel peut être
perdu :
Masquage des fonctions virtuelles : |
# include <iostream>
struct base
{
virtual void function ()
{
std:: cout< < " base::function\n " ;
}
} ;
struct derived : public base
{
void function (int = 0 )
{
std:: cout< < " derived::function\n " ;
}
} ;
struct derived_2 : public derived
{
} ;
int main ()
{
base b;
b.function ();
derived d;
d.function ();
derived_2 d2;
d2.function ();
return 0 ;
}
|
Ce code produit le résultat suivant :
base::function
derived::function
derived::function
|
Les choses peuvent sembler confuses dès lors que des fonctions
d'une classe dérivée masquent les fonctions virtuelles des
classes de bases. En effet, la résolution statique ou dynamique
de l'appel dépend du type statique de l'expression contenant l'appel.
Selon que ce type statique 'voit' la fonction virtuelle ou que
celle-ci est masquée par la surcharge, l'appel sera traité comme un
appel virtuel ou non. Et pour un même objet, selon l'expression
utilisée pour appeler la fonction, le comportement peut être très
différent. Voyons cela avec un peu de code :
Masquage de fonction : |
# include <iostream>
struct base
{
virtual void function ()
{
std:: cout< < " base::function\n " ;
}
void call_function ()
{
function ();
}
} ;
struct derived : public base
{
void function (int = 0 )
{
std:: cout< < " derived::function\n " ;
}
} ;
struct derived_2 : public derived
{
void call_function ()
{
function ();
}
} ;
void call_with_base (base& b_)
{
b_.function ();
b_.call_function ();
}
void call_with_derived (derived& d_)
{
d_.function ();
d_.call_function ();
}
int main ()
{
base b;
std:: cout< < " base\n " ;
b.function ();
b.call_function ();
call_with_base (b);
derived d;
std:: cout< < " \nderived\n " ;
d.function ();
d.call_function ();
call_with_base (d);
call_with_derived (d);
derived_2 d2;
std:: cout< < " \nderived_2\n " ;
d2.function ();
d2.call_function ();
call_with_base (d2);
call_with_derived (d2);
return 0 ;
}
|
Nous voici avec le résultat suivant :
base
base::function
base::function
base::function
base::function
derived
derived::function
base::function
base::function
base::function
derived::function
base::function
derived_2
derived::function
derived::function
base::function
base::function
derived::function
base::function
|
Pour comprendre comment sont traités ces appels de fonction,
il faut à chaque fois se poser les questions suivantes :
- Quel est le type statique de l'expression sur laquelle
s'applique la fonction ? La réponse permet de répondre à
la question suivante :
- Quelle est la fonction vue par ce type statique ?
La réponse donne la colonne (fonction virtuelle ou non) de
notre tableau
montrant où se fait la résolution de l'appel.
- Et s'il s'agit de la fonction virtuelle, alors quel
est le type dynamique de l'expression ? La réponse est
nécessaire si on se trouve sur la seconde colonne de
notre tableau.
Reprenons les différents appels et procédons à leur analyse
suivant cette grille :
Appels sur base : |
base b;
std:: cout< < " base\n " ;
b.function ();
b.call_function ();
call_with_base (b);
|
Pour ces trois appels :
expression |
type statique |
fonction vue |
type dynamique |
fonction appelée |
b.function |
base |
- |
- |
Appel d'une fonction : l'expression utilise une
variable par valeur, la résolution est donc faite à la
compilation avec le type statique : il s'agit
de base::function.
|
b.call_function() aboutit
à l'appel de base::call_function
contenant l'expression qui nous intéresse
function();. Nous devons regarder nous
intéresser à this qui est l'objet
sur lequel la fonction est appelée.
|
base |
base::function (virtuelle) |
base |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(base) : base::function
|
call_with_base(base&b_) :
b_.function(). Intéressons nous à b_
|
base |
base::function (virtuelle) |
base |
L'expression utilise une variable par référence,
la fonction est virtuelle, l'appel utilise donc le type
dynamique (base) :
base::function
|
call_with_base(base&b_) :
b_.call_function(). Cet appel est équivalent
à la seconde ligne de ce tableau :
b.call_function()
|
base |
base::function (virtuelle) |
base |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(base) : base::function
|
Fonctions appelées pour un objet de base
Passons à derived d :
Appel sur derived |
derived d;
d.function ();
d.call_function ();
call_with_base (d);
call_with_derived (d);
|
expression |
type statique |
fonction vue |
type dynamique |
fonction appelée |
d.function |
derived |
- |
- |
Appel d'une fonction : l'expression utilise une
variable par valeur, la résolution est donc faite à la
compilation avec le type statique : il s'agit
de derived::function.
|
d.call_function() aboutit
à l'appel de base::call_function
contenant l'expression qui nous intéresse
function();. Nous devons regarder nous
intéresser à this qui est l'objet
sur lequel la fonction est appelée.
|
base |
base::function (virtuelle) |
derived |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(derived). Sauf que
derived::function n'est pas
une spécialisation de base::function.
Donc, la fonction appelée est
base::function
|
call_with_base(base&b_) :
b_.function(). Intéressons nous à b_
|
base |
base::function (virtuelle) |
derived |
L'expression utilise une variable par référence,
la fonction est virtuelle, l'appel utilise donc le type
dynamique (derived) mais pour les
mêmes raisons que ci-dessus l'appel est :
base::function
|
call_with_base(base&b_) :
b_.call_function(). Cet appel est équivalent
à la seconde ligne de ce tableau :
d.call_function()
|
base |
base::function (virtuelle) |
derived |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(base) et toujours en l'absence de
résolution, la fonction appelée est
(12)
base::function
|
call_with_derived(derived&d_) :
d_.function(). Intéressons nous à d_
|
derived |
derived::function (non virtuelle) |
- |
L'expression utilise une fonction non virtuelle,
l'appel utilise donc le type statique
(derived), l'appel est donc résolu
à la compilation vers derived::function
|
call_with_derived(derived&d_) :
d_.call_function(); aboutit
à l'appel de base::call_function
contenant l'expression qui nous intéresse
function();. Nous devons regarder nous
intéresser à this qui est l'objet
sur lequel la fonction est appelée.
|
base |
base::function (virtuelle) |
derived |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(derived) mais la seule spécialisation
disponible est base::function.
|
Fonctions appelées pour un objet de base
Enfin, pour les dernières expressions avec
derived_2 d2 :
Appels sur derived_2 |
derived_2 d2;
d2.function ();
d2.call_function ();
call_with_base (d2);
call_with_derived (d2);
|
expression |
type statique |
fonction vue |
type dynamique |
fonction appelée |
d2.function |
derived_2 |
- |
- |
Appel d'une fonction : l'expression utilise une
variable par valeur, la résolution est donc faite à la
compilation avec le type statique : il s'agit
de derived::function.
|
d2.call_function() aboutit
à l'appel de derived_2::call_function
contenant l'expression qui nous intéresse
function();. Nous devons regarder nous
intéresser à this qui est l'objet
sur lequel la fonction est appelée.
|
derived_2 |
derived::function (non virtuelle) |
derived_2 |
L'expression utilise une fonction non virtuelle,
la résolution est donc à la compilation avec le
type statique : derived::function.
|
call_with_base(base&b_) :
b_.function(). Intéressons nous à b_
|
base |
base::function (virtuelle) |
derived_2 |
L'expression utilise une variable par référence,
la fonction est virtuelle, l'appel utilise donc le type
dynamique (derived_2) mais comme pour
derived, la spécialisation disponible
est : base::function
|
call_with_base(base&b_) :
b_.call_function().
call_function() n'étant pas
une fonction virtuelle, c'est la version de
base qui est appelée. C'est donc
l'expression function() dans
base::call_function qui nous intéresse,
donc le type de this dans cette expression.
|
base |
base::function (virtuelle) |
derived_2 |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(base) et toujours en l'absence de
résolution, la fonction appelée est
base::function
|
call_with_derived(derived&d_) :
d_.function(). Intéressons nous à d_
|
derived |
derived::function (non virtuelle) |
- |
L'expression utilise une fonction non virtuelle,
l'appel utilise donc le type statique
(derived), l'appel est donc résolu
à la compilation vers derived::function
|
call_with_derived(derived&d_) :
d_.call_function(); aboutit
à l'appel de base::call_function
contenant l'expression qui nous intéresse
function();. Nous devons regarder nous
intéresser à this qui est l'objet
sur lequel la fonction est appelée.
|
base |
base::function (virtuelle) |
derived_2 |
L'expression utilise une variable par pointeur
(this), la fonction est virtuelle,
l'appel utilise donc le type dynamique
(derived_2) mais la seule spécialisation
disponible est base::function.
|
Fonctions appelées pour un objet de base
XVI-B. Masquage d'une fonction non virtuelle par une fonction virtuelle
A la précédente section, nous avons vu qu'une fonction
non virtuelle peut masquer une fonction virtuelle d'une
classe de base, l'inverse est vrai :
Masquage d'une fonction non virtuelle par une fonction virtuelle |
# include <iostream>
struct base
{
void function ()
{
std:: cout< < " base::function\n " ;
}
void call_function ()
{
function ();
}
} ;
struct derived : public base
{
virtual void function ()
{
std:: cout< < " derived::function\n " ;
}
} ;
struct derived_2 : public derived
{
void call_function ()
{
function ();
}
} ;
void call_with_base (base& b_)
{
b_.function ();
b_.call_function ();
}
void call_with_derived (derived& d_)
{
d_.function ();
d_.call_function ();
}
int main ()
{
base b;
std:: cout< < " base\n " ;
b.function ();
b.call_function ();
call_with_base (b);
derived d;
std:: cout< < " \nderived\n " ;
d.function ();
d.call_function ();
call_with_base (d);
call_with_derived (d);
derived_2 d2;
std:: cout< < " \nderived_2\n " ;
d2.function ();
d2.call_function ();
call_with_base (d2);
call_with_derived (d2);
return 0 ;
}
|
A titre d'exercice, je laisse le soin au lecteur d'expliquer
les différents appels en reprenant nos grilles explicatives
présentées en début de section.
XVI-C. Des fonctions pas totalement masquée.
En fait, la fonction n'était pas vraiment masquée mais nous
nous sommes appuyés sur l'utilisation d'un argument par
défaut (int=0) pour insérer une confusion
dans les appels. En fait, si nous rajoutons une
spécialisation de la fonction virtuelle de base, le
résultat obtenu change encore de nature :
Spécialisation d'une fonction virtuelle masquée : |
struct derived_2 : public derived
{
void function ()
{
std:: cout< < " derived_2::function\n " ;
}
void call_function ()
{
function ();
}
} ;
|
Avec cette définition, la sortie produite devient :
derived_2
derived_2::function
derived_2::function
derived_2::function
derived_2::function
derived::function
derived_2::function
|
Malgré l'absence du mot-clé virtual, la fonction
derived_2::function est une spécialisation
de la fonction de la classe de base virtual void
base::function. Le mot-clé virtual
comme nous l'avons vu n'étant plus nécessaire (mais
fortement recommandé) dans les classe dérivée.
XVI-D. Ramener un symbole : using
Pour ceux qui arrivent encore à suivre, rajoutons un nouveau
chemin dans ce dédale : le mot-clé using.
Ce mot clé permet (entre autre) d'amener un symbole
dans la portée courante. Dans
notre exemple, nous pouvons dans
derived_2 réintroduire
base::function en 'sautant' la définition
de derived::function aboutissant
à un nouveau masquage de cette dernière :
Ramener un symbole avec using : |
struct derived_2 : public derived
{
using base:: function;
void call_function ()
{
function ();
}
} ;
|
Avec ce petit rajout, tous les appels de
function sur des variables de type statique
derived_2 sont résolus en considérant
base::function et non
derived::function bien que
derived_2 ne contienne aucune définition
spécifique de cette fonction.
Ainsi, la sortie produite pour :
Résultat lorsque la fonction a été ramenée au niveau public : |
derived_2 d2;
std:: cout< < " \nderived_2\n " ;
d2.function ();
d2.call_function ();
call_with_base (d2);
call_with_derived (d2);
|
devient :
derived_2
base::function
base::function
base::function
base::function
derived::function
base::function
|
XVI-E. Que conclure ?
Ce petit chapitre n'avait pas comme objectif de semer la
confusion sur la façon dont sont résolus les appels de
fonction mais de montrer que quelques inattentions peuvent
rendre un code obscur. La meilleure façon de s'en sortir
n'est pas de connaître sur le bout des doigts les différents
comportements mais de rechercher la simplicité. Ici, la
simplicité veut qu'une classe dérivée n'introduise pas
un symbole qui peut créer un conflit dans la classe parent :
|
Eviter d'introduire des masquages de fonctions !
|
XVII. Fonctions virtuelles et fonctions génériques (template)
XVII-A. Fonctions template
La question est rapidement traitée : il n'est pas possible
de définir comme virtuelle une fonction générique dans
une classe :
Les fonctions génériques ne peuvent être virtuelles : |
struct base
{
template < class T>
virtual void function ();
} ;
|
Réciproquement, une fonction générique d'une classe
dérivée ne spécialise pas une fonction virtuelle de la
classe de base :
Une fonction générique ne spécialise pas une fonction virtuelle : |
# include <iostream>
struct base
{
virtual void function (int )
{
std:: cout< < " base::function\n " ;
}
virtual ~ base (){ }
} ;
struct derived : public base
{
template < class T>
void function (T)
{
std:: cout< < " derived::function<T>\n " ;
}
} ;
template < > void derived:: function< int > (int )
{
std:: cout< < " derived::function<int>\n " ;
}
int main ()
{
derived d;
base & rb = d;
rb.function (1 );
return 0 ;
}
|
XVII-B. Fonctions virtuelles dans des classes génériques
Rien n'empêche de définir une fonction virtuelle dans
une classe générique :
Une classe générique peut définir des fonctions virtuelles : |
template < class T>
struct base
{
virtual void function (T);
virtual ~ base (){ }
} ;
|
La classe dérivée peut spécialiser la fonction virtuelle
de la classe de base :
Spécialisations des fonctions virtuelles d'une classe générique dans les classes dérivées : |
# include <iostream>
template < class T>
struct base
{
virtual void function (T)
{
std:: cout< < " base<T>::function\n " ;
}
virtual ~ base (){ }
} ;
struct derived : public base< int >
{
virtual void function (int )
{
std:: cout< < " derived::function\n " ;
}
} ;
template < class T2>
struct derived_t : public base< T2>
{
virtual void function (T2)
{
std:: cout< < " derived_t::function\n " ;
}
} ;
int main ()
{
derived d;
base< int > & rd = d;
rd.function (1 );
derived_t< double > dt;
base< double > & rdt = dt;
rdt.function (1 .);
return 0 ;
}
|
Bien sûr pour que la fonction soit une spécialisation
de la classe de base, elle doit avoir la même signature :
La spécialisation d'une fonction virtuelle doit avoir la même signature : |
# include <iostream>
template < class T>
struct base
{
virtual void function (T)
{
std:: cout< < " base<T>::function\n " ;
}
virtual ~ base (){ }
} ;
struct derived : public base< int >
{
virtual void function (int const & )
{
std:: cout< < " derived::function\n " ;
}
} ;
template < class T2>
struct derived_t : public base< T2 const & >
{
virtual void function (T2)
{
std:: cout< < " derived_t::function\n " ;
}
} ;
int main ()
{
derived d;
base< int > & rd = d;
rd.function (1 );
derived_t< double > dt;
base< double const & > & rdt = dt;
rdt.function (1 .);
return 0 ;
}
|
Le retour covariant reste valide :
Les règles de covariance sont les mêmes : |
# include <iostream>
template < class T>
struct base
{
virtual T& function ()
{
std:: cout< < " base<T>::function\n " ;
static T ret;
return ret;
}
virtual ~ base (){ }
} ;
struct return_type_base
{
virtual ~ return_type_base (){ }
} ;
struct return_type_derived : public return_type_base
{ } ;
struct derived : public base< return_type_base>
{
virtual return_type_derived& function ()
{
std:: cout< < " derived::function\n " ;
static return_type_derived ret;
return ret;
}
} ;
template < class T2>
struct derived_t : public base< T2>
{
virtual return_type_derived& function ()
{
std:: cout< < " derived_t::function\n " ;
static return_type_derived ret;
return ret;
}
} ;
int main ()
{
derived d;
base< return_type_base> & rd = d;
rd.function ();
derived_t< return_type_base> dt;
base< return_type_base> & rdt = dt;
rdt.function ();
return 0 ;
}
|
XVIII. Fonctions virtuelles et amitié (friend)
Les membres déclarées privés ou protégés sont visibles
uniquement par la classe (private)
ou que par la classe et ses classes dérivées
(protected). L'amitié, introduite par le
mot-clé friend permet de donner cet accès
explicitement à une classe ou une fonction tierce :
L'amitié peut être donnée à une classe en entier ou une fonction seulement : |
struct a_type;
struct a_dummy_type
{
void a_trusted_function (a_type & );
void a_non_trusted_function (a_type& );
} ;
struct a_trusted_type;
struct a_type
{
friend struct a_trusted_type;
friend void a_dummy_type:: a_trusted_function (a_type & );
private :
int membre;
} ;
void a_dummy_type:: a_trusted_function (a_type & a_)
{
a_.membre = 0 ;
}
void a_dummy_type:: a_non_trusted_function (a_type& a_)
{
a_.membre = 0 ;
}
struct a_trusted_type
{
void a_function (a_type a_)
{
a_.membre = 0 ;
}
} ;
|
L'amitié ne s'hérite pas : si une classe base
est déclarée amie de la classe a_type, alors
la classe dérivée derived n'est pas ami
de la classe a_type par défaut :
L'amitié ne s'hérite pas : |
struct base;
struct a_type
{
friend struct base;
private :
int membre;
} ;
struct base
{
void function (a_type & a_)
{
a_.membre = 0 ;
}
} ;
struct derived : public base
{
void a_derived_function (a_type & a_)
{
}
} ;
|
De la même façon, l'amitié sur une fonction virtuelle
ne se propage pas sur ses spécialisations :
L'amitié ne se propage pas sur les spécialisations : |
struct a_type;
struct base
{
virtual void function (a_type & a_);
} ;
struct a_type
{
friend void base:: function (a_type & a_);
private :
int membre;
} ;
void base:: function (a_type & a_)
{
a_.membre = 0 ;
}
struct derived : public base
{
virtual void function (a_type & a_)
{
a_.membre = 0 ;
}
} ;
|
L'amitié sur une fonction virtuelle est déjà un indicateur
d'un probable problème de conception. Car, cette amitié ne
se transmettant pas aux spécialisations des classes dérivées,
cela créé une dissymétrie forte entre la fonction
virtuelle de base et ses spécialisations dans les classes dérivées.
Il y a tout lieu de se demander si la fonction doit être amie
ou si la fonction doit être virtuelle.
L'exemple devient vraiment singulier avec une fonction virtuelle
pure sans définition. Cela revient à déclarer amie une
fonction qui ne sera jamais appelée.
L'amitié peut être utile dans différent cas pour ne pas
rompre l'encapsulation des données tout en évitant
de créer des accesseurs dans les classes :
Les amis brisent-ils l'encapsulation ?.
Parfois, la classe amie peut être une classe de base ayant
des fonctions virtuelles amenées à être spécialisée pour
les classes de base. La conception de la classe doit
alors différencier les parties nécessitant d'accéder à la classe
amie des fonctions virtuelles devant être spécialisées. Elles
n'ont pas le même rôle :
Marier amitié et abstraction en séparant les rôles : |
struct a_type
{
friend struct abstract_builder;
private :
int membre;
} ;
struct abstract_builder
{
void build_a_type (a_type & a_)
{
a_.membre = create_an_int ();
}
virtual ~ abstract_builder (){ }
private :
virtual int create_an_int ()= 0 ;
} ;
struct concrete_builder : public abstract_builder
{
private :
virtual int create_an_int ()
{
return 0 ;
}
} ;
|
XIX. Fonctions virtuelles et spécification d'exceptions
XIX-A. Rappel sur les exceptions
La déclaration d'une fonction peut indiquer les exceptions
qu'elle est susceptible de lever en rajoutant le mot-clé
throw suivi des types d'exceptions
éventuellement levées :
Indication des types d'exceptions levées : |
void a_function () throw (int , std:: exception, std:: string);
|
a_function ne peut lever que des exceptions de type
int, std::exception ou std::string.
Si elle lève une exception d'un autre type, alors la fonction
std::unexpected() est appelée qui aboutit (à gros traits)
à la terminaison du programme.
Exemple d'exceptions pouvant être levée : |
void a_function () throw (int , std:: exception, std:: string)
{
throw 2 ;
throw std:: exception ();
throw std:: string (" error " );
throw 3 .14 ;
}
|
Si la liste suivant le mot clé throw est vide
alors la fonction s'engage à ne lever aucune exception :
Fonction ne levant pas d'exception : |
void a_function () throw ();
|
A contrario, si aucune directive throw n'est
présente, la fonction peut lever n'importe quel type d'exception :
Fonction pouvant lever tout type d'exception : |
|
XIX-B. Exceptions et hiérarchie de classes
Une fonction peut lever une exception d'un type dérivant
d'un des types indiqués dans sa liste d'exceptions sans
que ce ne soit une erreur :
Une fonction peut déclencher une exception d'une classe dérivée de la classe parent : |
struct my_base_excpetion
{
} ;
struct my_derived_exception : public my_base_excpetion
{
} ;
void a_function () throw (my_base_excpetion)
{
throw my_derived_exception ();
}
|
Les fonctions virtuelles permettent ainsi de spécifier un
comportement dans la classe de base des exceptions,
comportement spécialisé dans les classes dérivées :
Spécialisation d'un comportement d'une exception par les fonctions virtuelles : |
# include <iostream>
# include <exception>
struct my_base_excpetion
{
virtual void error ()const = 0 ;
} ;
struct my_derived_exception : public my_base_excpetion
{
virtual void error () const
{
std:: cout< < " my_derived_exception::error\n " ;
}
} ;
void a_function () throw (my_base_excpetion)
{
throw my_derived_exception ();
}
int main ()
{
try {
a_function ();
}
catch (my_base_excpetion const & e_)
{
e_.error ();
}
return 0 ;
}
|
A noter que c'est sur ce principe que sont bâties les exceptions
de la STL : elles dérivent toutes de std::exception
qui propose la fonction virtuelle what
retournant une chaîne de caractère décrivant l'erreur.
|
Pour bénéficier des fonctions virtuelles définies dans
les classes d'exceptions, les blocs catch
doivent récupérer l'exception par référence constante.
|
XIX-C. Les exceptions d'une fonction virtuelle
Les fonctions virtuelles peuvent spécifier les
exceptions éventuellement levées comme toutes autres
fonctions :
Les fonctions virtuelles peuvent spécifier les exceptions qu'elles souhaitent lever : |
struct base
{
virtual void function () throw (int ,double );
} ;
|
Une spécialisation de fonction virtuelle ne peut pas spécifier des types
d'exceptions non précisés par la déclaration dans la classe
de base :
Une spécialisation ne peut lever des exceptions non présentes dans la définition de base : |
struct base
{
virtual void function () throw (int ,double );
} ;
struct derived : public base
{
virtual void function () throw (int ,char );
} ;
|
Ni en spécifier plus :
Une spécialisation ne peut lever plus d'exception que la définition de base : |
struct base
{
virtual void function () throw (int ,double );
} ;
struct derived : public base
{
virtual void function () throw (int , double , std:: exception);
} ;
|
Donc à fortiori, elle ne peut pas les spécifier toutes si la
classe de base en précise au moins une :
Si la classe de base contient une exigence throw, alors les spécialisations aussi : |
struct base
{
virtual void function () throw (int ,double );
} ;
struct derived : public base
{
virtual void function ();
} ;
|
Elle doit spécifier les mêmes types d'exceptions :
Une spécialisation doit indiquer les mêmes exceptions que la version de base : |
struct base
{
virtual void function () throw (int ,double );
} ;
struct derived : public base
{
virtual void function () throw (int , double );
} ;
|
ou moins :
Une spécialisation peut indiquer moins exceptions que la version de base : |
struct base
{
virtual void function () throw (int ,double );
} ;
struct derived : public base
{
virtual void function () throw (int );
} ;
|
ou s'engager à n'en lever aucune :
Une spécialisation peut s'engager à ne lever aucune exception : |
struct base
{
virtual void function () throw (int ,double );
} ;
struct derived : public base
{
virtual void function () throw ();
} ;
|
XX. Fonctions virtuelles et programmation par contrat
XX-A. Un rapide rappel
Le but n'est pas de présenter la programmation par contrat
(13).
Si celle-ci vous est totalement inconnue, alors il est
temps de vous y mettre. La programmation par contrat est
un puissant outil de conception pour réaliser des classes
sûres et simples clarifiant les responsabilités de chacun
des acteurs d'un échange. Contentons nous de rappeler les
éléments dont nous allons avoir besoin.
Un
invariant est une propriété d'un objet qui doit
rester valide pendant toute la durée de vie de l'objet ou
pour être plus précis dès que cet objet est utilisable par
un tiers. Par exemple, un invariant de la classe
std::vector est :
size()<=capacity(). Cela signifie
qu'un vecteur doit toujours avoir un nombre d'éléments
au plus égal aux nombre total d'éléments qu'il peut
contenir.
Une
précondition d'une fonction est une propriété qui
doit être vérifiée pour que l'appel de la fonction
garantisse le contrat. Par exemple, toujours pour
notre
std::vector, une précondition
pour appeler
std::vector::pop_back est que
size()>=1. On ne peut supprimer le dernier
élément que si le vecteur en contient au moins un. C'est à
l'appelant d'une fonction de s'assurer que les préconditions
sont remplies avant de solliciter cette fonction sur un objet.
Les préconditions peuvent porter sur les paramètres de la
fonction ou sur l'état de l'objet.
Une
postcondition est une propriété que garantit la fonction
à la fin de son exécution. Par exemple,
std::vector<T>::push_back garantit
que
size()>=1. La postcondition peut
même être formulée plus précisément :
size() = old size() + 1. La postcondition
garantit que si on demande à rajouter un élément dans
le vecteur, alors la taille du vecteur augmente de un.
Si une fonction est appelée sur un objet respectant ses
invariants avec des préconditions invalides, alors le bug
est à chercher du côté de l'appelant. Si au retour d'une
fonction appelée avec un objet respectant ses invariants
et en remplissant les préconditions, les postconditions
ne sont pas remplies, alors le bug est à chercher dans
l'implémentation de la fonction.
Si une fonction est appelée sur un objet en ne respectant
pas les préconditions, alors la fonction n'a pas à respecter
le contrat : elle peut légitimement retourner un objet qui
ne respecte plus les invariants ou ne pas respecter les
postconditions en sortie. Ce n'est pas un bug de la fonction
mais une erreur lors de l'appel. A noter que cette situation
a toute les chances de se traduire à un moment ou un autre
par au mieux un plantage au pire des résultats erronés.
Si une fonction est appelée sur un objet respectant les
invariants et en respectant les préconditions mais que,
pour une autre raison, la fonction ne peut garantir les
postconditions alors elle doit lever une exception.
Le
client désigne celui qui fait l'appel à une
fonction d'un objet. Le
fournisseur désigne la classe
qui implémente la fonction.
XX-B. Le principe de substitution de Liskov
Le
principe de substitution de
Liskov
énonce que partout où dans une expression valide on trouve
un objet d'un type
B, l'expression doit rester valide
si on remplace l'objet par un autre objet de type
D avec
D dérivant publiquement de
B. Avec les fonctions non
virtuelles alors le problème est moindre car leur résolution
statique implique l'appel de la fonction définie dans la classe
de base donc en respectant ses préconditions, celle-ci
assure alors les postconditions. Avec une fonction
virtuelle, lorsqu'un objet de la classe de base est substitué
par un objet de la classe dérivée, la fonction appelée est
celle de la classe dérivée. Se posent alors les questions
suivantes :
- Que se passe-t-il pour les invariants de la classe dérivée et de la classe de base ?
- Quelles sont les préconditions que doit vérifier l'appelant ?
- Quelles sont les postconditions que doit garantir la classe dérivée ?
- Qu'en est-il des exceptions pouvant être levée ?
Nous allons voir comment répondre à ces questions.
(14)
XX-C. Impact sur les invariants pour une fonction virtuelle
Le problème des invariants ne concerne pas l'appel d'une
fonction virtuelle à l'extérieur de la classe de base
ou de la classe dérivée mais au sein d'une fonction
membre. Une fonction peut pendant son
déroulement momentanément rompre un ou des invariants
à condition qu'elle les rétablisse avant de sortir vers
l'appelant. Il faut donc considérer les invariants dans
une fonction F1 appelée pour un objet
O quand elle sollicite une fonction
virtuelle F2 sur ce même objet (sur
this) :
Impact sur les invariants : |
struct base{
void F1 ()
{
F2 ();
}
virtual void F2 ();
} ;
|
La réponse à la seconde question est la plus facile : qu'une
fonction soit virtuelle ou non, qu'elle soit appelée
depuis l'extérieur ou non, elle doit de toute façon
rétablir les invariants qu'elle a elle même rompus.
En revanche, savoir s'il faut rétablir les invariants avant l'appel
d'une fonction virtuelle est une question qui suscite des
positions variées. Cette question a fait l'objet
d'une discussion :
Ruptures des invariants en C++. La question
s'articule alors entre savoir si le contrat doit être
abordé de façon stricte au niveau de la classe. Ou si
au contraire, il est préférable d'avoir une approche
à un niveau juste au-dessus - le module (bien cela n'est
pas de sens en C++) - qui est le seul pertinent d'un
point de vue de conception (définition du service,
réutilisabilité, etc.).
XX-D. Impact sur les préconditions pour une fonction virtuelle
Supposons que la classe de base
B pose (P1 &&
P2 && P3 ... && Pn)
(15)
comme préconditions
pour l'appel de la fonction virtuelle
F. Le client
a la charge de vérifier les préconditions avant
d'appeler
F. Le code peut ressembler alors à :
Exemple de vérification des préconditions avant l'appel d'une fonction : |
void client:: some_function ()
{
if (P1 (rb)& & P2 (rb)& & ...& & Pn (rb)){
rb.F ();
}
}
|
Maintenant, supposons qu'une classe dérivée D demande
une précondition supplémentaire Pk. Alors
pour rester dans la programmation par contrat, le code
devrait être changé en :
Vérifier les conditions supplémentaires ? |
void client:: some_function ()
{
if (is_dynamic_type_a_B (rb)){
if (P1 (rb)& & P2 (rb)& & ...& & Pn (rb)){
rb.F ();
}
}
else
if (is_dynamic_type_a_D (rb)){
if (P1 (rb)& & P2 (rb)& & ...& & Pn (rb)& & Pk (rb)){
rb.F ();
}
}
}
|
On viole un des principes fondamentaux de la
programmation objet (le principe ouvert/fermé) :
à chaque fois qu'une nouvelle classe dérivée est
ajoutée, il faut venir changer cet appel. Qui plus est,
avoir à se soucier du type dynamique avant d'appeler une
fonction virtuelle enlève tout l'intérêt de ce mécanisme.
Ce n'est donc pas envisageable. D'où la conclusion :
|
Une classe dérivée ne peut pas imposer des préconditions
plus restrictives pour une fonction virtuelle que
celles demandées par la classe de base.
|
Les préconditions ne peuvent être renforcées dans les classes
dérivées.
Peut-on supprimer une précondition dans la classe dérivée ?
Supposons que la classe dérivée supprime la précondition
Pi. Cela veut dire qu'un appel à la fonction
dérivée doit vérifier (P0 && P1 && P2 && ...
&& (Pi || !Pi) && ... && Pn). Cette
expression est vraie dès que Pi est vrai. Dit autrement,
(P0 && P1 && P2 && ... && Pi &&
... && Pn) implique (P0 && P1 && P2 && ...
&& (Pi || !Pi) && ... && Pn).
Donc notre code ne tenant pas compte du type dynamique reste
valide pour une classe dérivée :
Les préconditions peuvent être relâchées : |
void client:: some_function ()
{
if (P1 (rb)& & P2 (rb)& & ...& & Pn (rb)){
rb.F ();
}
}
|
Que Pi soit vérifiée est une contrainte supplémentaire
qui n'est pas demandée par la classe dérivée mais qui, si elle
l'est, permet tout autant à la classe dérivée de remplir son
contrat :
|
Une classe dérivée peut imposer des préconditions
plus larges pour une fonction virtuelle que
celles demandées par la classe de base.
|
Reformuler autrement, une classe dérivée doit savoir
faire la même chose que sa classe de base (les préconditions
ne peuvent pas être plus fortes) mais peut en savoir faire
plus (les préconditions peuvent être plus faibles).
XX-E. Impact sur les postconditions pour une fonction virtuelle
Examinons les postconditions en suivant un raisonnement
similaire au précédent. Soit une classe B proposant
une fonction virtuelle F garantissant les
postconditions (P1 && P2 && P3 ... &&
Pn) :
Vérifier les postconditions : |
void client:: some_function ()
{
if (check_preconditions_for_F (rb)){
rb.F ();
assert ( P1 (rb) & & P2 (rb) & & .. & & Pn (rb));
}
}
|
Si la classe dérivée est plus souple que la classe de base
et décide de ne plus assurée la postcondition Pi. Alors
la garantie précédente peut ne plus être vérifiée
chez le client en sortie de F, le seul moyen qu'aurait
le client serait à nouveau de différencier le traitement
selon le type dynamique. Nous avons vu que cela était une
impasse. Une classe dérivée ne peut donc supprimer une
postcondition.
|
Une classe dérivée doit garantir toutes les postconditions
d'une fonction virtuelle indiquées dans la classe de base.
|
A l'inverse, supposons que la classe dérivée garantisse
une nouvelle postcondition Pk. Cela n'empêche pas
les autres postconditions d'être valides. Le client peut
donc continuer de s'appuyer sur les autres postconditions
sans se soucier de ce 'bonus'.
|
Une classe dérivée peut ajouter de nouvelles postconditions
au contrat d'une fonction virtuelle de la classe de base.
|
En d'autres termes, une classe dérivée doit faire
au minimum la même chose que la classe de base (pas de
postconditions en moins) mais peut en faire plus
(on peut rajouter des postconditions).
On peut résumer les contraintes sur les préconditions et les
postconditions par cette simple image : un sous-traitant
(classe dérivée) DOIT faire autant (postconditions) et
au même prix (préconditions) qu'un fournisseur (classe de base),
mais il peut faire plus (renforcement des postconditions)
pour moins cher (allégement des préconditions). En aucun
cas il peut faire moins (allégement des postconditions)
pour plus cher (renforcement de préconditions) :
Un sous-traitant doit faire autant au même prix
XXI. Le pattern N.V.I.
Comme beaucoup de bonnes idées, le
pattern N.V.I. a probablement été introduit par plusieurs
auteurs simultanément. L'article certainement le plus cité
pour décrire ce pattern et écrit par Herb Sutter le présente
sous le nom de
Template Method dans
le C/C++ User Journal.
Mais ce nom a deux inconvénients majeurs : introduire le mot
template alors qu'il ne s'agit pas de programmation
générique, mais surtout de créer la confusion avec le
patron de conception de même nom (D.P. Template Method) dont les
objectifs sont différents. On parle donc de
pattern N.V.I. pour Non Virtual Interface.
Le principe est simple : déclarer les fonctions
publiques
comme
non virtuelles et les fonctions
virtuelles
comme
privées. Les premières s'appuyant sur les secondes :
Pattern NVI : |
struct non_virtual_interface
{
public :
void function ()
{
do_function ();
}
private :
virtual void do_function ()
{
}
} ;
struct derived_non_virtual_interface : public non_virtual_interface
{
private :
virtual void do_function ()
{
}
} ;
|
Au regard de ce que nous avons déjà vu, les avantages sont évidents :
- Différenciation du contrat : les utilisateurs
de la classe doivent respecter le contrat défini par la fonction
publique. Les classes dérivées doivent respecter le contrat
défini par la fonction virtuelle. Il est dès lors plus
facile de faire évoluer l'un indépendamment de l'autre. Les
rôles sont bien distribués.
- Cela permet de regrouper les vérifications du contrat
dans la fonction non virtuelle pour garantir l'appel des
spécialisations dans les fonctions dérivées.
- Cela permet de regrouper des fonctions d'instrumentation
(trace par exemple) dans la fonction non virtuelle sans avoir à
les dupliquer.
- Comme évoqué dans la section concernant les fonctions
virtuelles pures, le pattern N.V.I. permet d'entourer l'appel
de la fonction virtuelle de prétraitements ou de posttraitements.
Pourquoi rendre la fonction virtuelle privée ?
Tout d'abord, nous
avons vu que la visibilité de la fonction n'influe pas sur
l'appel dynamique et n'empêche donc pas la fonction non virtuelle
de l'invoquer. L'intérêt est que même les classes dérivées ne
seront pas tentées d'appeler directement la fonction virtuelle
et doivent passer par l'interface publique de la classe de base.
Quelles différences entre le pattern N.V.I. et le patron de
conception Template Method ?
Le DP Template Method a pour
objectif d'introduire des points de variation dans un algorithme.
La fonction de base enchaîne différentes opérations dont certaines
peuvent changer ou dépendre d'un contexte particulier : elles doivent être
spécialisées par les classes dérivées.
La pattern N.V.I. a un tout autre objectif : séparer l'interface
d'une classe en fonction de ses clients. L'interface publique
et non virtuelle s'adresse aux utilisateurs de la classe.
L'interface privée et virtuelle s'adresse aux classes dérivées.
XXII. Informations sur les types (dynamiques et statiques) et conversions
XXII-A. Types polymorphes
Le C++ définit un type polymorphe comme un type contenant
au moins une fonction virtuelle ou dont une des
classes de bases contient une fonction virtuelle.
Types polymorphes : |
struct base
{
} ;
struct derived_1 : private base
{
virtual void do_it ();
} ;
struct derived_2 : private base
{
void do_it ();
} ;
struct base_2
{
virtual ~ base_2 ();
} ;
struct derived_3 : public base_2
{
} ;
|
Dans l'exemple précédent, cela donne :
- base : non polymorphe
- derived_1 : polymorphe
- derived_2 : non polymorphe
- base_2 : polymorphe
- derived_3 : polymorphe
L'héritage ne suffit pas pour qu'un type soit polymorphe.
Une hiérarchie de classe ne contenant aucune fonction
virtuelle n'est pas considérée comme polymorphe. L'explication
est facile à comprendre au regard de ce que nous avons vu
jusqu'à présent : en l'absence de fonction virtuelle, cela
signifie que quelque soit l'expression utilisée, la
résolution des appels se fait toujours avec le type statique
de l'objet (variable, référence ou pointeur). Il n'est donc
pas possible d'invoquer le comportement d'une classe dérivée
à partir de la classe mère. En ce sens, il n'y a pas vraiment
de polymorphisme car l'objet est toujours vu comme le
type statique qui le manipule.
XXII-B. Comment connaître le type dynamique d'une variable ?
XXII-B-1. L'opérateur typeid
Pour être utilisé, l'opérateur typeid
nécessite le fichier d'en-tête <typeinfo>.
L'opérateur typeid retourne un objet de
type statique const std::type_info et dont
le type dynamique est défini par l'implémentation du
compilateur (mais qui a de toute façon
const std::type_info comme une de ses
classes de base). L'opérateur s'applique aux variables, aux
types ainsi qu'aux expressions. Elle permet
d'avoir des informations de typage. Les qualificateurs
const sont ignorés. Les références sont
identiques au type qu'elles désignent et les pointeurs
déréférencés aussi. Les valeurs retournées par
typeid peuvent être comparées (avec
l'opérateur == ou l'opérateur
!=), cette comparaison permet de savoir
si deux variables/types/expressions sont du même type :
Comparer des résultats de typeid pour comparer des types : |
# include <typeinfo>
# include <iostream>
struct my_type
{
} ;
int main ()
{
my_type t1;
my_type t2;
my_type & rt = t1;
my_type * pt = & t1;
my_type const ct= my_type ();
std:: cout< < std:: boolalpha;
std:: cout< < " typeid(t1)==typeid(t2) : " < < (typeid (t1)= = typeid (t2))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(my_type) : " < < (typeid (t1)= = typeid (my_type))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(rt) : " < < (typeid (t1)= = typeid (rt))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(my_type&) : " < < (typeid (t1)= = typeid (my_type& ))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(pt) : " < < (typeid (t1)= = typeid (pt))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(ct) : " < < (typeid (t1)= = typeid (ct))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(my_type const) : " < < (typeid (t1)= = typeid (my_type const ))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(*pt) : " < < (typeid (t1)= = typeid (* pt))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(my_type*) : " < < (typeid (t1)= = typeid (my_type* ))< < " \n " ;
std:: cout< < " typeid(t1)==typeid(int) : " < < (typeid (t1)= = typeid (int ))< < " \n " ;
return 0 ;
}
|
Appliquer à un type polymorphe, typeid
retourne un const std::type_info relatif
au type dynamique de l'objet :
typeid retrouve le type dynamique d'objets polymorphes : |
# include <typeinfo>
# include <iostream>
struct polymorphic_base
{
virtual ~ polymorphic_base (){ }
} ;
struct polymorphic_derived : public polymorphic_base
{
} ;
int main ()
{
polymorphic_base b;
polymorphic_derived d;
std:: cout< < std:: boolalpha;
std:: cout< < " typeid(b)==typeid(d) : " < < (typeid (b)= = typeid (d))< < " \n " ;
polymorphic_base & rb= b;
std:: cout< < " typeid(b)==typeid(rb) : " < < (typeid (b)= = typeid (rb))< < " \n " ;
polymorphic_base & rd= d;
std:: cout< < " typeid(b)==typeid(rd) : " < < (typeid (b)= = typeid (rd))< < " \n " ;
std:: cout< < " typeid(d)==typeid(rd) : " < < (typeid (d)= = typeid (rd))< < " \n " ;
return 0 ;
}
|
Ce code produit comme sortie :
typeid(b)==typeid(d) : false
typeid(b)==typeid(rb) : true
typeid(b)==typeid(rd) : false
typeid(d)==typeid(rd) : true
|
Le type dynamique sur la référence rd
est bien retrouvé et correspond au type
polymorphic_derived de d.
Appliquer à un type non polymorphe, l'information de
type retournée est relative au type statique :
typeid retrouve le type statique des objets non polymorphes : |
# include <typeinfo>
# include <iostream>
struct non_polymorphic_base
{
~ non_polymorphic_base (){ }
} ;
struct non_polymorphic_derived : public non_polymorphic_base
{
} ;
int main ()
{
non_polymorphic_base b;
non_polymorphic_derived d;
std:: cout< < std:: boolalpha;
std:: cout< < " typeid(b)==typeid(d) : " < < (typeid (b)= = typeid (d))< < " \n " ;
non_polymorphic_base & rb= b;
std:: cout< < " typeid(b)==typeid(rb) : " < < (typeid (b)= = typeid (rb))< < " \n " ;
non_polymorphic_base & rd= d;
std:: cout< < " typeid(b)==typeid(rd) : " < < (typeid (b)= = typeid (rd))< < " \n " ;
std:: cout< < " typeid(d)==typeid(rd) : " < < (typeid (d)= = typeid (rd))< < " \n " ;
return 0 ;
}
|
Ce code donne comme résultat :
typeid(b)==typeid(d) : false
typeid(b)==typeid(rb) : true
typeid(b)==typeid(rd) : true
typeid(d)==typeid(rd) : false
|
Les deux dernières lignes mettent en évidence que
typeid(rd) prend le type statique
de la référence non_polymorphic_base
et non son type dynamique car non_polymorphic_base
n'est pas une classe polymorphe.
XXII-B-2. Evaluation de l'expression
Appliqué à un type non polymorphe et utilisant par conséquent
le type statique de l'expression, l'opérateur
typeid n'évalue pas l'expression à laquelle
il est appliqué. En particulier, si un pointeur est nul,
et qu'il est déréférencé dans l'expression, cela ne provoque
pas d'erreur :
L'expression pour un type non polymorphe n'est pas évaluée : |
# include <typeinfo>
# include <iostream>
struct non_polymorphic_base
{
~ non_polymorphic_base (){ }
} ;
struct non_polymorphic_derived : public non_polymorphic_base
{
} ;
int main ()
{
non_polymorphic_base * pb= 0 ;
std:: cout< < std:: boolalpha;
std:: cout< < " typeid(*pb)==typeid(non_polymorphic_base) : "
< < (typeid (* pb)= = typeid (non_polymorphic_base))< < " \n " ;
return 0 ;
}
|
Ce code est correct et ne pose pas de problème à l'exécution.
En revanche, sur un type polymorphe, l'expression est
évaluée car c'est le type dynamique obtenu à l'exécution
qui est nécessaire à l'opérateur typeid.
En particulier, si un pointeur est nul et que celui-ci est
déréférencé dans l'expression, une exception de type
std::bad_typeid est générée :
L'expression pour un type non polymorphe est évaluée : |
# include <typeinfo>
# include <iostream>
struct polymorphic_base
{
virtual ~ polymorphic_base (){ }
} ;
struct polymorphic_derived : public polymorphic_base
{
} ;
int main ()
{
polymorphic_base * pb= 0 ;
std:: cout< < std:: boolalpha;
try {
std:: cout< < " typeid(*pb)==typeid(polymorphic_base) : "
< < (typeid (* pb)= = typeid (polymorphic_base))< < " \n " ;
}
catch (std:: bad_typeid const & e_)
{
std:: cout< < " Erreur : " < < e_.what ()< < " \n " ;
}
return 0 ;
}
|
Lorsqu'une expression est évaluée, cela signifie
aussi que les fonctions qu'elle contient sont
effectivement déroulées. Alors que lorsque l'expression
n'est pas évaluée, les fonctions ne sont pas exécutées :
Evaluation implique appel de fonctions : |
# include <typeinfo>
# include <iostream>
struct polymorphic_base
{
virtual ~ polymorphic_base (){ }
} ;
struct polymorphic_derived : public polymorphic_base
{
} ;
polymorphic_base& identity_1 (polymorphic_base& r_)
{
std:: cout< < " Fonction identity_1 " ;
return r_;
}
struct non_polymorphic_base
{
~ non_polymorphic_base (){ }
} ;
struct non_polymorphic_derived : public non_polymorphic_base
{
} ;
non_polymorphic_base& identity_2 (non_polymorphic_base& r_)
{
std:: cout< < " Fonction identity_2 " ;
return r_;
}
int main ()
{
polymorphic_base b;
std:: cout< < " typeid(identity_1(b)) : " ;
typeid (identity_1 (b));
std:: cout< < " --\n " ;
non_polymorphic_base b2;
std:: cout< < " typeid(identity_2(b2)) : " ;
typeid (identity_2 (b2));
std:: cout< < " --\n " ;
return 0 ;
}
|
Ce code donne le résultat :
typeid(identity_1(b)) : Fonction identity_1 --
typeid(identity_2(b2)) : --
|
Notons que GCC donne un avertissement lorsqu'un l'expression
contient un appel non exécuté :
Avertissement GCC : |
warning : statement has no effect
|
XXII-B-3. La classe type_info
Le synopsis de cette classe est assez simple :
Synopsis de la classe type_info |
namespace std {
class type_info {
public :
virtual ~ type_info ();
bool operator = = (const type_info& rhs) const ;
bool operator ! = (const type_info& rhs) const ;
bool before (const type_info& rhs) const ;
const char * name () const ;
private :
type_info (const type_info& rhs);
type_info& operator = (const type_info& rhs);
} ;
}
|
type_info est dans l'espace de nom
std. Le constructeur par copie et
l'opérateur d'affectation étant privés, les objets retournés
par typeid ne sont pas copiables. Il n'est
donc pas possible de garder par valeur le retour
de typeid dans un autre objet ou dans
un conteneur. En revanche, on peut garder une référence
ou un pointeur sur l'objet type_info
retourné, la norme garantit que la durée de vie de cet
objet doit dépasser celle du programme (pas du processus !).
type_info::name() retourne une chaîne
de caractère terminée par un '\0' contenant le nom du
type. Ce nom est spécifique au compilateur :
Récupérer le nom d'un type : |
# include <typeinfo>
# include <iostream>
class A{ } ;
int main ()
{
std:: cout< < typeid (int ).name ()< < " \n " ;
std:: cout< < typeid (A).name ()< < " \n " ;
return 0 ;
}
|
Produit comme sortie avec GCC :
Noms des types avec GCC : |
|
Et avec Visual C++ :
Noms des types avec Visual C++ : |
|
type_info::before() permet de définir un
ordre sur les objets retournés. Cet ordre est spécifique
au compilateur.
XXII-B-4. Pourquoi récupérer le type dynamique ?
Il est fort à parier que si vous ressentez le besoin
de récupérer le type dynamique d'une variable ou d'une
expression, c'est que vous avez probablement une erreur de
conception de votre logiciel. En revanche,
typeid peut être utile en mode debug
et pour le log afin d'avoir des traces sur les objets
effectivement manipulés. En dehors de ces cas de debug et
de tests-U, rares sont les bonnes raisons d'avoir à le faire.
XXII-C. Comment connaître le type statique d'une variable ?
Cette question peut sembler incongrue puisque nous avons
toujours dit et vu que le type statique d'une variable
est celui que nous avons sous le nez en lisant le code.
Ecartons le typedef qui ne définit qu'un
synonyme d'un type et dont on peut retrouver facilement
le type effectivement désigné. Avec les fonctions et
les classes génériques (template) le type est un paramètre
et ne peut donc être connu immédiatement lors de son utilisation :
Avoir des informations sur le type statique : |
template < class T> void function (T const & t_)
{
}
|
Si l'utilisation de template suppose que justement le type n'a
pas d'importance, en pratique, on souhaite parfois avoir des
informations sur le type effectivement utilisé pour instancier
la fonction ou la classe générique. Pour cela, on s'appuie
sur des classes traits. Les classes traits utilisent
le mécanisme des classes génériques et des spécialisations pour
permettre d'obtenir des informations sur les types :
Les classes traits : |
# include <iostream>
template < class T> struct is_int
{
static const bool value = false ;
} ;
template < > struct is_int< int >
{
static const bool value = true ;
} ;
template < class T> void is_an_int (T const & )
{
std:: cout< < is_int< T> :: value< < " \n " ;
}
class A{ } ;
int main ()
{
int i;
A a;
double d;
std:: cout< < std:: boolalpha;
std:: cout< < " is_an_int(i) ? " ;
is_an_int (i);
std:: cout< < " is_an_int(a) ? " ;
is_an_int (a);
std:: cout< < " is_an_int(d) ? " ;
is_an_int (d);
return 0 ;
}
|
Les classes traits sont beaucoup utilisées dans la programmation
générique. Vous pouvez trouver une introduction aux classes
traits et aux classes de politiques dans :
Présentation des classes de Traits et de Politiques en C++,
par Alp Mestan.
Actuellement, le C++ possède quelques classes traits dans
la STL. Par exemple, la bibliothèque
string
contient des classes traits sur les types de caractères pour
définir des propriétés sur ceux-ci :
template<class charT> char_traits<charT>
est spécialisée avec
char et
wchar_t.
Cela permet de n'avoir qu'une seule classe
std::basic_string
et de s'appuyer sur ces classes traits pour définir les chaînes
ANSI (
std::string) ou UNICODE
(
std::wstring) :
Exemple de classe trait de la STL : |
namespace std {
template < typename _CharT, typename _Traits, typename _Alloc>
class basic_string;
typedef basic_string< char ,char_traits< char > > string;
typedef basic_string< wchar_t ,char_traits< < wchar_t > > wstring;
}
|
La bibliothèque
Boost.Type Traits
propose beaucoup de classes traits permettant d'avoir un nombre
appréciable d'informations.
|
C++0x : la nouvelle norme prévoir d'intégrer une collection
plus riche de classes traits. Beaucoup des ces classes qui
nécessitent aujourd'hui une bibliothèque comme Boost.Type Traits
seront avec les compilateurs conformes à cette nouvelle norme
directement disponibles avec le fichier d'en-tête
#include <type_traits>. Voici quelques
exemples tirés du draft N3000 de septembre 2009 (donc encore
susceptible d'évoluer) :
|
Exemples de classes traits en C++0x : |
namespace std {
template < class T> struct is_void;
template < class T> struct is_integral;
template < class T> struct is_floating_point;
template < class T> struct is_array;
template < class T> struct is_pointer;
template < class T> struct is_class;
template < class T> struct is_const;
template < class T> struct is_polymorphic;
template < class T> struct is_abstract;
}
|
XXII-D. Conversions entre type de base et type dérivé
XXII-D-1. Conversion du type dérivé vers le type de base
La conversion depuis le type dérivé vers le type
de base (upcast) est automatique en C++ dès lors
que l'héritage est public. Nous l'avons employé dans chacun des
exemples précédents sans que cela pose le moindre
problème :
L'upcast est automatique : |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
derived d;
base b = d;
base b2;
b2 = d;
base & rb = d;
base * pb = & d;
return 0 ;
}
|
base b = d; suppose que
base est constructible par copie. En
effet cette ligne appel le constructeur de copie de
base sur la nouvelle variable
b avec en paramètre d.
b2 = d; suppose que
base est assignable. En
effet cette ligne appel l'opérateur d'affectation
base sur la variable b2
avec en paramètre d.
Les deux autres lignes base &rb = d;
et base *pb = &d; ne supposent
rien sur le type base et définissent
une référence (respectivement un pointeur) dont
le type statique est base et le type
dynamique derived.
La conversion depuis un type dérivé vers un type de base
ne pose strictement aucun problème. S'il s'agit
de pointeurs ou de références alors la nouvelle variable
pointe/référence un objet du type dérivé au travers
d'un pointeur/référence de type statique de base. Si
la conversion va vers une valeur, bien sûr l'information
du type dérivé est perdue puisqu'un nouvel objet du
type de base est construit avec uniquement les
informations du type de base contenu dans l'objet
dérivée.
XXII-D-2. Conversion du type de base vers le type dérivé
Si vous venez du monde du C ou que vous avez eu un cours
mal appelé C/C++, vous aurez peut être le réflexe de faire
ça :
Les cast à la C : une erreur ! |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
derived d;
base & rb = d;
derived& rd = (derived& )d;
return 0 ;
}
|
Oubliez tout de suite ce genre de conversion car un jour
vous aurez ça :
Les casts à la C brisent la vérification de type : |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
derived d;
int i;
derived& rd = (derived& )i;
return 0 ;
}
|
Code qui compile parfaitement mais qui emmènera votre
application directement dans le mur.
Le C++ propose un premier opérateur de conversion entre
types compatibles : static_cast. Cet
opérateur vous préserve de l'erreur précédente :
Eviter les erreurs avec les opérateurs de transtypage du langage : |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
derived d;
int i;
derived& rd = static_cast < derived& > (i);
return 0 ;
}
|
Utilisé avec une valeur, l'opérateur
static_cast<T>(e) est valide si
la déclaration T t_(e) est valide.
Autrement dit, la conversion réussie s'il existe un
constructeur prenant un argument du type de
e ou d'un type pouvant être automatiquement
converti depuis e. Donc, en général,
vouloir affecter à une classe dérivée un objet
d'une classe de base provoque une erreur ce qui
est bien normale :
Le downcast static doit être possible : |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
base b;
derived d = static_cast < derived> (b);
return 0 ;
}
|
La conversion d'un type de base vers un type dérivé
(downcast)
peut être intéressante si le type dynamique du type
de base correspond au type dérivé cible. Cela peut donc
se faire avec les références et les pointeurs :
downcast statique pour les références et les pointeurs : |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
derived d;
base & rb = d;
derived & rd = static_cast < derived& > (rb);
return 0 ;
}
|
L'opérateur static_cast ne peut pas
s'appliquer si derived hérite
virtuellement de base. Le compilateur
signale une erreur.
A noter que si le type dynamique de rb
n'est pas derived (ou une classe
dérivant de derived), la conversion
est indéterminée, en d'autres termes c'est une erreur
d'exécution.
Cette erreur est silencieuse et le plantage peut avoir
lieu à n'importe quel moment, en particulier très loin
après le cast malheureux :
Le downcast statique peut poser problème : |
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
struct derived2 : public base
{
} ;
int main ()
{
derived d;
base b;
base & rb = b;
derived & rd = static_cast < derived& > (rb);
derived2 d2;
base & rb2 = d2;
derived & rd2 = static_cast < derived& > (rb2);
return 0 ;
}
|
Pour pallier à ces défauts, un autre opérateur existe
en C++ : dynamic_cast. Il fonctionne
de façon semblable à static_cast
si ce n'est qu'il vérifie à l'exécution
que le type dynamique de l'expression peut être converti
vers le type demandé. Comme le type dynamique est
sollicité, dynamic_cast ne s'applique
qu'aux pointeurs et aux références. Si la conversion
échoue avec une référence, alors l'opérateur soulève
une exception std::bad_cast définie
dans le fichier typeinfo. Cette
exception peut ainsi être récupérée et traitée :
Le downcast dynamique permet de gérer les problèmes du downcast statique : |
# include <typeinfo>
# include <iostream>
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
struct derived2 : public base
{
} ;
int main ()
{
derived d;
base & rb_1 = d;
try {
std:: cout< < " dynamic_cast<derived&>(base &rb_1 = derived d) : " ;
derived & rd = dynamic_cast < derived& > (rb_1);
std:: cout< < " Conversion valide\n " ;
}
catch (std:: bad_cast const & e_){
std:: cout< < " Erreur de dynamic_cast : " < < e_.what ()< < std:: endl;
}
base b;
base & rb_2 = b;
try {
std:: cout< < " dynamic_cast<derived&>(base &rb_2 = base b) : " ;
derived & rd = dynamic_cast < derived& > (rb_2);
std:: cout< < " Conversion valide\n " ;
}
catch (std:: bad_cast const & e_){
std:: cout< < " Erreur de dynamic_cast : " < < e_.what ()< < std:: endl;
}
derived2 d2;
base & rb_3 = d2;
try {
std:: cout< < " dynamic_cast<derived&>(base &rb_3 = derived2 d2) : " ;
derived & rd = dynamic_cast < derived& > (rb_3);
std:: cout< < " Conversion valide\n " ;
}
catch (std:: bad_cast const & e_){
std:: cout< < " Erreur de dynamic_cast : " < < e_.what ()< < std:: endl;
}
return 0 ;
}
|
Ce code produit la sortie suivante :
dynamic_cast<derived&>(base &rb_1 = derived d) : Conversion valide
dynamic_cast<derived&>(base &rb_2 = base b) : Erreur de dynamic_cast : std::bad_cast
dynamic_cast<derived&>(base &rb_3 = derived2 d2) : Erreur de dynamic_cast : std::bad_cast
|
Avec les pointeurs, l'erreur n'est pas remontée avec
une exception mais en retournant un pointeur nul :
Le downcast dynamique d'un pointeur retourne nul plutôt qu'une exception en cas d'erreur : |
# include <iostream>
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
struct derived2 : public base
{
} ;
int main ()
{
derived d;
base * pb_1 = & d;
std:: cout< < " dynamic_cast<derived*>(base *pb_1 = derived d) : " ;
derived * pd_1 = dynamic_cast < derived* > (pb_1);
if (pd_1){
std:: cout< < " Conversion valide\n " ;
}
else {
std:: cout< < " Echec de la conversion\n " ;
}
base b;
base * pb_2 = & b;
std:: cout< < " dynamic_cast<derived*>(base *pb_2 = base b) : " ;
derived * pd_2 = dynamic_cast < derived* > (pb_2);
if (pd_2){
std:: cout< < " Conversion valide\n " ;
}
else {
std:: cout< < " Echec de la conversion\n " ;
}
derived2 d2;
base * pb_3 = & d2;
std:: cout< < " dynamic_cast<derived*>(base *pb_3 = derived2 d2) : " ;
derived * pd_3 = dynamic_cast < derived* > (pb_3);
if (pd_3){
std:: cout< < " Conversion valide\n " ;
}
else {
std:: cout< < " Echec de la conversion\n " ;
}
return 0 ;
}
|
Le résultat obtenu :
dynamic_cast<derived*>(base *pb_1 = derived d) : Conversion valide
dynamic_cast<derived*>(base *pb_2 = base b) : Echec de la conversion
dynamic_cast<derived*>(base *pb_3 = derived2 d2) : Echec de la conversion
|
Appliqué à un pointeur nul, dynamic_cast
retourne un pointeur nul :
dynamic_cast fonctionne sur un pointeur nul : |
# include <iostream>
struct base
{
virtual ~ base (){ }
} ;
struct derived : public base
{
} ;
int main ()
{
base * pb_1 = 0 ;
std:: cout< < " dynamic_cast<derived*>(base *pb_1 = 0) : " ;
derived * pd_1 = dynamic_cast < derived* > (pb_1);
if (pd_1){
std:: cout< < " Conversion valide\n " ;
}
else {
std:: cout< < " Echec de la conversion\n " ;
}
return 0 ;
}
|
La sortie est :
dynamic_cast<derived*>(base *pb_1 = 0) : Echec de la conversion
|
dynamic_cast ne peut s'appliquer
que sur une expression dont le type est polymorphique :
dynamic_cast ne s'utilise qu'avec des types polymorphes : |
struct polymorphic_base
{
virtual ~ polymorphic_base (){ }
} ;
struct polymorphic_derived : public polymorphic_base
{
} ;
struct non_polymorphic_base
{
~ non_polymorphic_base (){ }
} ;
struct non_polymorphic_derived : public non_polymorphic_base
{
} ;
int main ()
{
polymorphic_derived pd;
polymorphic_base & rpd = pd;
polymorphic_derived & rpdd = dynamic_cast < polymorphic_derived & > (rpd);
non_polymorphic_derived npd;
non_polymorphic_base & rnpd = npd;
non_polymorphic_derived & rnpdd = dynamic_cast < non_polymorphic_derived & > (rnpd);
return 0 ;
}
|
Enfin, dynamic_cast échoue si l'héritage
n'est pas public (héritage privée ou protégé) ou
s'il est ambigüe (héritage multiple).
XXII-D-3. Pourquoi faire une conversion d'un type de base vers un type dérivé
95% des cas où vous avez besoin de convertir un pointeur
ou une référence depuis une classe de base vers
un type dérivé traduisent une erreur de conception.
Je ne parlerai pas des 5% restant pour ne pas semer
le trouble. Si le downcast vous titille, visiter
le forum C++ pour confirmer votre besoin ou refroidir
vos ardeurs.
XXIII. Comment ça marche ?
XXIII-A. Qui définit la mise en oeuvre ?
La norme C++ et donc le langage ne précisent absolument pas
comment doit être mis en oeuvre la résolution dynamique
des appels de fonctions virtuelles. Le langage se contente
de définir le comportement attendu. A charge des compilateurs
de trouver des mécanismes appropriés pour atteindre cet objectif.
Ce qui suit précise les solutions habituellement utilisées
par les compilateurs. Il existe peut être des compilateurs ayant
opté pour d'autres solutions (je n'en connais pas) ou dans
l'avenir d'autres techniques émergeront. En ce sens, tout
ce qui suit concerne plus des détails d'implémentation que
des comportements imposés par la norme.
XXIII-B. Résoudre l'appel dynamiquement : les tables virtuelles
La compilation traduit le code source en code exécutable,
c'est à dire en une suite d'instructions machines directement
exécutées par le processeur. Une fonction est donc compilée
en une suite continue d'instructions à une adresse donnée, fixe
et connue à la compilation.
Cette adresse est l'adresse de la fonction. Lorsqu'un
appel vers une fonction est résolue à la compilation,
le compilateur génère une instruction spéciale (un saut)
qui demande au processeur de continuer son exécution
à l'adresse indiquée. Cette adresse est connue à la compilation,
et c'est l'éditeur de lien qui a la charge de mettre en
dur l'adresse du saut :
pseudo code assembleur d'appel d'une fonction non virtuelle : |
aller à 0x12345678 (0x12345678 est l'adresse de la fonction)
|
Les nouvelles instructions, celle de la
fonction, sont alors lues à partir de cette adresse
et exécutées par la machine.
Pour une résolution dynamique d'une fonction virtuelle,
le compilateur ne sait pas vers quelle adresse le saut
doit être fait car cela dépend du type dynamique connu
uniquement à l'exécution. Pour résoudre ce problème, les
compilateurs - sans que ce soit une obligation mais je ne
connais pas d'autres méthodes - utilisent une indirection :
la table des fonctions virtuelles. Les mots
vtable, v-table, dispatch table sont
aussi souvent employés pour désigner la table des fonctions
virtuelles.
Cette table contient autant d'entrées que de fonctions
virtuelles, chaque entrée contenant l'adresse d'une fonction.
A chaque classe est associée une telle table avec les adresses
des fonctions virtuelles lui correspondant : celle de la classe
de base si la fonction n'est pas spécialisée, sinon
celle de sa spécialisation.
Pour le code suivant :
Tables des fonctions virtuelles : |
struct base
{
virtual void function_1 ();
virtual void function_2 ();
virtual ~ base ();
} ;
struct derived : public base
{
virtual void function_1 ();
virtual ~ derived ();
} ;
|
Les tables virtuelles sont :
Entrée de la table |
base |
derived |
0 |
base::function_1 |
derived::function_1 |
1 |
base::function_2 |
base::function_2 |
2 |
base::~base |
derived::~derived |
Entrées de la table des fonctions virtuelles pour une classe de base et sa classe dérivée.
Le compilateur est chargé de construire ces tables. Une
fonction virtuelle donnée est toujours au même emplacement
dans la table des fonctions virtuelles dans tout l'arbre
d'héritage. Dans notre exemple, function_1
est à l'emplacement 0 pour la classe
de base et pour la classe dérivée. Si une classe dérivée
ajoute de nouvelles fonctions virtuelles alors celles-ci
sont mises à la suite des fonctions précédentes :
Pour le code suivant :
Tables des fonctions virtuelles : |
struct base
{
virtual void function_1 ();
virtual void function_2 ();
virtual ~ base ();
} ;
struct derived : public base
{
virtual void function_1 ();
virtual void function_3 ();
virtual ~ derived ();
} ;
|
Les tables virtuelles sont :
Entrée de la table |
base |
derived |
0 |
base::function_1 |
derived::function_1 |
1 |
base::function_2 |
base::function_2 |
2 |
base::~base |
derived::~derived |
3 |
- |
derived::~function_3 |
Entrées de la table des fonctions virtuelles pour une classe de base et sa classe dérivée.
L'appel d'une fonction virtuelle consiste alors à aller
chercher dans cette table l'adresse de la fonction
vers laquelle faire le saut. En pseudo code, les appels
ressemblent à ça :
Fonction non virtuelle |
Fonction virtuelle |
- Empiler l'adresse de retour ;
- empiler les paramètres ;
- aller à l'adresse de la fonction 0x12345678 (saut).
|
- Empiler l'adresse de retour ;
- empiler les paramètres ;
- récupérer la table virtuelle ;
- lire l'adresse de la fonction dans l'entrée i de la table virtuelle ;
- aller à l'adresse lue (saut).
|
L'appel d'une fonction virtuelle a par conséquent un coût
à l'exécution puisqu'il faut d'abord récupérer l'adresse
de la fonction vers laquelle faire le saut. L'appel d'une
fonction virtuelle est donc légèrement plus lent que l'appel
d'une fonction non virtuelle. Ce coût n'est pas prohibitif
en regard des avantages du mécanisme de résolution dynamique
des fonctions virtuelles. Généralement le coût est relativement
faible face à la durée d'exécution de la fonction. Enfin,
avec ces tables virtuelles, ce coût est constant quelque
soit la profondeur de l'héritage ou le nombre de fonctions
virtuelles car l'index dans le tableau est connu à la compilation.
Les tables des fonctions virtuelles sont associées aux classes.
Dès lors, comment retrouver la table virtuelle d'un objet ?
Chaque objet instance d'une classe polymorphe contient
un pointeur caché, vpointer,
vers la table des fonctions virtuelles de son type dynamique.
Pour s'en convaincre, le test suivant montre que la taille
d'une classe change selon qu'elle est polymorphe ou non.
Cette différence est due au pointeur vers la table
des fonctions virtuelles :
Un pointeur caché vers une table virtuelle est ajoutée pour les classes polymorphes : |
# include <iostream>
struct non_polymorphic_type
{
} ;
struct polymorphic_type
{
virtual ~ polymorphic_type ();
} ;
int main ()
{
std:: cout< < " sizeof(non_polymorphic_type) = " < < sizeof (non_polymorphic_type)< < " \n " ;
std:: cout< < " sizeof(polymorphic_type) = " < < sizeof (polymorphic_type)< < " \n " ;
return 0 ;
}
|
Ce code produit comme sortie :
sizeof(non_polymorphic_type) = 1
sizeof(polymorphic_type) = 4
|
La classe sans membre virtuel n'a pas une taille de zéro
car le C++ impose que tous les types aient une taille non
nulle.
Les 4 octets de la classe polymorphe correspondent avec
mon Windows 32 bits à la taille d'un pointeur : 4 octets
(et non pas 5, car de par la table virtuelle la classe n'a
plus une taille de 0 nécessitant un octet fictif).
Lorsqu'il y a héritage multiple, pour maintenir la contrainte
d'utilisation du même index dans la table des fonctions
virtuelles, plusieurs tables sont associées à la classe
dérivée :
En cas d'héritage multiple, plusieurs tables virtuelles peuvent être ajoutées : |
# include <iostream>
struct base_1
{
virtual ~ base_1 (){ }
} ;
struct base_2
{
virtual ~ base_2 (){ }
} ;
struct derived : public base_1, public base_2
{
virtual ~ derived (){ }
} ;
int main ()
{
std:: cout< < " sizeof(base_1) = " < < sizeof (base_1)< < " \n " ;
std:: cout< < " sizeof(base_2) = " < < sizeof (base_2)< < " \n " ;
std:: cout< < " sizeof(derived) = " < < sizeof (derived)< < " \n " ;
return 0 ;
}
|
La sortie produite est :
sizeof(base_1) = 4
sizeof(base_2) = 4
sizeof(derived) = 8
|
-
sizeof(base_1) = 4 : 4 octets pour l'adresse
de la table des fonctions virtuelles de
base_1.
-
sizeof(base_2) = 4 : 4 octets pour l'adresse
de la table des fonctions virtuelles de
base_2.
-
sizeof(derived) = 8 : 4 octets pour
l'adresse de la table des fonctions virtuelles
base_1, et 4 octets pour
l'adresse de la table des fonctions virtuelles
base_2.
XXIII-C. Quelle entrée pour les fonctions virtuelles pures ?
Nous avons vu que le type dynamique d'une variable ne
peut jamais correspondre à une classe abstraite. Autrement
dit, l'entrée de la vtable de la classe
abstraite pour une fonction virtuelle pure n'est jamais
lue pour trouver l'appel correspondant... Les compilateurs
utilisent généralement 3 stratégies :
- laisser l'entrée initialisée : tenter de la déréférencer provoque une erreur ;
- positionner l'entrée à un pointeur nul : tenter de la déréférencer provoque une erreur ;
- positionner l'entrée vers une fonction spéciale proposée par le compilateur : en général, cette fonction affiche un message d'erreur et provoque la terminaison du programme.
XXIII-D. Comment sont construites les tables virtuelles ?
Les tables de fonctions virtuelles sont de simples tableaux
statiques générés par le compilateur pour chaque classe
contenant des fonctions virtuelles. Il existe donc un tableau
par classe indépendamment des objets instanciés.
Les tables de fonctions virtuelles peuvent être vues comme
des membres statiques d'une classe : elles sont partagées
par toutes les instances de la classe. Le compilateur possède
toutes les informations nécessaires à l'initialisation de
ces tables. Le compilateur ajoute donc ces tables correctement
initialisées dans le code du programme. En revanche, pour
un objet instance d'une classe polymorphe, le compilateur doit
ajouter le code nécessaire pour initialiser correctement le
pointeur caché (vpointer) vers la bonne table. Pour cela,
le compilateur ajoute dans le constructeur d'une classe
un morceau de code après l'appel des constructeurs des classes
de bases et avant d'entrer dans le constructeur en cours pour
faire pointer le vpointer vers le tableau des fonctions
virtuelles de la classe dont le constructeur va être déroulé.
Ainsi, dans le constructeur, le vpointer pointe sur la table
de la classe en cours. Si un appel vers une fonction
virtuelle est demandé, la résolution prend l'entrée dans
la table de la classe en cours et c'est bien l'appel de la
fonction dont le constructeur est en train d'être exécutée
qui est appelée. Cela est conforme au comportement décrit
par la norme tel que nous l'avons présenté plus tôt.
Avec l'exemple suivant :
Construction des tables virtuelles : |
# include <iostream>
struct base_1
{
base_1 ()
{
function_1 ();
function_2 ();
}
virtual void function_1 ()
{
std:: cout< < " base_1::function_1\n " ;
}
virtual void function_2 ()
{
std:: cout< < " base_1::function_2\n " ;
}
virtual void function_3 ()= 0 ;
virtual ~ base_1 (){ }
} ;
struct base_2 : public base_1
{
base_2 () {
function_1 ();
function_2 ();
}
virtual void function_1 ()
{
std:: cout< < " base_2::function_1\n " ;
}
virtual ~ base_2 (){ }
} ;
struct base_3 : public base_2
{
base_3 () {
function_1 ();
function_2 ();
function_3 ();
}
virtual void function_1 ()
{
std:: cout< < " base_3::function_1\n " ;
}
virtual void function_2 ()
{
std:: cout< < " base_3::function_2\n " ;
}
virtual void function_3 ()
{
std:: cout< < " base_3::function_3\n " ;
}
virtual ~ base_3 (){ }
} ;
struct derived : public base_3
{
derived () {
function_1 ();
function_2 ();
function_3 ();
}
virtual void function_1 ()
{
std:: cout< < " derived::function_1\n " ;
}
} ;
int main ()
{
derived d;
return 0 ;
}
|
4 tables de fonctions virtuelles sont générées. Appelons-les :
Entrée de la table : |
base_1 : vtable_1 |
base_2 : vtable_2 |
base_3 : vtable_3 |
base_derived : vtable_derived |
[0] |
base_1::function_1 |
base_2::function_1 |
base_3::function_1 |
derived::function_1 |
[1] |
base_1::function_2 |
base_1::function_2 |
base_3::function_2 |
base_3::function_2 |
[2] |
- |
- |
base_3::function_3 |
base_3::function_3 |
Regardons maintenant la valeur du vpointeur
lors de la construction :
- Juste avant d'entrée dans base_1::base_1 :
vpointer = vtable_1
- Juste avant d'entrée dans base_2::base_2 :
vpointer = vtable_2
- Juste avant d'entrée dans base_3::base_3 :
vpointer = vtable_3
- Juste avant d'entrée dans derived::derived :
vpointer = vtable_derived
Avec cette liste et le tableau précédent, vous avez
tous les éléments pour prévoir la séquence de trace
provoquée par l'instanciation de la variable d
de la classe derived dans le
main.
Si in-fine l'appel d'une fonction virtuelle pure
dans le constructeur est indéterminée, au regarde de ce
que nous venons de voir, on comprend mieux les différents
comportements :
- Appel direct d'une fonction virtuelle pure
sans définition : nous avons vu que les compilateurs
choisissent une résolution statique de l'appel. Donc,
la fonction n'existant pas, une erreur de lien est
obtenue.
- Appel indirect d'une fonction virtuelle pure
sans définition : avec un appel indirect, le compilateur
doit utiliser la vtable pour résoudre
l'appel. Or, l'entrée pour la fonction virtuelle
est renseignée avec l'adresse d'une fonction spéciale.
A l'exécution, le message d'erreur est affiché et
le programme est terminé.
- Appel direct d'une fonction virtuelle pure
avec définition : comme pour le premier cas, l'appel
direct est optimisé par le compilateur et un saut
vers la définition de la fonction est ajouté. Cela
se passe donc sans problème.
- Appel indirect d'une fonction virtuelle pure
avec définition : comme dans le second cas, la table
virtuelle est utilisée et la fonction spéciale
d'affichage d'erreur et de terminaison est appelée.
XXIII-E. Comment sont détruites les tables virtuelles ?
La destruction d'une variable suit le processus inverse
de la construction : à l'entrée du destructeur,
le vpointer est redirigé vers
la classe dont le destructeur va être exécuté. Le
comportement est donc identique que pour le constructeur :
les fonctions virtuelles (non pures) appelées sont celles
de la classe dont le destructeur est en train de s'exécuter
et les fonctions virtuelles pures dans les classes abstraites
provoquent l'appel de la fonction spéciale de trace
et de terminaison.
XXIII-F. Qu'est-ce qu'un pointeur de fonction virtuelle ?
Les adresses des fonctions virtuelles peuvent être récupérées
tout comme celles des adresses des fonctions non virtuelles :
Récupérer l'adresse d'une fonction : |
# include <iostream>
struct base
{
void non_virtual_function ()
{
std:: cout< < " base::non_virtual_function\n " ;
}
virtual void virtual_function ()
{
std:: cout< < " base::virtual_function\n " ;
}
} ;
int main ()
{
base b;
void (base:: * p_function)(void );
p_function = & base:: non_virtual_function;
(b.* p_function)();
p_function = & base:: virtual_function;
(b.* p_function)();
return 0 ;
}
|
Pour une fonction non virtuelle, l'affaire est simple :
l'adresse positionnée dans p_function
est donnée en dur à la compilation avec la fonction
générée. En revanche, rien de tel ne peut être fait
avec une fonction virtuelle car l'adresse est résolue
dynamiquement à l'exécution.
Visual C++ créé ce qu'on appelle un thunk. Il s'agit
d'une fonction qui prend en paramètre un objet de
la classe et appelle la fonction virtuelle à l'aide de
la vtable. Dans notre exemple, il créé une fonction non
virtuelle vcall_0 et donne son adresse
à p_function. vcall_0
ressemble à ça :
Chunk : |
void vcall_0 (base & rb_)
{
rb_.virtual_function ();
}
|
A chaque fonction virtuelle est associée un thunk avec
la même signature (respectant les paramètres, le type
retour, la constance) pour permettre cet appel.
De ce que j'ai compris, GCC utilise une méthode un peu
plus subtile. Elle se base sur le fait que les adresses
de fonctions sont alignées sur 4 octets. Donc elles
sont toujours paires. Pour une fonction virtuelle,
p_function reçoit le décalage
en octet de l'entrée dans la vtable incrémenté de 1. Par
exemple, pour la fonction virtuelle de la première entrée,
la variable reçoit 1. Pour la fonction virtuelle de la
troisième entrée, la variable reçoit (3-1)*4 + 1 = 9. 3,
pour la troisième entrée, -1 car les tableaux commencent avec
l'index 0, *4 car les pointeurs sont sur 32 bits.
Donc un pointeur de fonction désignant une fonction virtuelle
a une valeur impaire. Lors de l'appel effectif de la fonction
sur un objet, GCC commence par tester la parité du pointeur
de fonction. Si la valeur du pointeur est paire, c'est
une fonction non virtuelle, l'adresse utilisée correspond à
cette valeur. Si la valeur du pointeur est impaire, GCC
récupère la table des fonctions virtuelles de l'objet,
décrémente la valeur du pointeur de 1, se décale pour
atteindre l'entrée correspondante et récupère l'adresse
effective à exécuter.
Visual C++ introduit un coût supplémentaire pour l'appel
de fonctions virtuelles par pointeur de fonction car un saut
supplémentaire est utilisé vers le thunk. GCC ne
génère pas de fonctions thunk spécifiques, en revanche
il introduit une pénalité pour chaque appel de fonction non
virtuelle via un pointeur de fonction puisque la parité
est systématiquement testée pour savoir quelle action
entreprendre. L'appel de fonctions non virtuelles avec
un pointeur de fonction n'a pas de surcoût avec Visual C++.
2 compilateurs, 2 choix différents. Probablement que
d'autres compilateurs ont fait des choix encore
différents de ceux-là.
XXIV. A retenir
|
Par défaut, en C++, les fonctions membres ne sont pas virtuelles.
Le mot-clé virtual est nécessaire pour définir
une fonction virtuelle.
|
|
L'appel d'une fonction virtuelle sur un pointeur ou une référence
utilise le type dynamique et est résolu à l'exécution.
|
|
Le type statique d'un pointeur ou d'une référence est celui
défini dans le code. Le type dynamique est celui de l'objet
effectivement pointé/référencé.
|
|
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.
|
|
Un destructeur public et non virtuel indique que la classe
ne doit pas être utilisée pour un héritage public.
|
|
Une fonction virtuelle ne peut être inlinée dans un appel
polymorphe.
|
|
Appeler une fonction virtuelle dans un constructeur ou un
destructeur 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.
|
|
L'appel d'une fonction virtuelle pure dans le constructeur
ou le destructeur d'une classe abstraite produit un
comportement indéterminé.
|
|
Evitez d'introduire des masquages de fonctions.
|
|
Une fonction générique (template) ne peut pas
être virtuelle.
|
|
Une classe générique peut avoir des fonctions membres
virtuelles.
|
|
L'amitié ne s'hérite pas : déclarer amie une fonction virtuelle
est faiblement utile.
|
|
Une fonction virtuelle ne peut s'engager à lever plus
d'exceptions que celles précisées dans la définition la plus
spécialisée de ses classes de base.
|
|
Les préconditions d'une fonction virtuelle dans la classe
dérivée ne peuvent être plus restrictives que celle de la
définition la plus spécialisée de ses classes de base.
En revanche, elles peuvent être plus lâches autorisant
l'utilisation de la spécialisation avec le type dérivé
là où la classe de base ne sait pas faire.
|
|
Les postconditions d'une fonction virtuelle dans la classe
dérivée ne peuvent être plus lâches que celle de la
définition la plus spécialisée de ses classes de base.
En revanche, elles peuvent être plus
restrictives.
|
|
En sortie d'une fonction virtuelle, les invariants de la
classe dérivée et de la classe de base doivent être
respectés.
|
|
Un transtypage d'un pointeur ou d'une référence depuis
la classe de base vers la classe dérivée est souvent
signe d'un problème de conception.
|
XXV. Un peu de lecture
Normes et livres :
Articles :
- Programmation par contrat, application en C++, de Julien Blanc
- Open Multi-Methods for C++, de Peter Pirkelbauer, Yuriy Solodkyy et Bjarne Stroustrup (2007)
- Présentation des classes de Traits et de Politiques en C++, de Alp Mestan (2007)
- "Pure Virtual Function Called": An Explanation, de Paul S. R. Chisholm (2007)
- Le principe "ouvert/fermé", d'Emmanuel Deloget (2006)
- Never Call Virtual Functions during Construction or Destruction, de Scott Meyers (2005)
- Implicit Virtual, de Herb Sutter et Jim Hyslop (2005)
- Adaptations, de Herb Sutter et Jim Hyslop (2004)
- Virtually Misbehavin'. C++ implicit overriding at work de Jim Hyslop et Herb Sutter (2004)
- Virtuality, de Herb Sutter (2001)
- Virtually Yours, de Jim Hyslop et Herb Sutter (2000)
- Multiple Inheritance for C++, de Bjarne Stroustrup (1999)
- The Open-Closed Principle, de Robert C. Martin (1996)
XXVI. Remerciements
Je remercie
Alp,
r0d et
Goten
pour leurs relectures et leurs conseils avisés et ainsi que
Luc Hermitte
dont la compétence, l'étendue des connaissances et l'attention aux
petits détails n'ont d'égales que la patience, la persévérance
et la générosité à les partager. Sans eux imprécisions,
approximations, oublis et erreurs seraient bien trop nombreux
dans cet article.
Encore merci à
dourouc05
et à
koala01
pour la grand-mère et l'aurteaugraffe.
Enfin, d'une façon plus globale, je remercie les membres des forums de développez.com qui,
par la qualité de leurs interventions, m'ouvrent constamment de nouvelles pistes
de réflexion sur ma pratique de développement.
(1) |
Dans cet article, les termes classes et structures sont employés
indifféremment. En effet, la seule différence en ce qui nous
concerne entre class et struct
est la visibilité par défaut : publique pour les membres
et l'héritage pour les types définis avec struct
et privée pour les membres et l'héritage pour les types définis
avec class. Pour tous ce que nous allons dire
par la suite, la chose est strictement identique que le type
soit struct ou class. Cf.
F.A.Q. :
Quelle est la différence entre class et struct ?
|
(2) |
Attention, le mot-clé virtual peut être
utilisé pour l'héritage virtuel afin de résoudre
le problème des classes de bases communes (héritage en
losange) :
Qu'est-ce que l'héritage virtuel et quelle est son utilité ?
Cela n'a strictement aucune influence sur les
fonctions virtuelles.
|
(3) |
On lira à profit les précisions de la F.A.Q. :
Qu'est-ce que le polymorphisme ? et
Qu'est-ce que la surcharge ?
|
(4) |
Bien sûr, la visibilité des fonctions (publique, protégée ou
privée) combinée à celle de l'héritage limite les cas où
l'appel est valide. On lira avec profit l'entrée de la
F.A.Q. suivante :
Que signifient public, private et protected ?
|
(5) |
Par facilité d'écriture, dans ce document, pour une variable
Type *p_var, on dira que son type est
Type alors que formellement p_var est
de type Type* (pointeur de Type)
et c'est *p_var qui est de type Type
. De même, pour les références Type &
r_var, on dira que le type de r_var
est Type et non Type&.
Enfin, de la même façon on fera pudiquement l'impasse sur
les qualifications const et/ou
volatile qui peuvent préciser le type réel d'une
variable.
|
(6) |
Le principe ouvert/fermé ou OCP (open/close principle
pour les amis de Lord Byron) énonce qu'une classe doit
être ouverte à l'extension et fermée à la modification.
Apparemment contradictoire, ce principe s'appuie justement
sur le mécanisme des fonctions virtuelles pour permettre
l'extension par les classes dérivées (ouverture) mais cela
doit se faire sans modifier le code existant (fermeture).
Pour plus d'information, le billet
Le principe "ouvert/fermé"
d'Emmanuel Deloget propose une présentation
de ce principe. The Open-Closed Principle
est une présentation de ce même principe en anglais.
|
(7) |
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.
|
(8) |
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 !
|
(9) |
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.
|
(10) |
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 ?
|
(11) |
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).
|
(12) |
Il est important de noter que dans ces trois cas l'appel est
bien dynamique. C'est à dire que c'est à l'exécution que
le lien vers base::function est effectué et non
à la compilation. En anticipant un peu, nous pouvons dire que
ces appels s'appuient sur la vtable dont l'entrée pour
(virtual) function pointe vers
base::function.
|
(13) |
La programmation par contrat a été introduite et
développée par Bertrand Meyer dans son livre
Conception et programmation orientées objet dont la
lecture est toujours recommandée.
|
(14) |
Dans toute cette section il est supposé que les fonctions
sont appelées avec un objet par référence ou par pointeur
afin de faire jouer le mécanisme de résolution dynamique
de l'appel des fonctions virtuelles. Sans quoi,
la fonction de la classe de base est toujours
appelée ce qui ne nous intéresse pas dans ce cas.
|
(15) |
Les préconditions sont bien sûr non contradictoires : pour
être vraie Pi ne doit pas nécessité que Pj soit fausse.
|
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.