본문으로 건너뛰기
Previous
Next
C++ Virtual Functions and vtables: How Dynamic Binding Works

C++ Virtual Functions and vtables: How Dynamic Binding Works

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():

  1. Load a->vptr (the Dog vtable pointer)
  2. Index into the vtable at the slot for speak
  3. 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:

  1. Load the vptr (usually one extra memory access)
  2. Index into the vtable (array access)
  3. 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:

  • final on 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
  • override catches 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++, Interview, Virtual functions, vtable, Polymorphism, Dynamic binding 등으로 검색하시면 이 글이 도움이 됩니다.