C++ Virtual Functions and vtables: How Dynamic Binding Works
This part of the series follows one question from start to finish: when you call a function through a base pointer, how does the program decide which body runs, and what does that decision cost? It covers the language rules (binding, override/final, pure virtual, virtual calls during construction, default arguments) together with the machinery behind them. Two neighbouring articles go further in one direction each: C++ Virtual Functions: Polymorphism, override, and Pure is built around design examples (a composite file tree, swappable strategies, loggers), and C++ VTable Explained stays at the level of object layout and call cost.
Static vs Dynamic Binding
When you call a method through a pointer or reference, C++ must decide which function to run. It makes this decision in two different ways:
Static binding (non-virtual): the compiler resolves the call at compile time based on the declared type of the pointer or reference.
Dynamic binding (virtual): the call is resolved at runtime based on the actual type of the object.
#include <iostream>
class Animal {
public:
void breathe() { // non-virtual
std::cout << "Animal breathes\n";
}
virtual void speak() { // virtual
std::cout << "...\n";
}
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void breathe() { // hides Animal::breathe
std::cout << "Dog breathes\n";
}
void speak() override { // overrides Animal::speak
std::cout << "Woof\n";
}
};
int main() {
Animal* a = new Dog();
a->breathe(); // Static binding — calls Animal::breathe (declared type)
a->speak(); // Dynamic binding — calls Dog::speak (actual type)
delete a;
}
// Output:
// Animal breathes
// Woof
The virtual keyword tells the compiler: “don’t resolve this at compile time — check the actual object type at runtime.”
Note that Dog::breathe is not an error and not an override; it simply hides Animal::breathe for code that uses the Dog type directly. Calling breathe() on a Dog object or Dog* prints “Dog breathes”, but the same object through an Animal* prints “Animal breathes”. That split behavior is almost never what anyone wants, and it is the reason most style guides say that a function you intend to customize in a derived class must be virtual in the base, and that redeclaring a non-virtual base function in a derived class is a code smell. Also note that dynamic binding only happens through pointers and references. Calling speak() on an Animal object (not a pointer) always calls Animal::speak, because the object’s type is known exactly at compile time.
How vtables Work
Every class with at least one virtual function has a vtable (virtual dispatch table) — an array of function pointers for each virtual function in the class.
Every object of that class stores a hidden vptr (virtual pointer), pointing to the class’s vtable. The C++ standard does not mention vtables at all; this is how every mainstream compiler implements virtual dispatch. In the Itanium C++ ABI (GCC and Clang on Linux and macOS) and in MSVC, the vptr of the primary base sits at offset 0 of the object, which is why it is often described as “the first member”. It costs one pointer (8 bytes on 64-bit platforms) per object, plus padding, which matters for small objects stored by the million.
Dog object in memory:
┌─────────────┐
│ vptr │ ──→ Dog's vtable
├─────────────┤ ┌────────────────────┐
│ members │ │ [0] Dog::speak() │
└─────────────┘ │ [1] ~Dog() │
└────────────────────┘
When you call a->speak():
- Load
a->vptr(the Dog vtable pointer) - Index into the vtable at the slot for
speak - Call the function pointer stored there
This is why Dog::speak() runs even through an Animal* — the vptr inside the Dog object points to Dog’s vtable, not Animal’s.
Viewing vtables with GCC and Clang
# GCC 8 and later (the old -fdump-class-hierarchy flag was removed)
g++ -fdump-lang-class -c your_file.cpp
# Creates your_file.cpp.001l.class with the vtable layout of each class
# Clang
clang++ -Xclang -fdump-vtable-layouts -fsyntax-only your_file.cpp
The vtable also holds more than function pointers. In the Itanium ABI, the slots before the function pointers store the “offset to top” (used when casting from a secondary base back to the full object) and a pointer to the class’s type_info. That is why typeid(*p) and dynamic_cast only work on polymorphic types: they find the object’s real type by following the vptr.
Where the vtable lives, and “undefined reference to vtable”
The compiler has to emit each vtable in exactly one object file. GCC and Clang choose the translation unit that defines the class’s key function: the first non-inline, non-pure virtual function declared in the class. If you declare a virtual function but never define it, no translation unit emits the vtable, and the error does not mention your function at all:
undefined reference to `vtable for Dog'
This linker error points at the constructor (which writes the vptr), not at the missing function, so it is confusing the first time. The usual causes are a virtual function declared in the header but missing from the .cpp, a .cpp that is not in the build, or, with Qt, a class with Q_OBJECT whose moc file is not compiled. Defining the missing virtual function, or marking it = 0 if it is meant to be abstract, fixes it.
Virtual Destructor — The Most Common Mistake
If you delete a Derived* through a Base*, and Base’s destructor is not virtual, only Base::~Base() runs. The Derived destructor is skipped — undefined behavior and resource leaks.
class Base {
public:
~Base() { // NOT virtual — wrong
std::cout << "Base destructor\n";
}
};
class Derived : public Base {
int* data;
public:
Derived() : data(new int[100]) {}
~Derived() {
delete[] data; // this NEVER runs if deleted through Base*
}
};
Base* p = new Derived();
delete p; // UB: calls Base::~Base only, leaks Derived::data
Rule: if a class is intended to be used as a base class (especially with delete through base pointers), give it a virtual destructor:
class Base {
public:
virtual ~Base() = default; // correct
};
If you don’t want a class to be deleted through a base pointer, document that and consider making the destructor protected non-virtual instead. With a protected destructor, delete basePtr; does not compile, so the mistake becomes a build error instead of undefined behavior. std::unique_ptr<Base> also needs the virtual destructor: unique_ptr calls delete on a Base*, so std::unique_ptr<Base> p = std::make_unique<Derived>(); has the same problem as the raw pointer example above. std::shared_ptr is the exception, because make_shared<Derived> remembers the real type’s deleter, but relying on that is fragile. GCC and Clang warn with -Wdelete-non-virtual-dtor (enabled by -Wall) when you delete a polymorphic type that has a non-virtual destructor.
override and final
override and final are contextual keywords (not reserved — you can use them as variable names, though you shouldn’t).
class Shape {
public:
virtual double area() const = 0;
virtual void describe() const;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
double area() const override; // compile error if signature doesn't match Shape::area
// void describe() const override; // uncomment to override
// void describe() override; // compile error: missing const — wrong signature
void draw() final; // no class derived from Circle can override draw()
};
class FinalShape final : public Shape { // no class can inherit from FinalShape
public:
double area() const override { return 0; }
};
// class MoreShape : public FinalShape {}; // compile error
override catches the most common error in virtual function hierarchies: accidentally hiding a base function instead of overriding it (wrong signature — different const, different parameters). Without override, void describe() in Circle would silently declare a new, unrelated function, and calls through a Shape* would keep running Shape::describe. With it, GCC reports 'void Circle::describe()' marked 'override', but does not override. Clang’s -Winconsistent-missing-override warns when some overrides in a class use the keyword and others don’t, which helps enforce it across a codebase.
final has a performance side effect as well: if the compiler knows a class or function is final, a call through a pointer to that exact type cannot be overridden further, so it can call the function directly (devirtualize) and even inline it.
Pure Virtual Functions and Abstract Classes
A pure virtual function (= 0) has no implementation in the base class. A class with at least one pure virtual function is abstract — you cannot instantiate it directly.
class Serializable {
public:
virtual std::string serialize() const = 0; // pure virtual
virtual void deserialize(const std::string&) = 0; // pure virtual
virtual ~Serializable() = default;
};
class JsonDocument : public Serializable {
std::string content_;
public:
std::string serialize() const override {
return "{\"content\": \"" + content_ + "\"}";
}
void deserialize(const std::string& json) override {
// parse json into content_
}
};
// Serializable s; // compile error: abstract class
JsonDocument doc; // OK — implements all pure virtuals
You can provide a default implementation for a pure virtual function — derived classes still must override it, but they can call the base implementation:
class Base {
public:
virtual void doWork() = 0; // pure virtual with body
};
void Base::doWork() { // implementation provided
std::cout << "Base::doWork\n";
}
class Derived : public Base {
public:
void doWork() override {
Base::doWork(); // call the base implementation
std::cout << "Derived::doWork\n";
}
};
Object Slicing
Dynamic binding only works through a pointer or reference. Copying a derived object into a base-typed value (Vehicle v = car;) runs Vehicle’s copy constructor, which builds a brand-new Vehicle with Vehicle’s vptr; the derived members are dropped and v.type() prints “Vehicle”. The copy constructor never copies the vptr from the source. Pass and store polymorphic objects by reference, pointer or smart pointer, and make the base class’s copy operations protected (C++ Core Guidelines C.67) if you want accidental copies rejected at compile time. Where slicing hides beyond the obvious case (containers of base values, catch by value, throw e;) and how to design the base class so it cannot happen are in C++ Object Slicing.
Virtual Calls in Constructors and Destructors
class Base {
public:
Base() { init(); } // calls Base::init, not Derived::init
virtual void init() { std::cout << "Base::init\n"; }
virtual ~Base() = default;
};
class Derived : public Base {
std::string name_ = "derived";
public:
void init() override { std::cout << "Derived::init " << name_ << '\n'; }
};
Derived d; // prints "Base::init"
While Base::Base() runs, the Derived part of the object does not exist yet (name_ has not been constructed), so the language deliberately makes the object behave as a Base: the constructor sets the vptr to Base’s vtable, and only Derived’s constructor switches it to Derived’s. Calling Derived::init at that point would read an unconstructed std::string. The same happens in reverse during destruction. If the function is pure virtual in Base, the call has no valid target: calling it directly from the constructor is usually caught at link time, but calling it indirectly (through a helper) typically crashes at runtime with pure virtual method called followed by terminate called without an active exception (GCC/Clang) or “R6025 pure virtual function call” (MSVC). The fix is a two-phase pattern: construct the object fully, then call init() from a factory function.
Default Arguments on Virtual Functions
Default argument values are resolved at compile time using the static type. This creates a surprise when virtual functions have default parameters:
class Base {
public:
virtual void greet(std::string name = "World") const {
std::cout << "Hello, " << name << "!\n";
}
};
class Derived : public Base {
public:
void greet(std::string name = "C++") const override {
std::cout << "Hi, " << name << "!\n";
}
};
Base* p = new Derived();
p->greet(); // Calls Derived::greet with name="World" (Base's default!)
// Output: Hi, World!
The function called is Derived::greet (dynamic binding), but the default argument value comes from Base::greet (static binding). The result is confusing.
Rule: don’t use different default argument values in virtual function overrides. If you need defaults, use Non-Virtual Interface (NVI):
class Base {
public:
void greet(std::string name = "World") const { // non-virtual, owns the default
greetImpl(name);
}
private:
virtual void greetImpl(const std::string& name) const {
std::cout << "Hello, " << name << "!\n";
}
};
Multiple Inheritance and vtables
With multiple inheritance, an object may have multiple vptrs — one per polymorphic base class:
class A {
public:
virtual void fa() {}
virtual ~A() = default;
};
class B {
public:
virtual void fb() {}
virtual ~B() = default;
};
class C : public A, public B {
public:
void fa() override {}
void fb() override {}
};
A C object has two vptrs — one for the A subobject, one for the B subobject. Casting a C* to B* adjusts the pointer to point to the B subobject, which has its own vptr. This pointer adjustment is automatic and transparent.
Performance
A virtual call costs:
- Load the vptr (usually one extra memory access)
- Index into the vtable (array access)
- Indirect call through the function pointer
The indirect call itself is cheap on modern CPUs, because the branch predictor also predicts indirect branch targets; a call site that always sees the same dynamic type is predicted almost perfectly. The costs that actually show up in profiles are different: the call cannot be inlined, so the optimizer loses the chance to fold constants and vectorize across the call, and when a loop mixes many dynamic types (for example, a vector of pointers to a dozen shape classes in random order), the prediction misses and each object’s vtable and code may not be in cache. The impact depends heavily on the workload; it matters mainly in tight inner loops called millions of times per second (game physics, audio processing, financial tick processing).
A common fix for the mixed-type case is to store objects grouped by type (one vector per concrete class) so that each loop sees a single type, which keeps the predictor and cache happy while keeping the virtual interface.
Optimization options when virtual calls are a measured bottleneck:
finalon the class — the compiler may devirtualize calls to final classes- Link-Time Optimization (LTO) — can devirtualize across translation units
- Static polymorphism with CRTP (Curiously Recurring Template Pattern) — no runtime overhead
std::variant+std::visit— type-erased dispatch without vtables for small, fixed type sets
How final and LTO devirtualize calls, and what the object layout looks like in memory, are covered in C++ VTable Explained.
Rule: measure before removing virtual. The abstraction almost always costs less than you think, and the code clarity is usually worth it.
Virtual dispatch rules worth memorizing
- Virtual functions use the object’s actual type at runtime (dynamic binding); non-virtual uses the declared pointer type (static binding)
- vtable: one per class, a table of function pointers for each virtual function
- vptr: one per object (per polymorphic base subobject), a hidden pointer to the class’s vtable
- Virtual destructor is required whenever you delete through a base pointer — skip it and you get UB
overridecatches signature mismatches at compile time — always use it when overriding- Object slicing silently discards derived data when copying by value — use pointers or references
- Default arguments use static binding for the value but dynamic binding for dispatch — don’t use different defaults in overrides
- Virtual call overhead is real but small — profile before optimizing away polymorphism
Frequently Asked Questions (FAQ)
Q. Why does an override run but use the base class’s default argument value?
A. Default arguments are chosen at compile time from the static type of the expression, while the function body is chosen at runtime through the vtable. So base_ptr->greet() runs Derived::greet but passes the default declared on Base::greet. Avoid different defaults in overrides; the Non-Virtual Interface pattern from the article keeps the default in one non-virtual public function.
Related Articles
- C++ override & final
- Fixing “undefined reference to vtable”
- C++ Object Slicing
- CRTP: static polymorphism