Qu'est-ce qu'une table de table virtuelle?

Une fois chez Slack, je suis tombé sur un nouvel acronyme pour mon glossaire d'acronymes C ++ : «VTT». Godbolt :

test.o: In function `MyClass': test.cc:3: undefined reference to `VTT for MyClass' 

«VTT» dans ce contexte signifie «table de table virtuelle». Il s'agit d'une structure de données auxiliaire utilisée (dans Itanium C ++ ABI) lors de la création de certaines classes de base héritées elles-mêmes des classes de base virtuelles. Les VTT suivent les mêmes règles de disposition que les tables virtuelles (vtable) et les informations de type (typeinfo), donc si vous obtenez l'erreur ci-dessus, vous pouvez simplement remplacer mentalement «vtable» par «VTT» et commencer le débogage. (Très probablement, vous avez laissé la fonction clé de la classe non définie). Pour voir pourquoi le VTT, ou une structure similaire, est nécessaire, commençons par les bases.

Ordre de conception pour l'héritage non virtuel


Lorsque nous avons une hiérarchie d'héritage, les classes de base sont construites à partir des plus basiques . Pour construire Charlie, nous devons d'abord construire ses classes parent MrsBucket et MrBucket, récursivement, pour construire MrBucket, nous devons d'abord construire ses classes parents GrandmaJosephine et GrandpaJoe.

Comme ça:

 struct A {}; struct B : A {}; struct C {}; struct D : C {}; struct E : B, D {}; //     // ABCDE 

Ordre de conception pour les classes de base virtuelles


Mais l'héritage virtuel confond toutes les cartes! Avec l'héritage virtuel, nous pouvons avoir une hiérarchie en forme de losange dans laquelle deux classes parentales différentes peuvent partager un ancêtre commun.

 struct G {}; struct M : virtual G {}; struct F : virtual G {}; struct E : M, F {}; //     // GMFE 

Dans la dernière section, chaque constructeur était responsable d'appeler le constructeur de sa classe de base. Mais maintenant, nous avons l'héritage virtuel, et les constructeurs M et F doivent en quelque sorte savoir qu'il n'est pas nécessaire de construire G, car c'est commun. Si M et F étaient responsables de la construction des objets de base dans ce cas, l'objet de base commun serait construit deux fois, ce qui n'est pas très bon.

Pour travailler avec des sous-objets d'héritage virtuel, Itanium C ++ ABI divise chaque constructeur en deux parties: le constructeur d'objet de base et le constructeur d'objet complet. Le constructeur de l'objet de base est responsable de la construction de tous les sous-objets d'héritage non virtuels (et de leurs sous-objets, et de l'installation de leur vptr sur leur vtable, et de l'exécution du code entre accolades dans le code C ++). Le constructeur de l'objet complet, qui est appelé chaque fois que vous créez l'objet C ++ complet, est responsable de la construction de tous les sous-objets de l'héritage virtuel de l'objet dérivé, puis fait le reste.

Considérez la différence entre notre exemple ABCDE de la section précédente et l'exemple suivant:

 struct A {}; struct B : virtual A {}; struct C {}; struct D : virtual C {}; struct E : B, D {}; //     // ACBDE 

Le constructeur de l'objet complet E appelle d'abord les constructeurs de l'objet de base des sous-objets virtuels A et C; alors les constructeurs de l'objet d'héritage non virtuel de base B et D. sont appelés. B et D ne sont plus responsables de la construction de A et C, respectivement.

Conception de tables vtables


Supposons que nous ayons une classe avec quelques méthodes virtuelles, par exemple, telles ( Godbolt ):

 struct Cat { Cat() { poke(); } virtual void poke() { puts("meow"); } }; struct Lion : Cat { std::string roar = "roar"; Lion() { poke(); } void poke() override { roar += '!'; puts(roar.c_str()); } }; 

Lorsque nous construisons Lion, nous commençons par construire le sous-objet Cat de base. Le constructeur de Cat appelle poke (). À ce stade, nous n'avons qu'un seul objet Cat - nous n'avons pas encore initialisé les données de membre nécessaires pour créer l'objet Lion. Si le constructeur Cat appelle Lion :: poke (), il peut essayer de changer le membre non initialisé de std :: string rugissement et nous obtenons UB. Ainsi, le standard C ++ nous oblige à le faire dans le constructeur Cat, un appel à la méthode virtuelle poke () devrait appeler Cat :: poke (), pas Lion :: poke ()!

Il n'y a aucun problème. Le compilateur fait simplement démarrer Cat :: Cat () (à la fois la version de l'objet de base et la version de l'objet complet) en définissant le vptr de l'objet sur la vtable de l'objet Cat. Lion :: Lion () appellera Cat :: Cat (), puis réinitialisera vptr sur le pointeur de la table vtable pour l'objet Cat dans Lion, avant d'exécuter le code entre parenthèses. Pas de problème!

Décalages d'héritage virtuels


Laissez le chat hériter virtuellement de l'animal. Ensuite, la table virtuelle pour Cat stocke non seulement les pointeurs de fonction pour les fonctions de membre virtuel Cat, mais également le décalage du sous-objet virtuel Animal à l'intérieur de Cat. ( Godbolt .)

 struct Animal { const char *data = "hi"; }; struct Cat : virtual Animal { Cat() { puts(data); } }; struct Nermal : Cat {}; struct Garfield : Cat { int padding; }; 

Le constructeur Cat interroge le membre Animal :: data. Si cet objet Cat est le sous-objet de base de l'objet Nermal, ses données de membre sont au décalage 8, juste derrière vptr. Mais si l'objet Cat est le sous-objet sous-jacent de l'objet Garfield, alors les données des membres sont à l'offset 16, derrière vptr et Garfield :: padding. Pour y faire face, Itanium ABI stocke les décalages des objets de base virtuels dans la table de l'objet Cat. La table pour Cat-in-Nermal préserve le fait que Animal, le sous-objet Cat sous-jacent, est stocké à l'offset 8; La table virtuelle pour Cat-in-Garfield conserve le fait que Animal, le sous-objet Cat sous-jacent, est stocké à l'offset 16.

Maintenant, combinez cela avec la section précédente. Le compilateur doit s'assurer que Cat :: Cat () (à la fois la version d'objet de base et la version d'objet complet) démarre en installant vptr sur la vtable pour Cat-in-Nermal ou sur la vtable pour Cat-in-Garfield, selon le type facilité la plus dérivée! Mais comment ça marche?

Le constructeur de l'objet complet pour l'objet le plus dérivé doit pré-calculer la table vtable à laquelle il souhaite que le vptr du sous-objet de base fasse référence pendant la construction de l'objet, puis le constructeur de l'objet complet pour l'objet le plus dérivé doit transmettre ces informations au constructeur de l'objet de base du sous-objet de base comme paramètre caché! Regardons le code généré pour Cat :: Cat () ( Godbolt ):

 _ZN3CatC1Ev: #    Cat movq $_ZTV3Cat+24, (%rdi) # this->vptr = &vtable-for-Cat; retq _ZN3CatC2Ev: #     Cat movq (%rsi), %rax # fetch a value from rsi movq %rax, (%rdi) # this->vptr = *rsi; retq 

Le constructeur de l'objet de base accepte non seulement ce paramètre caché dans% rdi, mais aussi le paramètre VTT caché dans% rsi! Le constructeur de l'objet de base charge l'adresse à partir de (% rsi) et stocke l'adresse dans la table virtuelle de l'objet Cat.

Celui qui appelle le constructeur de l'objet Cat de base est responsable de prédire quelle adresse Cat :: Cat () doit être écrite dans vptr et de définir le pointeur dans (% rsi) sur cette adresse.

Pourquoi avons-nous besoin d'un autre niveau d'identité?


Considérez le constructeur d'un objet Nermal complet.

 _ZN3CatC2Ev: #    Cat movq (%rsi), %rax #    rsi movq %rax, (%rdi) # this->vptr = *rsi; retq _ZN6NermalC1Ev: #    Nermal pushq %rbx movq %rdi, %rbx movl $_ZTT6Nermal+8, %esi # %rsi = &VTT-for-Nermal callq _ZN3CatC2Ev #     Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq _ZTT6Nermal: .quad _ZTV6Nermal+24 # vtable-for-Nermal .quad _ZTC6Nermal0_3Cat+24 # construction-vtable-for-Cat-in-Nermal 

Pourquoi _ZTC6Nermal0_3Cat + 24 se trouve-t-il dans la section des données et son adresse est transmise à% rsi, au lieu de simplement passer _ZTC6Nermal0_3Cat + 24 directement?

 #   ? _ZN3CatC2Ev: #     Cat movq %rsi, (%rdi) # this->vptr = rsi; retq _ZN6NermalC1Ev: #     Nermal pushq %rbx movq %rdi, %rbx movl $_ZTC6Nermal0_3Cat+24, %esi # %rsi = &construction-vtable-for-Cat-in-Nermal callq _ZN3CatC2Ev #     Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq 

En effet, nous pouvons avoir plusieurs niveaux d'héritage! À chaque niveau d'héritage, le constructeur de l'objet de base doit définir vptr puis, éventuellement, passer le contrôle plus loin dans la chaîne au constructeur de base suivant, qui peut définir vptrs à une autre valeur. Cela implique une liste ou un tableau de pointeurs vers vtable.

Voici un exemple concret ( Godbolt ):

 struct VB { int member_of_vb = 42; }; struct Grandparent : virtual VB { Grandparent() {} }; struct Parent : Grandparent { Parent() {} }; struct Gretel : Parent { Gretel() : VB{1000} {} }; struct Hansel : Parent { int padding; Hansel() : VB{2000} {} }; 

L'objet constructeur de base Grandparent doit définir son vptr sur Grandparent - autre chose, qui est la classe la plus dérivée. Le constructeur de l'objet de base Parent doit d'abord appeler Grandparent :: Grandparent () avec le% rsi approprié, puis définir vptr sur Parent - autre chose, qui est la classe la plus dérivée. Une façon de mettre cela en œuvre pour Gretel:

 Gretel::Gretel() [  ]: pushq %rbx movq %rdi, %rbx movl $1000, 8(%rdi) # imm = 0x3E8 movl $VTT for Gretel+8, %esi callq Parent::Parent() [  ] movq $vtable for Gretel+24, (%rbx) popq %rbx retq VTT for Gretel: .quad vtable for Gretel+24 .quad construction vtable for Parent-in-Gretel+24 .quad construction vtable for Grandparent-in-Gretel+24 

Vous pouvez voir dans Godbolt que le constructeur de l'objet de base de la classe Parent appelle d'abord Grandparent :: Grandparent () avec% rsi + 8, puis définit son propre vptr sur (% rsi). Donc, ici, nous utilisons le fait que Gretel, pour ainsi dire, a soigneusement tracé un chemin de chapelure le long duquel toutes ses classes de base ont suivi pendant la construction.

Le même VTT est utilisé dans le destructeur ( Godbolt ). Pour autant que je sache, la ligne nulle de la table VTT n'est jamais utilisée. Le constructeur Gretel charge la vtable pour Gretel + 24 dans vptr, mais il sait que cette adresse est statique, elle n'a jamais besoin d'être chargée depuis VTT. Je pense que la ligne zéro du tableau a été conservée simplement pour des raisons historiques. (Et bien sûr, le compilateur ne peut pas simplement le jeter, car ce sera une violation d'Itanium ABI et il sera impossible de lier à l'ancien code qui adhère à Itanium-ABI).

C'est tout, nous avons regardé une table de tables virtuelles, ou VTT.

Plus d'informations


Vous pouvez trouver des informations VTT dans ces endroits:

StackOverflow: « Qu'est-ce que le VTT pour une classe? "
« Notes VTable sur l'héritage multiple dans le compilateur GCC C ++ v4.0.1 » (Morgan Deters, 2005)
Le Itanium C ++ ABI , rubrique «Commande VTT»

Enfin, je dois répéter que le VTT est une fonctionnalité de Itanium C ++ ABI, et est utilisé sur Linux, OSX, etc. L'ABI MSVC utilisé sous Windows n'a pas de VTT et utilise un mécanisme complètement différent pour l'héritage virtuel. Jusqu'à présent, je ne sais presque rien de MSVC ABI, mais peut-être qu'un jour je trouverai tout et j'écrirai un article à ce sujet!

Source: https://habr.com/ru/post/fr474318/


All Articles