Skip to content
jieqi's archive
Go back

C++ Basics: Polymorphism - Virtual Dispatch

Polymorphism in C++ is the ability to call the same interface (a function name, an operator, a call expression) and get behavior that depends on the type involved, decided either at compile time (overloading, templates) or at runtime (virtual dispatch through a base pointer or reference).

                         POLYMORPHISM
                (same interface, many behaviors)
                              |
              +---------------+---------------+
              |                               |
        COMPILE TIME                      RUNTIME
     (type known statically)      (type known only at runtime)
              |                               |
      +-------+-------+              +--------+--------+
      |       |       |              |        |        |
  function  operator  templates   virtual  variant   manual fn
 overloading overloading (incl.   dispatch  + visit   pointers /
                        CRTP)    (open set) (closed   type erasure
                                             set)

Motivation

struct Animal {
    void speak() const { std::cout << "..."; }    // no virtual
};
struct Cat : Animal {
    void speak() const { std::cout << "meow"; }
};

void report(const Animal& a) {
    a.speak();                                     // the frozen call site
}

Cat c{};
report(c);                                         // prints "..."  <- the problem

Why does this print ... instead of meow?

The Fix

virtual void speak() const { std::cout << "..."; }   // <- the one edit

What happens under the hood:

1. Compiler's blueprint for Animal changes

When the compiler lays out Animal and finds a virtual function, its internal blueprint of what Animal looks like changes: it prepends a hidden member — the vptr. Every derived class inherits that layout.

struct Animal {                        struct Animal {
    std::string m_name;                    virtual void speak() const;
    int m_age;                             std::string m_name;
    void speak() const;                    int m_age;
};                                     };

NON-VIRTUAL layout                     POLYMORPHIC layout

offset                                 offset
+----------------------+               +----------------------+
|  0: m_name (32)      |               |  0: vptr    (8)      | <- prepended,
+----------------------+               +----------------------+    always first*
| 32: m_age   (4)      |               |  8: m_name  (32)     |
| 36: padding (4)      |               +----------------------+
+----------------------+               | 40: m_age   (4)      |
sizeof == 40                           | 44: padding (4)      |
                                       +----------------------+
                                       sizeof == 48


And a derived class stacks on top, vptr staying at offset 0:

struct Cat : Animal { int m_lives; };

+----------------------+
|  0: vptr    (8)      |  <- ONE vptr, inherited slot; Cat's ctor
+----------------------+     just re-aims it at Cat's table
|  8: m_name  (32)     |  }
+----------------------+  }  Animal subobject
| 40: m_age   (4)      |  }
| 44: padding (4)      |  }
+----------------------+
| 48: m_lives (4)      |  <- Cat's own members after
| 52: padding (4)      |
+----------------------+
sizeof == 56
2. Vtable emission

The compiler walks the base class top to bottom and assigns each virtual function the next slot number, in declaration order. That ordering is fixed for the whole hierarchy.

For a derived class:

  • Overrides a parent method (matching signature) → inherits the parent’s slot number; its function pointer simply replaces the entry in that slot.
  • Doesn’t override → keeps the inherited entry.
  • Declares a new virtual → extends the table with a fresh slot.
struct Animal {
    virtual void speak() const;      // -> slot 0
    virtual void eat(int grams);     // -> slot 1
    virtual ~Animal();               // -> slot 2 (conceptually one slot)
    void sleep();                    // NOT virtual: no slot, ordinary direct call
};

struct Cat : Animal {
    void speak() const override;     // signature matches slot 0 -> REPLACES entry
    void purr();                     // new non-virtual: no slot
    virtual void pounce();           // NEW virtual: Cat extends the table -> slot 3
};

struct Dog : Animal {
    void eat(int grams) override;    // matches slot 1 -> replaces
    // speak not overridden -> slot 0 entry inherited from Animal
};
SLOT CONTRACT (fixed per hierarchy, declaration order)

slot |  Animal's vtable      Cat's vtable          Dog's vtable
-----+---------------------------------------------------------------
 0   |  &Animal::speak       &Cat::speak    <-R    &Animal::speak <-I
 1   |  &Animal::eat         &Animal::eat   <-I    &Dog::eat      <-R
 2   |  &Animal::~Animal     &Cat::~Cat     <-R    &Dog::~Dog     <-R
 3   |       -               &Cat::pounce   <-N        -

R = replaced by override   I = inherited copy   N = new slot (extension)

Call sites compile against SLOT NUMBERS, not names:

  a.speak()  ->  call [vptr + 0*8]     works on all three columns
  a.eat(5)   ->  call [vptr + 1*8]     works on all three columns
  cat.pounce() -> call [vptr + 3*8]    only valid via Cat*/Cat& :
                                        Animal's table has no slot 3,
                                        and Animal's static type doesn't
                                        even admit the call
3. Codegen at every call site

Following the example code snippet in Motivation: at each virtual call site, instead of a direct jump to a fixed address, the compiler emits an indirect one — follow the object’s vptr, then jump through the slot. The overhead is at most a single pointer traversal per call.

STACK (per object)          RODATA (per class)          TEXT (per function)

Cat c
+-----------+               Animal's vtable
| vptr ─────┼───┐           +-------------------+
+-----------+   │           | 0: Animal::speak ─┼────>  Animal::speak
                │           +-------------------+       { "..." }

                │           Cat's vtable
                └─────────> +-------------------+
                            | 0: Cat::speak ────┼────>  Cat::speak
                            +-------------------+       { "meow" }

report's call site:  follow vptr ──> jump slot 0

c's vptr points at Cat's table, so report(c) prints "meow"

Additional Notes

Why the destructor was made virtual. In the slot table above, virtual ~Animal() quietly occupied slot 2. That’s not decoration — deleting through a base pointer is just another call site, and it freezes the same way report did:

struct Animal {
    virtual ~Animal() { puts("~Animal"); }
};

struct Cat : Animal {
    ~Cat() override { puts("~Cat"); }   // no mention of ~Animal anywhere
};

Animal* a = new Cat{};
delete a;
with virtual ~Animal        without virtual
--------------------        ---------------
~Cat                        ~Animal
~Animal

Hence the rule: if a class has virtual functions, give it a virtual destructor — it already has a vptr, so the slot is free.

Caveats

Virtual dispatch only works through a pointer or reference. report(const Animal& a) dispatches because the reference still points at the full Cat — vptr included. Take the parameter by value instead and dispatch is gone:

void report(Animal a) {   // by value: SLICED
    a.speak();            // always Animal::speak, virtual or not
}

Cat c{};
report(c);                // prints "..."

This is object slicing: a is a brand-new Animal built by Animal’s copy constructor, which copies only the Animal subobject — m_name, m_age, and nothing else. The Cat layer isn’t hidden; it was never copied. a’s vptr points at Animal’s vtable (it is a genuine Animal), so even the vtable agrees: "..." is the right answer for what a now is.

The mental model: the vptr answers “what does this really point at?” — pass by value and there’s no pointing left, just a fresh base object. Polymorphic types travel by &, *, or smart pointer; the moment one is copied into a base-typed variable, its derived identity is gone.

Next Post
Understanding ELF