C++ Virtual Functions and vtables: How Dynamic Binding Works
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.”
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) as its first member, pointing to the class’s vtable.
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
g++ -fdump-class-hierarchy -c your_file.cpp
# Creates a .class file showing vtable layout for each class
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.
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).
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
Slicing is a subtle bug that happens when you copy a derived object into a base variable by value:
class Vehicle {
public:
int speed = 0;
virtual std::string type() const { return "Vehicle"; }
};
class Car : public Vehicle {
public:
int doors = 4;
std::string type() const override { return "Car"; }
};
Car car;
car.speed = 100;
car.doors = 4;
Vehicle v = car; // slicing: Car's `doors` and vtable entry are LOST
std::cout << v.type(); // prints "Vehicle", not "Car"
std::cout << v.doors; // compile error: Vehicle has no 'doors'
After slicing, v is a genuine Vehicle object — it lost the derived portion completely. The vptr now points to Vehicle’s vtable.
Prevention: use pointers or references for polymorphic types:
Vehicle& vr = car; // reference to Car — no slicing
vr.type(); // prints "Car"
Vehicle* vp = &car; // pointer to Car — no slicing
vp->type(); // prints "Car"
// Make base classes non-copyable if slicing would be dangerous:
class Base {
public:
Base(const Base&) = delete;
Base& operator=(const Base&) = delete;
virtual ~Base() = default;
};
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 (no branch prediction, prevents inlining)
For most code, this is a few nanoseconds — negligible. It matters in tight inner loops called millions of times per second (game physics, audio processing, financial tick processing).
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
Rule: measure before removing virtual. The abstraction almost always costs less than you think, and the code clarity is usually worth it.
Key Takeaways
- 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, hidden first member pointing 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. When would I use this in practice?
A. How C++ virtual functions work under the hood: vtables, vptrs, static vs dynamic binding, override and final, pure.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- C++ override와 final | ‘가상 함수’ 가이드
- C++ 람다 표현식 | [=]·[&] 캡처와 sort·find_if에서 람다 활용법
- C++ std::function | 콜백·전략 패턴과 함수 객체
Keywords Covered in This Article (Related Search Terms)
This article covers C++, Interview, Virtual functions, vtable, Polymorphism, Dynamic binding.