|

Écrivez
à AILES ! |

Retour
vers programmation |

Retour
vers les questions C++ |

Retour
vers les questions sur
l'héritage multiple |
|
|
Questionnaire C++
Héritage multiple
[AnnotatedC++], pp.
198 et suiv.
|
Dans le monde très statique de
l'héritage, l'héritage multiple fait figure de
casse-tête : dès que l'on dépasse deux ou
trois niveaux d'héritage, dont certains sont
multiples, on considère pensivement la classe
fille terminale en se demandant songeusement : «
mais qu'est-ce que c'est que ce machin ??? ».
Et pourtant... Peut-il servir utilement ? Quelles
sont, sur un plan purement technique, ses dangers
?
Voici toutes les réponses! |
A
quoi sert l'héritage multiple ?
Une vision saine de l'héritage
multiple consiste à l'aborder sous l'angle suivant :
« Ajouter à une classe
fille un ensemble de propriétés qui complètent ses
caractéristiques propres sans remettrent en causes les
caractéristiques dont elle héritait déjà par ailleurs».
Par "caractéristique", on entend ses services
(méthodes) et ses attributs. Comme il est de bon ton de n'accéder à ces derniers que par des accesseurs (get et
set), cela revient à ne parler que de méthodes.
Classe de base :
Dériver deux fois de la même classe n'est pas possible
directement, mais l'est en revanche de façon indirecte.
Le bon diagramme est dans ce cas :

On remarquera que L est répliqué
en mémoire. Cela évite toute ambiguïté lorsque l'on accède à une donnée membre de
L : on y accède via A ou via B et non directement.
Si une classe héritait deux fois d'une même classe de
base, il y aurait ambiguïté dès que l'on voudrait accéder à une des variables membres de cette classe de
base...
Dans cet esprit, il devient évident qu'on ne peut caster
directement D en L. Il faut passer par un cast intermédiaire afin de savoir sur quel L on va tomber :
L* p_l = (L*)(A*)p_d2 ; ou
L* p_l = (L*)(B*)p_d2 ;
Héritage virtuel
Le graphe se présente ainsi :

Cette fois B n'est pas répliqué en mémoire en tant que
classe mère de X et de Y, mais il l'est pour Z.
Tous le problème dans ce type de configuration consiste
à éviter que l'appel d'une fonction virtuelle f()
n'engendre 2 fois l'appel de la-dite fonction B::f().
si l'on considère la solution proposée dans l'énoncé
de cette question ()
void DDD::f()
{
...
X::f() ; Y::f() ; Z::f() ; // ? suffisant ?
},
on se rend bien
compte que si X::f() appelle B::f() et si Y::f() en fait
autant, on a un problème...
La vraie solution consiste (par exemple) à décomposer
f() en deux :
- une fonction protected _f() qui ne fait que la partie
spécifique à la classe et rien de plus.
- une fonction publique f() qui se contente d'appeler
_f() sur *toutes* ses classes de base
void DDD::f()
{
_f() ; // réalise la
partie propre à DDD
// appels des parties
spécifiques à *toutes* ses classes de base :
X::_f() ; Y::_f() ;
Z::_f() ; B::_f() ;
}
Ambiguités
Si l'on considère le diagramme
suivant :

voici les réponses :
void
D:: g()
{
v++; // OK : v n'est
représenté qu'une fois en mémoire
a++; // AMBIGÜE : B::a
ou C::a ?
x++; // OK : B::x
"domine"
V::x
f() ; // OK : B::f()
"domine"
V::f()
static_a = 1 ; // OK
: static_a est static, commun à toutes les instances de
A
int i = e ; // OK
: l'enum e n'est représenté qu'une fois en mémoire
B* p_b = this ; // OK
A* p_a = this ; // AMBIGÜE
: le A est-il celui contenu par B ou par C ?
V* p_v = this ; // OK
: un seul V en mémoire est concerné
int my_vvv = vvv ; // AMBIGÜE
: C::vvv ou V::vvv ?
}
B::x ou B::f dominent leurs
homologuent de V car :
- B a pour classe de base V
- x et f() ne sont pas trouvées dans d'autres classes
(comme C, par exemple)
Si f avait été une fonction virtuelle, c'est bien
B::f() qui aurait été appelée...
On remarquera que si B et C dérivaient de V de façon
non-virtuelle, x++ et f() utilisés dans D::g() seraient,
pour le coup, ambigus...
void ggg(
B* p_b, DDD* p_ddd )
{
p_b->f() ; // B::f() ;
p_ddd->f() ; // B::f() ?!
}
Là encore, si f avait été une fonction virtuelle,
c'est bien B::f() qui aurait été appelée... dans tous
les cas!
Pourquoi, alors, considérer que p_b->f() appelle bien
B::f() et p_ddd->f() appelerait V::f() (sous prétexte
que V est "plus proche" de DDD que B) ? C'est
donc normal que p_ddd->f() appelle B::f().
Dans le cas de C::vvv et V::vvv, on remarquera que
l'ambigüité se juge sur le nom et pas sur le type ou
l'accessibilité d'une variable (ou d'une fonction).
Fonctions virtuelles
class
V
{
public :
static virtual int sf() ; // impossible
: la table des fonctions virtuelles est créée pour
chaque instance de l'objet
virtual int f() ;
virtual int g( V* ) ;
virtual V* h() ;
};
class A : public V
{
int f() // f()
est virtuelle : il suffit de la déclarer une seule fois
dans une de ses classes de base!
{
f() ; // aïe,
aïe, aïe... appel réccursif de A::f()!
};
virtual int g( A* ) ; // A::g( A* ) cache
V::g( V* ) : ce sont 2 fonctions différentes
virtual A* h() ; // A::h n'est pas égal
à V::h(). Ca plante à la compilation.
};
*
Dans le cas de la fonction g(), si le compilateur
considérait que A::g() est bien la même fonction
(virtuelle) que V::g(), rien n'empêcherait alors de
faire :
A* p_a = new A() ;
V* p_v = p_a ;
V* p_vArg = new V() ;
p_v->g( p_vArg ) ;
Si
g était vraiment virtuelle, la fonction g() appelée
serait donc A::g(). Mais on aurait alors A::g() appelée
avec en paramètre un objet de type V et non A, d'où
crash immédiat! Surtout si A::g() essaie d'appeler des
méthodes propres à A (classe dérivée) alors que
l'objet passé en paramètre est de type V (classe de
base).
* Dans le cas de la fonction h(), si le compilateur
considérait que A::h() est bien la même
fonction (virtuelle) que V::h(), rien n'empêcherait
alors de faire :
A* p_a = new A() ;
A* p_a2 = p_a->h() ; au lieu de p_a2 = (A*)
p_a->h() si h() avait correctement été redéfini
dans la classe A comme étant égale à V* h() ;
Et alors ?
Et
alors, cela compliquerait singulièrement le mécanisme
de retour de la fonction h(), car, pour les deux fonction
V::h et A::h (censées être identiques), le pointeur
retourné serait différent!...
en clair : si vous faite V* p_v = (V*) p_a et que vous
comparez p_v et p_a, vous constaterez une différence
(dûe à l'offset mémoire entre la base de V et celle de
A, cf. dernière rubrique de ce test pour tous les
détails).
En n'autorisant qu'une seule signature : virtual V* h() ;
on est tranquille : le pointeur sera toujours le
résultat d'un cast implicite en V*, il sera donc
toujours le même.
Pour
résumer tout cela, la signature d'une fonction virtuelle
dans une classe dérivée doit être exactement
la même que celle de sa classe origine de base. (ça vous le saviez
sans doute déjà, mais vous en ignoriez peut-être les
justifications qui viennent de vous être
présentées...)
Le code :
main()
{
C c ; // affiche 'A::f()'
c.g() ;// affiche 'C::f()'
}
Lorsque C se construit, il commence par construire B qui
, lui, commence par construire A.
Une fois A construit, B exécute le contenu de son
constructeur.
A
ce stade, la seule fonction f() "disponible" (c'est-à-dire appartenant à un
objet complétement construit) est A::f().
c.g() en revanche fait bien appel à C::f(), fonction virtuelle de
même signature que A::f() et de plus bas niveau dans la
hiérarchie d'héritage.
void ma_fonction(
ABL* p_abl )
{
BL* p_bl = (BL*)p_abl ;
p_bl->f() ; //
AL::f() !!!
}
Le
résultat peut sembler étonnant puisque AL n'est même
pas sur la même branche d'héritage que BL. Et pourtant,
la réponse à la dernière question de la rubrique
suivante démontrera qu'en mémoire se trouve tous les
renseignements nécessaires pour retrouver le début de
la bonne fonction...
Représentation en mémoire
Les
2 mon capitaine!
A retenir : un compilateur ne garanti
pas l'ordre interne de représentation en mémoire de
classes de base et de la classe dérivée...
p_c->fb()
se traduit par :
( (B*)
( (char*)p_c + delta(B) )
)->fb()
En effet, la représentation mémoire interne est la
suivante :

donc p_c
et (B*)p_c n'ont pas des adresse mémoires identiques...
On constatera bien que les adresses mémoires de p_c et
de p_b (= (B*)p_c) sont différentes.... pourtant,
la comparaison (p_c == p_b) reste true!!!
Pourquoi
? Parce qu'elle est interprétée comme : ( (B*)p_c ==
p_b ) !
Le code suivant marche bien : (p_b vaut bien 0 même
après cast implicite de p_c en B*, ce qui aurait dû lui
rajouter un offset "delta(B))
C*
p_c = 0 ;
B* p_b = 0 ;
if( p_c == 0 ) { /* ... */ }
p_b = p_c ;
if( p_b == 0 ) { /* ... */ }
En
effet, le cast implicite d'un C* en B* est traité ainsi
:
p_b = ( p_c == 0 ) ? 0 : (B*) ( (char*)p_c + delta(B) ) ;
La représentation en mémoire de l'instance de c
dérivant de façon virtuelle de L est :

A et B conservent dans leur table virtuelle un offset
leur permettant d'accéder à la partie L commune à
l'objet C.
Si C héritait aussi de D (en plus de A et B) et que D
héritait de façon non virtuelle de L, on aurait en
mémoire, pour un objet C, deux représentation de L :
- celle commune à A et B (comme le montre la figure
ci-dessus),
- celle de D.
La représentation des tables de pointeur des fonctions
virtuelles de l'objet ABL se décrivent ainsi :

Ainsi,
lorsque ABL se construit, chacun des composant de ABL
sait que 4 fonctions existent et connaît leur adresse. Dans la mesure où
chacune de ses fonctions n'existent bien qu'une seule
fois en mémoire, qu'il n'y a aucune ambiguïté possible, ces 4 adresses (&AL::f, &BL::g,
&ABL::h et &LL::k) se retrouvent à l'identique
dans chacune des tables virtuelles des 4 parties de ABL.
L'adresse
de chacune des fonction est celle de la fonction de plus
bas niveau dans la hiérarche d'héritage : c'est ainsi que BL connaît l'adresse de la fonction f() redéfinie dans la
classe AL (AL n'est pourtant pas dans la même branche
d'héritage que BL!).
Cela aide d'ailleurs à répondre à la dernière
question de la précédente rubrique sur les fonctions
virtuelles.
L'adresse
de ces fonctions est donnée par rapport au tout début
de l'objet auquel elles appartiennent. &AL::f est donné
par rapport au début de AL, mais &BL::g est donné
par rapport au début de BL.
Donc, si l'on considère la table virtuelle de AL (ou de
ABL, c'est pareil puisque l'adresse mémoire de ABL
pointe sur le début de lui-même, c'est-à-dire sur le
premier objet qui le compose, soit AL), il est normal de
trouver un offset de 0 pour les fonctions f() et h() :
leurs adresses sont basées sur la même origine que
celle de AL et ABL.
En revanche, pour accéder, depuis AL ou ABL, à la
fonction BL::g(), il faut se déplacer d'un offset
correspondant à celui de BL afin de se retrouver au
début de la partie BL de l'objet ABL, partie par rapport
à laquelle l'adresse de BL::g() a un sens.
Il est donc normal que le code suivant appelle AL::f()
void
ma_fonction( ABL* p_abl )
{
BL* p_bl = (BL*)p_abl ;
p_bl->f() ; //
AL::f() !!!
}
p_bl pointe donc sur la partie "BL" de l'objet
ABL. Il possède une table des fonctions virtuelles dans
laquelle il retrouve bien l'adresse d'une fonction f()
unique (pas d'ambiguïté). Simplement, pour appeler
cette fonction f(), p_b sait qu'il doit soustraire à son
adresse un offset delta(BL) afin de se retrouver au
début de l'objet ABL (ou, ici, de l'objet AL
qui est le premier à composer en mémoire ABL), puis à
exécuter la fonction f() se trouvant à l'adresse
indiquée dans sa table : &AL::f().
|