20 Stimmen

vptr bei Mehrfachvererbung verstehen?

Ich versuche, die Aussage im Buch effective c++ zu verstehen. Im Folgenden ist das Vererbungsdiagramm für Mehrfachvererbung.

enter image description here

enter image description here

Im Buch heißt es, dass für vptr ein separater Speicher in jeder Klasse erforderlich ist. Außerdem macht es folgende Aussage

Eine Besonderheit im obigen Diagramm ist, dass es nur drei vptrs gibt, obwohl vier Klassen beteiligt sind. Den Implementierungen steht es frei, vier vptrs zu erzeugen, wenn sie wollen, aber drei reichen aus (es stellt sich heraus, dass B und D sich eine vptr teilen können), und die meisten Implementierungen nutzen diese Möglichkeit, um den vom Compiler erzeugten Overhead zu reduzieren.

Ich konnte keinen Grund erkennen, warum in jeder Klasse ein separater Speicher für vptr erforderlich ist. Ich hatte das Verständnis, dass vptr von der Basisklasse geerbt wird, unabhängig vom Vererbungstyp. Wenn wir davon ausgehen, dass die resultierende Speicherstruktur mit vererbten vptr gezeigt wird, wie können sie dann die Aussage treffen, dass

B und D können sich eine vptr teilen

Kann jemand bitte ein bisschen über vptr in Mehrfachvererbung klären?

  • Brauchen wir für jede Klasse eine eigene vptr?
  • Auch wenn oben wahr ist, warum B und D können vptr teilen?

30voto

Matthieu M. Punkte 266317

Ihre Frage ist interessant, aber ich fürchte, dass Sie mit Ihrer ersten Frage ein zu großes Ziel verfolgen, daher werde ich in mehreren Schritten antworten, wenn es Ihnen nichts ausmacht :)

Haftungsausschluss: Ich bin kein Compiler-Autor, und obwohl ich das Thema sicherlich studiert habe, sollte mein Wort mit Vorsicht genossen werden. Es wird Ungenauigkeiten geben. Und ich kenne mich mit RTTI nicht so gut aus. Da es sich hier nicht um einen Standard handelt, sind die von mir beschriebenen Möglichkeiten.

1. Wie wird die Vererbung umgesetzt?

Hinweis: Ich werde die Ausrichtungsprobleme weglassen, sie bedeuten nur, dass zwischen den Blöcken etwas Platz eingeplant werden könnte

Lassen wir virtuelle Methoden vorerst außen vor und konzentrieren uns darauf, wie die Vererbung implementiert wird, weiter unten.

Die Wahrheit ist, dass Vererbung und Komposition viel gemeinsam haben:

struct B { int t; int u; };
struct C { B b; int v; int w; };
struct D: B { int v; int w; };

aussehen werden:

B:
+-----+-----+
|  t  |  u  |
+-----+-----+

C:
+-----+-----+-----+-----+
|     B     |  v  |  w  |
+-----+-----+-----+-----+

D:
+-----+-----+-----+-----+
|     B     |  v  |  w  |
+-----+-----+-----+-----+

Schockierend, nicht wahr :) ?

Dies bedeutet jedoch, dass die Mehrfachvererbung recht einfach herauszufinden ist:

struct A { int r; int s; };
struct M: A, B { int v; int w; };

M:
+-----+-----+-----+-----+-----+-----+
|     A     |     B     |  v  |  w  |
+-----+-----+-----+-----+-----+-----+

Anhand dieser Diagramme wollen wir sehen, was passiert, wenn wir einen abgeleiteten Zeiger in einen Basiszeiger umwandeln:

M* pm = new M();
A* pa = pm; // points to the A subpart of M
B* pb = pm; // points to the B subpart of M

Verwenden Sie unser vorheriges Diagramm:

M:
+-----+-----+-----+-----+-----+-----+
|     A     |     B     |  v  |  w  |
+-----+-----+-----+-----+-----+-----+
^           ^
pm          pb
pa

Die Tatsache, dass die Adresse von pb unterscheidet sich geringfügig von dem der pm wird durch Zeigerarithmetik automatisch vom Compiler für Sie behandelt.

2. Wie implementiert man virtuelle Vererbung?

Virtuelle Vererbung ist knifflig: Sie müssen sicherstellen, dass eine einzelne V (für virtuelle) Objekte wird von allen anderen Unterobjekten gemeinsam genutzt. Definieren wir eine einfache Diamantenvererbung.

struct V { int t; };
struct B: virtual V { int u; };
struct C: virtual V { int v; };
struct D: B, C { int w; };

Ich werde die Darstellung weglassen und mich darauf konzentrieren, dass in einem D Objekt, sowohl die B y C Unterteile teilen sich das gleiche Unterobjekt. Wie kann man das machen?

  1. Denken Sie daran, dass eine Klassengröße konstant sein sollte
  2. Denken Sie daran, dass weder B noch C beim Entwurf vorhersehen können, ob sie zusammen verwendet werden oder nicht.

Die Lösung, die gefunden wurde, ist daher einfach: B y C nur den Platz für einen Zeiger auf V , und:

  • wenn Sie eine eigenständige B ordnet der Konstruktor eine V auf dem Heap, die automatisch behandelt werden
  • wenn Sie bauen B als Teil einer D die B Unterabschnitt erwartet die D Konstruktor, um den Zeiger auf den Speicherort von V

Und dasselbe gilt für C natürlich.

En D eine Optimierung, die es dem Konstruktor erlaubt, Platz zu reservieren für V direkt im Objekt, denn D erbt nicht virtuell entweder von B o C , was das von Ihnen gezeigte Diagramm ergibt (obwohl wir noch keine virtuellen Methoden haben).

B:  (and C is similar)
+-----+-----+
|  V* |  u  |
+-----+-----+

D:
+-----+-----+-----+-----+-----+-----+
|     B     |     C     |  w  |  A  |
+-----+-----+-----+-----+-----+-----+

Es ist zu beachten, dass das Gießen von B a A ist etwas komplizierter als einfache Zeigerarithmetik: Sie müssen den Zeiger in B statt einfacher Zeigerarithmetik.

Es gibt aber auch einen schlimmeren Fall, das Up-Casting. Wenn ich Ihnen einen Verweis auf A Woher wissen Sie, wie Sie zurück zum B ?

In diesem Fall wird die Magie ausgeführt von dynamic_cast Dies erfordert jedoch eine gewisse Unterstützung (d. h. Informationen), die irgendwo gespeichert sind. Dies ist die so genannte RTTI (Laufzeittyp-Informationen). dynamic_cast wird zunächst feststellen, dass A ist Teil einer D und dann die Laufzeitinformationen von D abfragen, um zu erfahren, wo innerhalb von D die B Unterobjekt gespeichert ist.

Wenn wir in einem Fall wären, in dem es keine B Unterobjekt, würde es entweder 0 zurückgeben (Zeigerform) oder ein bad_cast Ausnahme (Referenzformular).

3. Wie implementiert man virtuelle Methoden?

Im Allgemeinen werden virtuelle Methoden durch eine v-Tabelle (d.h. eine Tabelle mit Zeigern auf Funktionen) pro Klasse und v-ptr zu dieser Tabelle pro Objekt implementiert. Dies ist nicht die einzig mögliche Implementierung, und es hat sich gezeigt, dass andere schneller sein könnten, aber es ist sowohl einfach als auch mit einem vorhersehbaren Overhead (sowohl in Bezug auf den Speicher als auch auf die Versandgeschwindigkeit).

Nehmen wir ein einfaches Objekt einer Basisklasse mit einer virtuellen Methode:

struct B { virtual foo(); };

Für den Computer gibt es so etwas wie Mitgliedschaftsmethoden nicht, also haben Sie in der Tat:

struct B { VTable* vptr; };

void Bfoo(B* b);

struct BVTable { RTTI* rtti; void (*foo)(B*); };

Wenn Sie von B :

struct D: B { virtual foo(); virtual bar(); };

Sie haben nun zwei virtuelle Methoden, von denen eine die folgende überschreibt B::foo , die andere ist brandneu. Die Computerdarstellung ist ähnlich wie:

struct D { VTable* vptr; }; // single table, even for two methods

void Dfoo(D* d); void Dbar(D* d);

struct DVTable { RTTI* rtti; void (*foo)(D*); void (*foo)(B*); };

Beachten Sie, wie BVTable y DVTable so ähnlich sind (da wir die foo avant bar ) ? Das ist wichtig!

D* d = /**/;
B* b = d; // noop, no needfor arithmetic

b->foo();

Übersetzen wir den Aufruf in foo in Maschinensprache (etwas):

// 1. get the vptr
void* vptr = b; // noop, it's stored at the first byte of B

// 2. get the pointer to foo function
void (*foo)(B*) = vptr[1]; // 0 is for RTTI

// 3. apply foo
(*foo)(b);

Diese vptrs werden von den Konstruktoren der Objekte initialisiert, wenn sie den Konstruktor von D ist folgendes passiert:

  • D::D() ruft auf. B::B() in erster Linie seine Teilbereiche zu initiieren
  • B::B() initialisieren vptr auf seine vtable verweist, und gibt dann
  • D::D() initialisieren vptr auf seine vtable verweist und damit die von B

Deshalb, vptr zeigte hier auf D's vtable, und damit auf die foo angewendet wurde, war D. Für B es war völlig transparent.

Hier teilen sich B und D die gleiche vptr!

4. Virtuelle Tabellen in der Multi-Vererbung

Leider ist diese gemeinsame Nutzung nicht immer möglich.

Erstens ist, wie wir gesehen haben, im Falle der virtuellen Vererbung das "gemeinsame" Element im endgültigen vollständigen Objekt seltsam positioniert. Es hat daher seine eigene vptr. Das ist 1 .

Zweitens ist im Falle der Mehrfachvererbung die erste Base auf das gesamte Objekt ausgerichtet, die zweite Base jedoch nicht (beide benötigen Platz für ihre Daten), weshalb sie ihre vptr nicht teilen kann. Das ist 2 .

Drittens, die erste Basis es auf das gesamte Objekt ausgerichtet und bietet uns damit dasselbe Layout wie im Falle der einfachen Vererbung (dieselbe Optimierungsmöglichkeit). Das ist 3 .

Ziemlich einfach, oder?

1voto

AProgrammer Punkte 49452

Wenn eine Klasse virtuelle Mitglieder hat, muss man ihre Adresse finden können. Diese werden in einer konstanten Tabelle (vtbl) gesammelt, deren Adresse in einem versteckten Feld für jedes Objekt (vptr) gespeichert wird. Ein Aufruf an ein virtuelles Mitglied ist im Wesentlichen:

obj->_vptr[member_idx](obj, params...);

Eine abgeleitete Klasse, die ihrer Basisklasse virtuelle Mitglieder hinzufügt, braucht auch einen Platz für diese. Also eine neue vtbl und eine neue vptr für sie. Ein Aufruf eines geerbten virtuellen Mitglieds ist immer noch

obj->_vptr[member_idx](obj, params...);

und ein Aufruf zum neuen virtuellen Mitglied ist:

obj->_vptr2[member_idx](obj, params...);

Wenn die Basis nicht virtuell ist, kann man dafür sorgen, dass die zweite vtbl unmittelbar nach der ersten platziert wird, wodurch die Größe der vtbl effektiv erhöht wird. Und die _vptr2 wird nicht mehr benötigt. Ein Aufruf an ein neues virtuelles Mitglied ist also:

obj->_vptr[member_idx+num_inherited_members](obj, params...);

Im Falle der (nicht virtuellen) Mehrfachvererbung erbt man zwei vtbl und zwei vptr. Sie können nicht zusammengeführt werden, und Aufrufe müssen darauf achten, dem Objekt einen Offset hinzuzufügen (damit die geerbten Datenelemente an der richtigen Stelle gefunden werden). Aufrufe an die ersten Basisklassenmitglieder werden

obj->_vptr_base1[member_idx](obj, params...);

und für die zweite

obj->_vptr_base2[member_idx](obj+offset, params...);

Neue virtuelle Mitglieder können wiederum entweder in eine neue vtbl eingefügt oder an die vtbl der ersten Basis angehängt werden (so dass bei künftigen Aufrufen keine Offsets hinzugefügt werden).

Wenn eine Basis virtuell ist, kann man die neue vtbl nicht an die geerbte anhängen, da dies zu Konflikten führen könnte (in dem von Ihnen angeführten Beispiel, wenn sowohl B als auch C ihre virtuellen Funktionen anhängen, wie kann dann D seine Version erstellen?).

A braucht also eine vtbl. B und C benötigen eine vtbl, die nicht an die von A angehängt werden kann, da A eine virtuelle Basis von beiden ist. D benötigt eine vtbl, die aber an die von B angehängt werden kann, da B keine virtuelle Basisklasse von D ist.

0voto

mif Punkte 1121

Es hat alles damit zu tun, wie der Compiler die tatsächlichen Adressen der Methodenfunktionen ermittelt. Der Compiler geht davon aus, dass sich der Zeiger der virtuellen Tabelle an einem bekannten Offset von der Basis des Objekts befindet (normalerweise an Offset 0). Der Compiler muss auch die Struktur der virtuellen Tabelle für jede Klasse kennen - mit anderen Worten, er muss wissen, wie er Zeiger auf Funktionen in der virtuellen Tabelle nachschlagen kann.

Klasse B und Klasse C werden völlig unterschiedliche Strukturen von virtuellen Tabellen haben, da sie unterschiedliche Methoden haben. Die virtuelle Tabelle für Klasse D kann wie eine virtuelle Tabelle für Klasse B aussehen, gefolgt von zusätzlichen Daten für Methoden der Klasse C.

Wenn Sie ein Objekt der Klasse D erzeugen, können Sie es als Zeiger auf B oder als Zeiger auf C oder sogar als Zeiger auf die Klasse A casten. Sie können diese Zeiger an Module weitergeben, die noch nicht einmal von der Existenz der Klasse D wissen, aber Methoden der Klasse B oder C oder A aufrufen können. Diese Module müssen wissen, wie sie den Zeiger auf die virtuelle Tabelle der Klasse finden können, und sie müssen wissen, wie sie Zeiger auf Methoden der Klasse B/C/A in der virtuellen Tabelle finden können. Aus diesem Grund müssen Sie für jede Klasse separate VPTRs haben.

Die Klasse D ist sich der Existenz der Klasse B und der Struktur ihrer virtuellen Tabelle bewusst und kann daher ihre Struktur erweitern und die VPTR von Objekt B wiederverwenden.

Wenn Sie einen Zeiger auf ein Objekt D in einen Zeiger auf ein Objekt B oder C oder A umwandeln, wird der Zeiger um einen bestimmten Offset aktualisiert, so dass er von vptr ausgeht, das dieser spezifischen Basisklasse entspricht.

0voto

Itay Maman Punkte 29121

Ich konnte keinen Grund erkennen, warum es separater Speicher in jeder Klasse erforderlich ist jeder Klasse für vptr

Wenn Sie zur Laufzeit eine (virtuelle) Methode über einen Zeiger aufrufen, hat die CPU keine Kenntnis über das tatsächliche Objekt, für das die Methode ausgeführt wird. Wenn Sie B* b = ...; b->some_method(); kann die Variable b potenziell auf ein Objekt zeigen, das mit new B() oder über new D() oder sogar new E() wobei E ist eine andere Klasse, die von (entweder) B o D . Jede dieser Klassen kann ihre eigene Implementierung (Override) für some_method() . Daher ist der Aufruf b->some_method() sollte die Implementierung entweder von B , D o E abhängig von dem Objekt, auf das b zeigt.

Die vptr eines Objekts ermöglicht es der CPU, die Adresse der Implementierung von some_method zu finden, die für dieses Objekt in Kraft ist. Jede Klasse definiert ihre eigene vtbl (die die Adressen aller virtuellen Methoden enthält) und jedes Objekt der Klasse beginnt mit einer vptr, die auf diese vtbl zeigt.

0voto

Jan Gray Punkte 3354

Ich denke, D braucht 2 oder 3 Vptrs.

Hier kann A eine vptr benötigen oder auch nicht. B braucht eine, die nicht mit A geteilt werden sollte (weil A virtuell vererbt wird). C braucht eine, die nicht mit A geteilt werden sollte (dito). D kann die vftable von B oder C für seine neuen virtuellen Funktionen (falls vorhanden) verwenden, also kann es die von B oder C mitbenutzen.

Mein altes Papier "C++: Under the Hood" erklärt die Microsoft C++-Implementierung von virtuellen Basisklassen. http://www.openrce.org/articles/files/jangrayhood.pdf

Und (MS C++) können Sie mit cl /d1reportAllClassLayout kompilieren, um einen Textbericht der Klassenspeicherlayouts zu erhalten.

Viel Spaß beim Hacken!

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X