C++ / INHERITANCE AND POLYMORPHISM
How vtables implement dynamic dispatch
Trace a virtual call down to its real steps: load the object's hidden vtable pointer, index a fixed slot, call through it.
What you will learn
- Describe a virtual call as: load vptr, index a compile-time slot, indirect call
- Predict how sizeof changes when a class gains its first virtual function
- Explain why vtables are per class while only the vptr lives inside each object
- Say why a virtual call inside a base constructor runs the base version
Understanding How vtables implement dynamic dispatch
When the compiler sees `p->area()` through a `Shape*`, it cannot know whether the object at the other end is a `Shape`, a `Square`, or a class written later in another translation unit. Rather than decide, it plans one indirection. For every class with virtual functions it emits a vtable: a single static array of function pointers, laid out in the order the virtual functions were first declared in the hierarchy. Every object of such a class carries a hidden pointer to that array, and the constructor is what stores it.
The call site knows only the static type, and that is enough to fix a slot number. `p->area()` becomes roughly: load the vptr out of `*p`, load the pointer sitting at slot k of that table, call it with `p` passed as `this`. The index k is a constant baked into the instructions, so nothing is compared, searched, or matched by name at run time, and the cost is one extra load plus an indirect call whether the hierarchy is two levels deep or ten. Overriding never appends a slot: `Square` reuses slot k and stores the address of `Square::area` there.
Two consequences follow. Objects grow by exactly one pointer, once, no matter how many virtual functions you declare, because the object holds a pointer to the table and not the table itself; and all objects of the same most-derived type share one vtable, so the value of the vptr is the only thing that tells a `Square` apart from a `Shape` at run time. The vptr is also rewritten by each constructor in the chain, base first, which means the dynamic type of an object effectively changes while it is being built.
Because the slot index is chosen from the static type at compile time, the layout of a base class vtable is part of a library's binary interface, not just its source interface.
<cstring>
<iostream>
struct Shape {
virtual ~Shape() = default;
virtual double area() const { return 0.0; }
virtual const char* name() const { return "Shape"; }
};
struct Square : Shape {
double side;
explicit Square(double side) : side(side) {}
double area() const override { return side * side; }
const char* name() const override { return "Square"; }
};
// Not portable: on the usual ABI the hidden vptr sits at offset 0.
// Copied out here only to make the mechanism visible.
const void* vptr_of(const Shape& s) {
const void* v = nullptr;
std::memcpy(&v, &s, sizeof v);
return v;
}
int main() {
Shape generic;
Square small(2.0), big(5.0);
const Shape* items[] = {&generic, &small, &big};
for (const Shape* p : items)
std::cout << p->name() << " area=" << p->area() << '\n';
std::cout << std::boolalpha
<< "a Shape is just a vptr: " << (sizeof(Shape) == sizeof(void*)) << '\n'
<< "small and big share a vtable: " << (vptr_of(small) == vptr_of(big)) << '\n'
<< "generic uses a different one: " << (vptr_of(generic) != vptr_of(small)) << '\n';
}
A virtual call is a load of the object's hidden vtable pointer followed by an indirect call through a slot index the compiler fixed at compile time.
Worked examples
Non-virtual functions have no slot
Shows that only virtual functions go through the vtable, while everything else is bound from the static type.
<iostream>
struct Base {
void tag() const { std::cout << "Base::tag\n"; } // no vtable slot
virtual void id() const { std::cout << "Base::id\n"; } // occupies a slot
virtual ~Base() = default;
};
struct Derived : Base {
void tag() const { std::cout << "Derived::tag\n"; } // hides, not overrides
void id() const override { std::cout << "Derived::id\n"; }
};
int main() {
Derived d;
Base* p = &d;
p->tag(); // address fixed at compile time
p->id(); // fetched from the object's vtable
d.tag(); // static type of d is Derived
}
Example explained
Line 1`tag` is not virtual, so it has no slot at all and `p->tag()` is resolved from the static type `Base*` alone.
Line 2`id` is virtual, so `p->id()` loads the vptr and calls whatever address sits in `id`'s slot, which for this object is `Derived::id`.
Line 3`Derived::tag` overrides nothing; it merely hides the name, and `d.tag()` selects it only because `d` is declared as `Derived`.
Line 4The two calls on the same pointer `p` behave differently purely because one goes through a slot and one does not.
The vptr changes during construction
Demonstrates that each constructor installs its own class's vtable, so the dynamic type grows as the object is built.
<iostream>
struct Base {
Base() { std::cout << "Base ctor sees: " << label() << '\n'; }
virtual const char* label() const { return "Base"; }
virtual ~Base() = default;
};
struct Derived : Base {
Derived() { std::cout << "Derived ctor sees: " << label() << '\n'; }
const char* label() const override { return "Derived"; }
};
int main() {
Derived d;
Base* p = &d;
std::cout << "after construction: " << d.label() << '\n';
std::cout << "through Base*: " << p->label() << '\n';
}
Example explained
Line 1`Base()` runs first and sets the vptr to `Base`'s vtable before its body executes, so `label()` there reaches `Base::label`.
Line 2`Derived()` then overwrites the vptr with `Derived`'s vtable, and the identical call in its body now lands on `Derived::label`.
Line 3The call sites in the two constructor bodies are compiled the same way; only the value stored in the object's vptr differs.
Line 4Once construction finishes the vptr stays pointing at `Derived`'s table, which is why both later calls print `Derived`.
Important notes
The standard specifies only the behaviour, never vtables; the slot ordering, the offset of the vptr, and the neighbouring RTTI and offset-to-top entries are ABI details, so the memcpy above is a demonstration and not something to rely on in real code.
A class with virtual functions is not trivially copyable: memcpy or memset over such an object writes the vptr field directly, and a zeroed or mismatched vptr turns the next virtual call into a crash.
Common mistakes
Assuming each object stores its own copy of the table, so every added virtual function makes objects bigger; in reality sizeof grows by one pointer exactly once, which leads to wrong conclusions in memory budgets and premature avoidance of virtuals.
Calling a virtual function from a base constructor and expecting the derived override: the vptr still names the base's table, so the base version runs with no warning, and if that slot holds a pure virtual the program terminates.
Inserting or reordering a virtual function in a base class of an already shipped shared library: existing callers have the old slot indices compiled in, so they call the wrong entry and usually crash, with nothing failing at compile time.
Try it yourself
Change, predict, then run
Write a `Base` with two virtual functions `speak()` and `size()` plus a `Derived` that overrides only `speak()`, then call both through a `Base*` pointing at a `Derived` and confirm one slot changed while the other still reaches the base. Print `sizeof(Base)`, add a third virtual function to `Base`, and check that the size is unchanged.
Open the C++ workspaceCheck your understanding
A `Base*` points to a `Derived` object, and `Base` declares five virtual functions. What does the compiler emit for the call `p->third()`?
- A direct call to `Base::third`, because the compiler knows only the static type at the call site
- A lookup that compares the object's stored type name against each class in the hierarchy, then a direct call
- A load of the object's vptr, a load from a slot index fixed at compile time, then an indirect call
- A search through the vtable for the entry whose name matches `third`
Show answer
The static type `Base` is used to compute a constant slot index, not to pick the target function, so the emitted code loads the vptr, reads that one slot, and calls through it; the slot's contents differ per dynamic type. Option 1 is tempting because the compiler really does know only the static type there, but binding directly to `Base::third` would make overriding impossible; direct calls appear only when the compiler can prove the dynamic type. Names are not present at run time in this mechanism at all, so nothing is compared or searched.