Témoignage
 

Rubrique « Info »

 



É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().


               
 
Avertissement !
 
Décollage !  |  Présentation du site web "AILES"  | 
Infos générales  |  articles "Informatique"