C++ Virtual Functions and vtables: How Dynamic Binding Works
이 글의 핵심
Virtual functions are C++'s mechanism for runtime polymorphism. Understanding vtables, vptrs, and dynamic binding lets you write correct polymorphic code and answer the follow-up questions that come after 'virtual means the derived class version runs.'
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
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. How C++ virtual functions work under the hood: vtables, vptrs, static vs dynamic binding, override and final, pure virtu… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ override와 final | ‘가상 함수’ 가이드
- C++ 람다 표현식 | [=]·[&] 캡처와 sort·find_if에서 람다 활용법
- C++ std::function | 콜백·전략 패턴과 함수 객체
이 글에서 다루는 키워드 (관련 검색어)
C++, Interview, Virtual functions, vtable, Polymorphism, Dynamic binding 등으로 검색하시면 이 글이 도움이 됩니다.