Was ist eine virtuelle Tischtabelle?

Bei Slack stieß ich auf ein neues Akronym für mein C ++ - Akronym-Glossar : "VTT". Godbolt :

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

"VTT" bedeutet in diesem Zusammenhang "virtuelle Tabellentabelle". Dies ist eine zusätzliche Datenstruktur, die (in Itanium C ++ ABI) beim Erstellen einiger Basisklassen verwendet wird, die selbst von virtuellen Basisklassen geerbt werden. VTTs folgen denselben Layoutregeln wie virtuelle Tabellen (vtable) und Typinformationen (typeinfo). Wenn Sie also den obigen Fehler erhalten, können Sie "vTT" nur mental durch "vtable" ersetzen und mit dem Debuggen beginnen. (Höchstwahrscheinlich haben Sie die Schlüsselfunktion der Klasse undefiniert gelassen). Um zu sehen, warum VTT oder eine ähnliche Struktur erforderlich ist, beginnen wir mit den Grundlagen.

Entwurfsauftrag für nicht virtuelle Vererbung


Wenn wir eine Vererbungshierarchie haben, werden Basisklassen ausgehend von den grundlegendsten erstellt . Um Charlie zu konstruieren, müssen wir zuerst seine Elternklassen MrsBucket und MrBucket rekursiv konstruieren. Um MrBucket zu konstruieren, müssen wir zuerst seine Elternklassen GrandmaJosephine und GrandpaJoe konstruieren.

So:

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

Entwurfsauftrag für virtuelle Basisklassen


Aber die virtuelle Vererbung verwirrt alle Karten! Mit der virtuellen Vererbung können wir eine rautenförmige Hierarchie haben, in der zwei verschiedene übergeordnete Klassen einen gemeinsamen Vorfahren haben können.

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

Im letzten Abschnitt war jeder Konstruktor dafür verantwortlich, den Konstruktor seiner Basisklasse aufzurufen. Aber jetzt haben wir eine virtuelle Vererbung, und die Konstruktoren M und F müssen irgendwie wissen, dass es nicht notwendig ist, G zu konstruieren, weil es üblich ist. Wenn M und F in diesem Fall für die Konstruktion der Basisobjekte verantwortlich wären, würde das gemeinsame Basisobjekt zweimal konstruiert, was nicht sehr gut ist.

Um mit Unterobjekten für die virtuelle Vererbung zu arbeiten, unterteilt Itanium C ++ ABI jeden Konstruktor in zwei Teile: den Basisobjektkonstruktor und den vollständigen Objektkonstruktor. Der Konstruktor des Basisobjekts ist dafür verantwortlich, alle nicht virtuellen Vererbungsunterobjekte (und ihre Unterobjekte) zu erstellen, ihre vptr auf ihrer vtable zu installieren und den Code in geschweiften Klammern in C ++ - Code auszuführen). Der Konstruktor des vollständigen Objekts, der jedes Mal aufgerufen wird, wenn Sie das vollständige C ++ - Objekt erstellen, ist für die Erstellung aller Unterobjekte der virtuellen Vererbung des abgeleiteten Objekts verantwortlich und erledigt dann den Rest.

Betrachten Sie den Unterschied zwischen unserem ABCDE-Beispiel aus dem vorherigen Abschnitt und dem folgenden Beispiel:

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

Der Konstruktor des vollständigen Objekts E ruft zuerst die Konstruktoren des Basisobjekts der virtuellen Unterobjekte A und C auf; dann werden die Konstruktoren des nicht virtuellen Basisvererbungsobjekts B und D aufgerufen. B und D sind nicht länger für die Konstruktion von A bzw. C verantwortlich.

Entwerfen von vtable-Tabellen


Angenommen, wir haben eine Klasse mit einigen virtuellen Methoden, zum Beispiel ( 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()); } }; 

Wenn wir Lion konstruieren, konstruieren wir zunächst das grundlegende Cat-Unterobjekt. Der Konstruktor von Cat ruft poke () auf. Zu diesem Zeitpunkt haben wir nur ein Cat-Objekt - wir haben die Mitgliedsdaten, die zum Erstellen des Lion-Objekts erforderlich sind, noch nicht initialisiert. Wenn der Cat-Konstruktor Lion :: poke () aufruft, kann er versuchen, das nicht initialisierte Mitglied von std :: string roar zu ändern, und wir erhalten UB. Der C ++ - Standard verpflichtet uns daher, dies im Cat-Konstruktor zu tun. Ein Aufruf der virtuellen Methode poke () sollte Cat :: poke () aufrufen, nicht Lion :: poke ()!

Es gibt kein Problem. Der Compiler veranlasst einfach Cat :: Cat () (sowohl die Version für das Basisobjekt als auch die Version für das vollständige Objekt), indem das vptr-Objekt auf die vtable des Cat-Objekts gesetzt wird. Lion :: Lion () ruft Cat :: Cat () auf und setzt dann vptr auf den Zeiger auf die vtable-Tabelle für das Cat-Objekt in Lion zurück, bevor der Code in Klammern ausgeführt wird. Kein Problem!

Offsets für virtuelle Vererbung


Lassen Sie Cat virtuell von Animal erben. In der vtable für Cat werden dann nicht nur die Funktionszeiger für die virtuellen Mitgliedsfunktionen von Cat gespeichert, sondern auch der Offset des virtuellen Unterobjekts Animal in Cat. ( Godbolt .)

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

Der Cat-Konstruktor fragt das Mitglied Animal :: data ab. Wenn dieses Cat-Objekt das Basis-Unterobjekt des Nermal-Objekts ist, befinden sich seine Mitgliedsdaten im Offset 8 direkt hinter vptr. Wenn das Cat-Objekt jedoch das zugrunde liegende Unterobjekt des Garfield-Objekts ist, befinden sich die Elementdaten auf Versatz 16 hinter vptr und Garfield :: padding. Um dies zu bewältigen, speichert Itanium ABI die Offsets der virtuellen Basisobjekte in der vtable des Cat-Objekts. In der Tabelle für Cat-in-Nermal wird die Tatsache beibehalten, dass Animal, das zugrunde liegende Cat-Unterobjekt, im Offset 8 gespeichert ist. Die vtable für Cat-in-Garfield behält die Tatsache bei, dass Animal, das zugrunde liegende Cat-Unterobjekt, bei Offset 16 gespeichert ist.

Kombinieren Sie dies nun mit dem vorherigen Abschnitt. Der Compiler muss sicherstellen, dass Cat :: Cat () (sowohl die Basisobjektversion als auch die vollständige Objektversion) mit der Installation von vptr in der vtable für Cat-in-Nermal oder in der vtable für Cat-in-Garfield beginnt, je nach Typ am meisten derivative Fazilität! Aber wie funktioniert es?

Der Konstruktor des vollständigen Objekts für das am meisten abgeleitete Objekt muss vorberechnen, auf welche Tabelle vtable der vptr des Basis-Unterobjekts während der Erstellung des Objekts verweisen soll, und dann muss der Konstruktor des vollständigen Objekts für das am meisten abgeleitete Objekt diese Informationen an den Konstruktor des Basisobjekts des Basis-Unterobjekts weitergeben als versteckter Parameter! Schauen wir uns den generierten Code für Cat :: Cat () ( Godbolt ) an:

 _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 

Der Konstruktor des Basisobjekts akzeptiert nicht nur diesen versteckten Parameter in% rdi, sondern auch den versteckten VTT-Parameter in% rsi! Der Basisobjektkonstruktor lädt die Adresse von (% rsi) und speichert die Adresse in der vtable des Cat-Objekts.

Wer den Konstruktor des Basis-Cat-Objekts aufruft, ist dafür verantwortlich, vorherzusagen, welche Cat :: Cat () -Adresse in vptr geschrieben werden soll, und den Zeiger in (% rsi) auf diese Adresse zu setzen.

Warum brauchen wir eine andere Identitätsebene?


Betrachten Sie den Konstruktor eines vollständigen Nermal-Objekts.

 _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 

Warum befindet sich _ZTC6Nermal0_3Cat + 24 im Datenbereich und seine Adresse wird an% rsi übergeben, anstatt nur _ZTC6Nermal0_3Cat + 24 direkt zu übergeben?

 #   ? _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 

Dies liegt daran, dass wir mehrere Vererbungsebenen haben können! Auf jeder Vererbungsebene sollte der Konstruktor des Basisobjekts vptr festlegen und dann möglicherweise die Kontrolle über die Kette an den nächsten Basiskonstruktor übergeben, der vptrs auf einen anderen Wert setzen kann. Dies impliziert eine Liste oder eine Tabelle mit Zeigern auf vtable.

Hier ist ein konkretes Beispiel ( 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} {} }; 

Das Grandparent-Basiskonstruktorobjekt muss sein vptr auf Grandparent setzen - etwas anderes, das die am meisten abgeleitete Klasse ist. Der Konstruktor des übergeordneten Basisobjekts muss zuerst Grandparent :: Grandparent () mit dem entsprechenden% rsi aufrufen und dann vptr auf Parent setzen - etwas anderes, das die am meisten abgeleitete Klasse ist. Eine Möglichkeit, dies für Gretel umzusetzen:

 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 

Sie können in Godbolt sehen, dass der Konstruktor des Basisobjekts der Parent-Klasse zuerst Grandparent :: Grandparent () mit% rsi + 8 aufruft und dann sein eigenes vptr auf (% rsi) setzt. Hier nutzen wir also die Tatsache, dass Gretel sozusagen sorgfältig einen Weg der Brotkrumen gelegt hat, dem alle ihre Basisklassen während des Baus folgten.

Das gleiche VTT wird im Destruktor ( Godbolt ) verwendet. Soweit ich weiß, wird die Nullzeile der VTT-Tabelle nie verwendet. Der Gretel-Konstruktor lädt die vtable für Gretel + 24 in vptr, weiß jedoch, dass diese Adresse statisch ist und niemals aus VTT geladen werden muss. Ich denke, dass die Nullzeile der Tabelle nur aus historischen Gründen erhalten blieb. (Und natürlich kann der Compiler es nicht einfach wegwerfen, da es eine Verletzung von Itanium ABI darstellt und es unmöglich ist, auf alten Code zu verlinken, der Itanium-ABI entspricht.)

Das ist alles, wir haben uns eine Tabelle mit virtuellen Tabellen oder VTT angesehen.

Weitere Informationen


Sie finden VTT-Informationen an folgenden Stellen:

StackOverflow: „ Was ist die VTT für eine Klasse?
" VTable-Hinweise zur Mehrfachvererbung in GCC C ++ Compiler v4.0.1 " (Morgan Deters, 2005)
Der Abschnitt „VTT-Bestellung“ von Itanium C ++ ABI

Abschließend muss ich noch einmal betonen, dass VTT eine Funktion von Itanium C ++ ABI ist und unter Linux, OSX usw. verwendet wird. Das unter Windows verwendete MSVC-ABI verfügt nicht über VTT und verwendet einen völlig anderen Mechanismus für die virtuelle Vererbung. Ich weiß (bis jetzt) ​​fast nichts über MSVC ABI, aber vielleicht werde ich eines Tages alles herausfinden und einen Beitrag darüber schreiben!

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


All Articles