C++ VTable Explained: Virtual Function Tables & Dynamic Dispatch
Key takeaways
How C++ vtables and vptrs implement polymorphism: indirect calls, object size, multiple inheritance costs, and optimization with final and NVI.
What is a VTable?
A vtable is a table that stores pointers to virtual functions. Neither vtables nor vptrs are mentioned anywhere in the C++ standard — the standard only guarantees the behavior (calling a virtual function dispatches to the most-derived override), not the mechanism. The vtable/vptr scheme is simply what every major compiler (GCC, Clang, MSVC) independently converged on to implement that behavior efficiently, which is why it’s worth understanding even though you’ll never write vtable in your own source code: it explains why virtual functions have the specific costs they do (extra pointer per object, one indirect jump per call) rather than being free.
This article is about that mechanism: layout, dispatch, cost and how compilers remove it. For how to use virtual functions in a design (override, pure virtual interfaces, worked examples) see C++ Virtual Functions: Polymorphism, override, and Pure, and for the language rules around binding, default arguments and virtual calls during construction see Virtual Functions and vtables: How Dynamic Binding Works.
class Base {
public:
virtual void func() {}
};
// Memory layout:
// [vptr] -> VTable -> [address of func]
vptr, vtable, and one indirect call
Every object of a polymorphic class type (one with at least one virtual function) carries a hidden pointer — the vptr — set by the constructor to point at its class’s vtable. The vtable itself is a single shared table per class, not per object; what makes a->speak() below call Dog::speak() even though a is declared as Animal* is that a’s vptr was set to point at Dog’s vtable when the Dog object was constructed, regardless of what static type the pointer holding it has. The compiler generates a->speak() as “follow a’s vptr, index into the table, jump to whatever function pointer is stored there” — it genuinely does not know at compile time which function will run.
class Animal {
public:
virtual void speak() {
std::cout << "Animal" << std::endl;
}
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "Woof!" << std::endl;
}
};
// VTable:
// Animal: [speak -> Animal::speak]
// Dog: [speak -> Dog::speak]
Animal* a = new Dog();
a->speak(); // vptr -> Dog vtable -> Dog::speak
Layout, dispatch, vptr inspection, and call cost
Memory layout of a polymorphic object
The 8 (vptr) + 4 (data) + padding breakdown below is architecture-specific, not a language guarantee — it’s the typical layout on a 64-bit platform (8-byte pointer) with 4-byte int and 8-byte struct alignment, which is why the actual sizeof(b) comes out to 16 rather than the “obvious” 12. This is the concrete cost of adding even a single virtual function to a class: every instance pays for a pointer it wouldn’t otherwise need, which matters for classes you allocate in bulk (particle systems, graph nodes) more than it does for a handful of long-lived objects.
#include <iostream>
class Base {
public:
virtual void func1() {}
virtual void func2() {}
int data = 10;
};
int main() {
Base b;
std::cout << "size: " << sizeof(b) << std::endl;
// 8 (vptr) + 4 (data) + padding
}
Dispatch through a base pointer
class Shape {
public:
virtual void draw() = 0;
virtual double area() = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
void draw() override {
std::cout << "Circle" << std::endl;
}
double area() override {
return 3.14 * radius * radius;
}
};
class Rectangle : public Shape {
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
void draw() override {
std::cout << "Rectangle" << std::endl;
}
double area() override {
return width * height;
}
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5));
shapes.push_back(std::make_unique<Rectangle>(4, 6));
for (auto& shape : shapes) {
shape->draw();
std::cout << "Area: " << shape->area() << std::endl;
}
}
Peeking at the vptr (for learning only)
A caution before you run this: *(void***)&b is reinterpreting an object’s memory through a pointer type the standard never sanctioned for this purpose — it happens to work on GCC, Clang, and MSVC because they all place the vptr at offset 0 of a polymorphic object, but that layout is an implementation detail of the Itanium C++ ABI (and MSVC’s equivalent), not something the standard promises. This is genuinely undefined behavior by the letter of the standard, even though every mainstream compiler’s behavior here is stable and well-documented in practice. Treat this as a debugging trick for understanding the mechanism, not a pattern to ship in production code — dynamic_cast and typeid are the standard-sanctioned ways to inspect an object’s dynamic type.
class Base {
public:
virtual void func() {}
};
class Derived : public Base {
public:
void func() override {}
};
int main() {
Base b;
Derived d;
// vptr location (often first 8 bytes)
void** vptr_b = *(void***)&b;
void** vptr_d = *(void***)&d;
std::cout << "Base vptr: " << vptr_b << std::endl;
std::cout << "Derived vptr: " << vptr_d << std::endl;
}
Virtual vs direct call cost
The direct call to nv.func() can be inlined by the compiler if it’s small enough — the compiler sees the exact function at compile time, so there’s nothing stopping it. The virtual call v.func() cannot be inlined in the general case, because the compiler genuinely doesn’t know which override will run until runtime (unless it can prove the dynamic type through other means, which optimizers sometimes manage via “devirtualization” when the type is locally provable). Losing inlining is often a bigger performance cost in practice than the indirect jump itself — a virtual function that would have been a single inlined instruction becomes a real function call with its own stack frame, argument passing, and no cross-function optimization.
class NonVirtual {
public:
void func() {}
};
class Virtual {
public:
virtual void func() {}
};
// Call comparison
NonVirtual nv;
nv.func(); // direct call
Virtual v;
v.func(); // vptr -> vtable -> func (indirect)
Destructors, constructor calls, call overhead, and object size
Missing virtual destructor
This is the pitfall I’d flag as the single most dangerous one on this list, because the failure is silent — delete b below compiles cleanly, runs without crashing, and simply leaks data (and skips any other cleanup ~Derived() was supposed to do) with no warning from the compiler by default. The mechanism is exactly the vtable indirection from earlier: without a virtual destructor, delete b resolves to Base::~Base() at compile time (static dispatch, since ~Base() isn’t virtual), so the destructor call never even looks at b’s vptr to find Derived::~Destructor. I’ve seen this exact bug hide in a codebase for months — the leak was small enough per-object that it only showed up as a slow, generalized memory-growth pattern in production monitoring, not an obvious crash, which made it far harder to trace back to “the base class destructor isn’t virtual” than a segfault would have been. The rule that avoids this entirely: any class intended to be deleted through a base pointer needs a virtual destructor, full stop — and -Wnon-virtual-dtor (Clang/GCC) or its MSVC equivalent will catch this at compile time if you enable it.
// Bad: non-virtual destructor
class Base {
public:
~Base() {} // non-virtual
};
class Derived : public Base {
int* data;
public:
~Derived() { delete[] data; }
};
Base* b = new Derived();
delete b; // Derived destructor not called
// Good: virtual destructor
class Base {
public:
virtual ~Base() {}
};
Virtual calls from constructors
This one surprises people coming from languages like Java or Python, where calling an overridable method from a constructor does dispatch to the most-derived override. C++ deliberately does the opposite, and the vtable mechanism explains why: during Base’s constructor body, the object being constructed is not yet a Derived — its vptr still points at Base’s vtable, because Derived’s constructor hasn’t run yet to set it to Derived’s vtable (that happens after the base subobject is fully constructed). So init() called from inside Base::Base() unavoidably calls Base::init(), never Derived::init(), regardless of what the actual dynamic type of the object will eventually be. This isn’t a bug in the compiler — it’s the only behavior that’s actually safe, since Derived::init() might touch Derived’s member variables, which don’t exist yet at that point in construction.
class Base {
public:
Base() {
init(); // calls Base::init (no polymorphism in ctor)
}
virtual void init() {
std::cout << "Base init" << std::endl;
}
};
class Derived : public Base {
public:
void init() override {
std::cout << "Derived init" << std::endl;
}
};
Call overhead and final
final on a class or a specific override tells the compiler “no further class will ever override this,” which removes the runtime uncertainty a virtual call otherwise has — if the compiler can see the object’s static type is exactly Derived (not just “some subtype of Base”), and Derived::func is final, it can resolve the call directly instead of going through the vtable, and potentially inline it. This optimization only fires when the compiler can prove the concrete type locally (a stack-allocated Derived object, say) — calling through a Base* of unknown provenance still can’t be devirtualized even if the underlying class happens to be final, since the pointer’s static type doesn’t carry that guarantee across a function boundary in the general case.
// Virtual calls are indirect
for (int i = 0; i < 1000000; i++) {
obj->virtualFunc(); // vtable lookup
}
// Optimization: final
class Derived final : public Base {
void func() override final {}
};
One vptr per object
The vptr cost is per-object, not per-virtual-function — a class with one virtual function and a class with fifty virtual functions both add exactly one pointer’s worth of size to every instance, because there’s only ever one vptr pointing at one vtable per class (the vtable itself, which holds all fifty function pointers, is shared across every instance and doesn’t multiply per object). This matters when deciding between “a few classes with many virtual methods” versus “many small classes each with one or two virtuals” — the per-object overhead is the same fixed cost either way, so object-count matters far more than virtual-function-count for this specific cost.
class NoVirtual {
int x;
}; // sizeof may be 4
class WithVirtual {
int x;
virtual void func() {}
}; // sizeof includes vptr
Devirtualization, final, and NVI
The Non-Virtual Interface (NVI) pattern below inverts the usual “make everything virtual so subclasses can customize it” instinct: the public entry point (func()) is non-virtual and fixed, while only the actual customization point (funcImpl()) is virtual and private. This buys you a place to enforce invariants that run on every call regardless of which subclass is involved — logging, precondition checks, a lock acquired before the customizable part runs — without every subclass having to remember to call some super()-equivalent first (C++ has no such mechanism, unlike languages with super.method()). It’s a genuinely underused pattern relative to how often “should this be virtual” comes up in a design review.
// 1. final keyword
class Base {
virtual void func() {}
};
class Derived final : public Base {
void func() override final {}
};
// 2. Non-virtual interface (NVI)
class Base {
public:
void func() { // non-virtual
funcImpl();
}
private:
virtual void funcImpl() {}
};
FAQ
Q1: When do you get a vtable?
A: For classes that have virtual functions.
Q2: Performance impact?
A:
- Indirect calls
- Extra memory (vptr)
- Possible cache misses
Q3: Is a virtual destructor required?
A: When you delete through a base pointer to a derived object—yes.
Q4: How to optimize?
A:
final- Non-virtual interface idiom
- Templates (e.g. CRTP)
Q5: How big is a vtable?
A: Roughly number of virtual functions × pointer size.
Related Articles
- Virtual functions and vtable mechanics (series)
- C++ Virtual Functions
- CRTP vs Virtual Functions: Static Polymorphism Without vtable Overhead
- C++ vtable linker error