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?
reportis written once for anyAnimal— but it’s also compiled once. The compiler sees only the static typeconst Animal&, soa.speak()becomes a direct jump toAnimal::speak.- It can’t do better: one fixed address must serve every subclass, including ones written years later in other translation units.
- What we want — each object answers as what it really is — requires deciding at runtime, using information the object itself carries. The vptr + vtable is that mechanism, and
virtualis the switch: one keyword onAnimal::speak, and the same unmodifiedreportprintsmeow.
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
- Virtual:
delete afollows the vptr to the most derived destructor,Cat::~Cat. From there the chain is automatic — the compiler plants a direct call toAnimal::~Animalat the bottom of every derived destructor, which is why~Animalprints without~Catever mentioning it. Dispatch picks the entry point; chaining down to the base is ordinary direct calls. - Non-virtual: the call binds to the static type, so the chain starts at
Animal::~Animal— and theCatlayer is never destroyed. Formally this is undefined behaviour; leaking the derived members is just the lucky outcome.
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.