Die V-Table objektiv betrachtet

Man wirft sowohl C++ als auch dem COM unter Windows vor, dass sie eine V-Table (auch Virtual Method/Function Table genannt) in ihrem Objektmodell und -layout einsetzen.

Die Kritik lautet dann: O-M-G! Ein Funktionsaufruf läuft über 2 Pointer! Der erste zeigt auf die Methodentabelle und in der Tabelle zeigt ein zweiter Pointer zur tatsächlichen Funktion.

Na … jetzt könnten wir doch meinen: Ha! Das machen wir einfacher.

Und zwar so:

typedef struct my_interface 
{
    /* public methods */
    int (*method1)(struct my_interface* thisptr);
    int (*method2)(struct my_interface* thisptr, int a);
    int (*method3)(struct my_interface* thisptr, int a, int b);
} my_interface_t;

typedef struct my_class 
{
    /* public methods */
    int (*method1)(struct my_interface* thisptr);
    int (*method2)(struct my_interface* thisptr, int a);
    int (*method3)(struct my_interface* thisptr, int a, int b);

    /* private members */
    int member1;
    int member2;
} my_class_t;

my_interface_t*	construct_object()
{
    my_class_t* object = malloc(sizeof(my_class_t));
    object->method1 = &method1_impl;
    object->method2 = &method2_impl;
    object->method3 = &method3_impl;
    object->member1 = 0;
    object->member2 = 0;
    return (my_interface_t*)object;
}

Wow! Schon haben wir uns eine Pointerauflösung gespart, denn das Objekt hat seine Funktionstabelle integriert.

Dass das aber doch keine perfekte Lösung ist, merkt man erst bei der näheren Analyse.

Ein “Konstruktor” muss beim Erstellen einer Objektinstanz jedes mal aufs Neue den Methoden-Pointern die Funktionen zuweisen. Und wenn wir Objekte mit vielen Methoden haben, die häufig instantiiert werden, kommt da schon ordentlich was an Overhead zustande.

Sehen wir uns jetzt eine mögliche V-Table-Implementierung an:

struct my_interface;

typedef struct my_interface_vtbl 
{
    /* public methods */
    int (*method1)(struct my_interface* thisptr);
    int (*method2)(struct my_interface* thisptr, int a);
    int (*method3)(struct my_interface* thisptr, int a, int b);
} my_interface_vtbl_t;

typedef struct my_interface 
{
    my_interface_vtbl_t const*	vtbl;
} my_interface_t;

typedef struct my_class 
{
    /* public methods in VTBL */
    my_interface_vtbl_t const*	vtbl;
	
    /* private members */
    int member1;
    int member2;
} my_class_t;

static my_interface_vtbl_t global_my_class_vtbl const = 
{
    &method1_impl,
    &method2_impl,
    &method3_impl
};

my_interface_t*	construct_object()
{
    my_class_t* object = malloc(sizeof(my_class_t));
    object->vtbl = &global_my_class_vtbl;
    object->member1 = 0;
    object->member2 = 0;
    return (my_interface_t*)object;
}

Der wichtige Punkt ist, dass für eine “Klasse” die V-Table einmal statisch initialisiert werden kann. Unser “C-Konstruktor” hängt nun jedem Objekt nur den Pointer zur V-Table an und schon ist es einsatzbereit.

Es geht dabei nicht um die Größe des Objektes im Speicher, und dass Methoden-Pointer dann bei jedem Objekt redundant zugeordnet werden müssen, sondern vielmehr darum, dass es sinnvoll ist, dass alle Methoden in einem globalen Objekt pro Klasse aufbewahrt werden können.

Für C++ und COM ist das von besonderem Interesse, weil so ein ganz wichtiges weiteres Feature “kostenlos” implementiert ist, und zwar die Run-Time-Type-Information kurz RTTI.

Über einen einzigen Pointervergleich kann man feststellen, von welcher Klasse ein Objekt abstammt. Und ohne dieses Feature wären Exceptions, dynamic_cast und viele weiteren Features nicht (ohne Zusatzaufwände) verfügbar.

Auch nicht zu vergessen: Vererbung. Mit einer V-Table haben die Member eines Objektes immer den gleichen Offset zum This-Pointer, egal auf welcher Vererbungsebene wir uns befinden, denn der V-Table Pointer ist immer gleich groß, egal wo er hinzeigt.

Sind die Methoden-Pointer aber direkt im Objekt untergebracht, verschiebt sicher der Offset mit jeder neuen Vererbungsstufe um die Anzahl der neuen Methoden. Es wäre somit unmöglich, dass eine Methode einer Ableitung den Code einer Methode einer Basisklasse nutzen kann, denn dort wären Methoden und Member anders angeordnet.

Und bei der Implementierung mehrerer Interfaces in einem Objekt wird es noch komplizierter (zugegeben, das ist auch mit V-Tables kein Zuckerschlecken).

Fazit: V-Tables mögen nicht perfekt sein, aber sie sind das Beste, was wir derzeit zur Implementierung vieler Features nutzen können.

Ich habe jetzt keine Performance-Tests vorbereitet und vertraue darauf, dass V-Table Zugriffe durchaus etwas langsamer sein können … aber ich denke, dass die Caches heutiger CPUs diesen Nachteil aufheben können.

C++ Compiler hingegen sind smart genug, dass sie erkennen, wann ein Interface aus einer “fremden” Quelle stammt und die langsamere V-Table aufgerufen werden MUSS, oder wenn es sich um ein “eigenes” Objekt handelt. Hier optimiert der Compiler alles weg und ruft die Method direkt ohne einen Zwischen-Pointer auf.

Diesen Vorteil haben wir in reinem C leider nicht. Doch ich habe mich trotzdem dazu entschieden das V-Table Konzept im GATE Projekt abzubilden.


PS: Das soll natürlich auf keinen Fall bedeuten, dass wir überall nur noch virtuelle Methoden und V-Tables einsetzen sollen! Bitte ja nicht!

Aber dort, wo Polymorphie und Vererbung eine Rolle spielt, sind sie ein würdiger Weggefährte.

… und es kann nie schaden zu wissen, was im Inneren eines Objektes wirklich abgeht.


Wenn sich eine triviale Erkenntnis mit Dummheit in der Interpretation paart, dann gibt es in der Regel Kollateralschäden in der Anwendung.
frei zitiert nach A. Van der Bellen
... also dann paaren wir mal eine komplexe Erkenntnis mit Klugheit in der Interpretation!