Programming Languages · Objects and Modules

Vtable Layout and the Cost of a Virtual Call

How virtual method slots are assigned, why overrides reuse a slot, how multiple inheritance adjusts the this pointer, and what the indirection costs at run time.

A call through a base-class pointer has to reach the override that belongs to the object’s actual class, and the caller does not know that class. The usual implementation answers with one pointer stored in the object and one array of function pointers shared by every instance of the class. That arrangement is small enough to describe completely, and the details of how slots are assigned explain several behaviours that otherwise look arbitrary: why a base-class constructor calls the base version of a virtual function, why deleting through a base pointer without a virtual destructor corrupts the heap, and why adding a virtual function to a published library class can break already-compiled clients.

The object header and the slot table

An object of a polymorphic class carries a hidden pointer as its first member, commonly called the vptr. The pointer refers to a per-class table of function addresses, the vtable. A class with at least one virtual function and no base class owns one vtable; every instance of that class points at the same table, so the per-object cost is a single pointer, eight bytes on the common 64-bit ABIs, and the per-class cost is eight bytes per virtual function plus a small header.

The layout in C, written by hand, is the layout the compiler generates:

struct shape_vtable {
    double (*area)(const struct shape *);
    void   (*draw)(const struct shape *, struct canvas *);
    void   (*destroy)(struct shape *);
};

struct shape {
    const struct shape_vtable *vptr;  /* offset 0, so a derived pointer is also a base pointer */
    unsigned id;
};

/* a call site is exactly these two loads and one indirect branch */
double shape_area(const struct shape *s) {
    return s->vptr->area(s);
}

A call compiles to a load of the vptr from the object, a load of the function address at a fixed byte offset in the table, and an indirect call. The receiver becomes the first argument, so the callee receives the object pointer it was written against. No search occurs and no name is looked up: the slot index is a compile-time constant baked into the call site, which is why a virtual call in a language with single inheritance is only slightly more expensive than a direct call.

Slot assignment follows declaration order within a class, with one rule that carries most of the semantics: an override of an inherited virtual function reuses the slot the base class assigned. The derived vtable is a copy of the base vtable with entries replaced in place, and any genuinely new virtual functions are appended after the inherited ones. This is why the slot index for a given virtual function is the same in every class of the hierarchy, and therefore why a call site can hard-code it.

What reuse of slots guarantees

Because an override occupies the base’s slot, a caller compiled against the base class can call through a pointer to any derived object and reach the derived implementation without recompilation. The caller only needs the base class declaration, which fixes the slot index; the object supplies the table. That property is the entire point of separate compilation with polymorphism, and it is also the source of the binary-compatibility rules discussed in the fragile base class problem.

The vtable also stores a pointer to type information, used by dynamic_cast, typeid, and exception handling, and often a pointer to the virtual base offset table when virtual inheritance is present. Those extra entries sit at negative offsets from the address the vptr points to — the vptr points at the first function slot, with offset-to-top and type information before it — which is why code that reads a vtable through a cast to function pointers must not assume the pointer it loads from the object is the first entry.

Objects that are not polymorphic have no vptr and no table. A class with no virtual functions, or a value type in a language that separates structs from classes, pays nothing for dispatch it does not use. This is a real design axis: C++ stores a vptr only in classes that declare or inherit a virtual function, so a class with none has no table; Java and C# instead dispatch instance methods virtually unless they are final, sealed overrides, private, or (in C#) struct members, and both rely on the runtime and JIT to devirtualize calls that provably have one target. The default changes what a class declaration means, not just what it costs.

A second axis is where the table’s auxiliary data lives. The callee receives only a this pointer, so whatever the runtime needs in addition to the function slots must be reachable from the object without an extra parameter. The Itanium ABI places the type information pointer at a fixed negative offset from the vptr, so a typeid or dynamic_cast can find it from the object alone; the type information structure in turn names the class and its bases, which is how a cast across a hierarchy locates the base subobject. Any scheme that keeps that data in a side table keyed by the vtable address adds a lookup to every type test, which is one reason the negative-offset arrangement is kept even though it makes the vtable layout differ from a plain array of function pointers.

Multiple inheritance and the this pointer

With single inheritance, the base subobject sits at offset zero of the derived object, so a derived pointer and a base pointer hold the same address. A virtual call needs no adjustment: the callee receives the address it expects.

Multiple inheritance breaks that identity. If Derived inherits from Base1 and Base2, only one of them can live at offset zero. The chosen one, called the primary base, shares the derived class’s vptr. The others, the secondary bases, are placed at later offsets and each carries its own vptr pointing at a table containing the entries a Base2-pointer call site expects.

A call through a Base2 pointer to a function overridden in Derived cannot jump straight to the override, because the override expects a pointer to the start of the Derived object while the caller holds a pointer to the embedded Base2 subobject. The compiler emits a small piece of code, a thunk, that adds the fixed offset — Base2 sits at a non-negative offset from the start of Derived — and then jumps to the real function. The adjustment is a constant known at compile time, so the thunk is a handful of instructions rather than a call.

Consequently an object of a class with two polymorphic bases contains at least two vptrs. The cost is eight bytes per polymorphic base subobject plus one thunk per overridden function per secondary base. Virtual inheritance, needed to share a single copy of a common ancestor, adds more: the location of the virtual base is not a compile-time constant, so the object carries a table of offsets and each access to a virtual base member becomes an extra load. That is the price of the diamond, and it is why virtual inheritance appears rarely outside library frameworks that genuinely need it. Languages that instead linearise the ancestor graph, as described in method resolution order in multiple inheritance, avoid the offset table but pay for a lookup order computed once per class.

What the indirection costs

The direct cost is one extra dependent load per call. The vptr load and the slot load are serial, so the call cannot issue until both have completed; if the object is not in cache, the chain is two cache misses deep rather than one. The indirect branch is also harder to predict than a direct call. Processors predict indirect branches by history, so a call site that always sees the same target predicts well, and one that alternates between two targets predicts poorly. A mispredicted indirect branch costs on the order of fifteen to twenty cycles on current hardware, comparable to a mispredicted conditional branch.

The larger costs are on the optimisation side. A virtual call is an optimisation barrier: the compiler cannot inline the callee, cannot propagate constants through the receiver, and cannot prove anything about the callee’s side effects. Inlining is the gateway to nearly every other optimisation, so a hot loop full of virtual calls keeps its abstraction penalty even when each call site is monomorphic in practice. Compilers recover some of this with speculative devirtualisation: if a call site is guarded by a check that the object’s vptr equals a known table, the call can be inlined and the check folded away when the class is provably final. The guard is the same shape as the one a runtime uses for a monomorphic call site, and the removal of a compiled body when its assumption fails is the same problem described in inline caches and polymorphic call sites; in a statically compiled program the invalidation happens between builds rather than at run time. Whole-program compilation and profile data make that guard cheap or unnecessary.

Where the model breaks

The constructor case follows directly from slot assignment. During execution of a base-class constructor, the object’s vptr points at the base class’s table, because the derived part has not been constructed yet. A virtual call made there, directly or through a helper the constructor invokes, dispatches to the base implementation. The same holds in reverse during destruction. This is deliberate: dispatching to an override that reads members not yet initialised would be worse. The base table is simply the one the object points at during that phase. Java’s dispatch during construction differs: an overridable method called from a constructor still dispatches to the override, which can therefore observe a not-yet-constructed receiver, and the language’s advice to avoid calling such methods from constructors exists because the override does run. C++ instead lets the call silently select the base version.

Deleting through a base pointer without a virtual destructor is undefined behaviour for a related reason: the deallocation function and the destructor body are selected by the static type of the pointer, so the derived destructor never runs and the wrong size may be passed to the allocator.

Object slicing is the value-semantics version of the same mistake. Copying a derived object into a base-typed variable copies only the base subobject, vptr included, so the copy dispatches as the base class and silently discards derived state. Languages with value types either forbid the conversion or make it explicit.

Finally, vtable layout is not standardised across C++ compilers in the way the language standard is. The Itanium ABI fixes the layout for the platforms that adopt it, and that is what makes binary interoperability possible on those platforms, but it is an ABI document rather than a language guarantee. Code that assumes a particular slot order, for instance by casting a vtable to an array of function pointers, depends on the ABI and on the absence of virtual bases, and it breaks quietly when either changes. The safe interface to the mechanism is the language’s own virtual call, which is also the one the optimiser understands.

References

  1. Itanium C++ ABI: Object Layout — Itanium C++ ABI project
  2. The Design and Evolution of C++ — Bjarne Stroustrup, Addison-Wesley, 1994
  3. System V Application Binary Interface, AMD64 Supplement — System V ABI working group