Polymorphism and the vtable: paying for dynamic dispatch
Lesson 09 proved that a non-virtual method call is free: it compiles to a plain function call with a hidden this argument, exactly what you'd hand-write, and the optimizer can inline straight through it. The compiler knows the function at compile time because it knows the object's type. This lesson introduces the one abstraction in that movement you genuinely pay for: virtual dispatch, where the function actually called is chosen at runtime by the object's real type. We will draw the machinery that makes that choice — the vtable and the per-object vptr — count the cost in pointer hops and a defeated inline, tie it back to the cache (Lesson 08), and say precisely when that cost earns its keep and when it is pure waste.
virtual is where you ask for something — picking the function at runtime — and so you pay for it: one extra indirection, one table lookup, and the loss of inlining. That is a small, itemized bill, not a hidden tax. The skill is knowing what landed on the bill and whether you needed the purchase: an open set of types that vary at runtime earns dynamic dispatch; a closed set in a hot loop does not.this pointer, that the optimizer can inline. You also need pointers/references as indirection (Lesson 03) and the cache cost of chasing a pointer (Lesson 08).New capability: explain what
virtual compiles to (a vptr load + a vtable lookup + an indirect call), price it against the free call from Lesson 09, recognize and avoid object slicing, write override/final correctly, and decide between runtime dispatch and the zero-cost alternatives previewed for Lesson 12.virtual and how the call is actually resolved. (3) The vtable and vptr, drawn explicitly. (4) A full worked example — a Shape hierarchy with virtual area(), the memory diagram, and the call trace, contrasted with Lesson 09's free call. (5) The cost, itemized: indirection, defeated inlining, cache. (6) When it earns its keep, object slicing, override/final, and the zero-cost alternatives.1 · The problem: one pointer, many types, chosen at runtime
Suppose you have a collection of shapes — circles, rectangles, triangles — and you want the total area. In Lesson 09 every call resolved at compile time because the compiler always knew the exact type of the object. But here you want to hold different concrete types behind one handle (a base-class pointer or reference) and have area() do the right thing for whatever the object actually is. The set of concrete types may even be open — a plugin could add a Hexagon the original code never saw.
The term for this is runtime polymorphism (Greek: "many shapes") — one interface, many behaviors, with the choice deferred to runtime because the compiler cannot know, at the point of the call, which concrete type the pointer refers to. That deferral is the entire feature, and the entire cost. The mechanism C++ uses to implement it is the vtable, and seeing it removes all the mystery.
2 · virtual and how the call is resolved
You mark a member function virtual in the base class. A derived class then overrides it. The rule: when you call a virtual function through a base-class pointer or reference, the function chosen is the one matching the object's dynamic type — its real runtime type — not its static type — the type the compiler sees in the source.
struct Shape {
virtual double area() const = 0; // pure virtual: no body, makes Shape abstract
virtual ~Shape() = default; // virtual destructor — essential (see §6)
};
struct Circle : Shape {
double r;
explicit Circle(double r) : r(r) {}
double area() const override { return 3.141592653589793 * r * r; }
};
struct Rect : Shape {
double w, h;
Rect(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
};
double total(const std::vector<std::unique_ptr<Shape>>& shapes) {
double sum = 0;
for (const auto& s : shapes)
sum += s->area(); // static type is Shape; dynamic type is Circle or Rect
return sum; // the RIGHT area() runs for each — chosen at runtime
}
The static type of *s is Shape, but each object's dynamic type is Circle or Rect, so s->area() calls the matching override. How does the running program know which one? It cannot read the source type — the source type is the same Shape* for all of them. It must carry the answer inside each object. That carried answer is the vptr, and the table it points at is the vtable.
area() were not virtual, then s->area() would resolve at compile time to Shape::area based on the static type alone — every shape would use the base version regardless of what it really is. That compile-time binding is the free call from Lesson 09. virtual is exactly the switch that trades that freedom for runtime correctness.3 · The vtable and the vptr, drawn
When a class has any virtual function, the compiler builds, once per class, a static read-only array of function pointers called the vtable (virtual function table). Slot 0 is the address of that class's area(), slot 1 its destructor, and so on — one slot per virtual function, in a fixed order shared across the hierarchy. (This is the teaching picture; real ABIs such as Itanium also store RTTI and an offset-to-top above the entry point and split a virtual destructor into two slots — the mental model is what matters here, not the exact byte offsets.) Then it adds a hidden pointer to every object of that class, the vptr (virtual pointer), which points at its class's vtable. The vptr is set up by the constructor. So a Circle object is not just its double r; it secretly carries a vptr first.
The picture is the whole idea: the object carries a pointer to a table that names its real type's functions. All Circles share one vtable; the per-object cost is just the single vptr (8 bytes on a 64-bit machine). The vtable itself lives once, in read-only data, alongside the program's constants.
4 · Worked example: the call trace, and the contrast with Lesson 09
Take one line, sum += s->area();, where s points at a Circle. Here is what the CPU actually does, step by step — this is the dispatch sequence the vtable forces:
Three machine steps where Lesson 09 had effectively zero. Now lay the two calls side by side. In Lesson 09 a non-virtual c.area() on a known Circle compiled to a direct call to a fixed address — and because the target was known, the optimizer could inline it, erasing the call entirely and folding 3.14159… * r * r into the surrounding code. The virtual call cannot be inlined in the general case: the compiler does not know at compile time which function step 3 will branch to.
| Aspect | Non-virtual call (Lesson 09) | Virtual call (this lesson) |
|---|---|---|
| Target chosen | at compile time (static type) | at runtime (dynamic type, via vptr) |
| Extra memory loads | none | two (vptr, then vtable slot) |
| The call itself | direct branch to a known address | indirect branch through a pointer |
| Inlining | yes — call can vanish entirely | usually no — target unknown |
| Per-object size cost | 0 | +8 bytes (the vptr) |
| Branch prediction | trivial | indirect-branch predictor; mispredict if target varies |
area() in a tight loop lets the optimizer keep r in a register, vectorize, and constant-fold; a virtual call is an opaque wall the optimizer cannot see past, so all of that disappears. In a loop over millions of shapes, the gap between "inlined arithmetic" and "indirect call per element" can be an order of magnitude — not because the lookup is slow, but because of everything inlining would have enabled and now cannot.5 · The cost, itemized — and the cache tie-in
So the bill for one virtual call, in order of how much it tends to hurt:
Item 3 is the direct payment on Lesson 08's lesson: dynamic dispatch over a vector<unique_ptr<Shape>> is pointer-chasing by construction — the vector holds pointers, each object lives elsewhere on the heap, and each call dereferences twice more for the vptr/vtable. The cache misses, not the arithmetic, dominate. This is why "virtual in a hot loop over many small objects" is a classic performance smell.
6 · When it earns its keep — slicing, override/final, and the alternatives
When dynamic dispatch is worth it. The cost buys you an open set of types and runtime variation. If new types arrive at runtime (plugins, a parsed scene file, a UI widget tree) or the set of types is large and managed independently, a vtable is the clean, extensible answer — and a few nanoseconds of dispatch is invisible next to the work each call does. When it doesn't. If the set of types is closed and known at compile time, and the call sits in a hot inner loop where inlining would matter, paying for runtime dispatch you don't need violates zero-overhead. There the right tool is a compile-time mechanism (below).
Shape is abstract (it has a pure virtual area() = 0), so the compiler actually rejects Shape s = c; outright — you cannot instantiate an abstract type, which is the type system saving you here. But give a base a body and the slice happens silently:
struct Base { int id = 1; virtual ~Base() = default; virtual int tag() const { return id; } };
struct Derived : Base { int extra = 99; int tag() const override { return id + extra; } };
Derived d;
Base b = d; // SLICING: copies only the Base sub-object
// b.extra does not exist; b's vptr is Base's, not Derived's
b.tag(); // calls Base::tag -> 1, NOT Derived::tag -> 100
// A vector<Base> would slice every Derived you push into it.
The fix is the rule from Lesson 03/07: handle polymorphic objects through a pointer or reference (Base&, Base*, unique_ptr<Base>), never by value. For our hierarchy, vector<Shape> won't even compile (abstract); vector<unique_ptr<Shape>> is the correct polymorphic container.virtual and you get undefined behavior — typically the derived part's destructor never runs, leaking its resources. Rule: a base class with any virtual function needs a virtual destructor. That is why Shape above declares virtual ~Shape().overrideconst) that would otherwise silently create a new, never-called function.finalstd::variantstd::visit dispatches without a heap allocation or a vptr per object, keeping data contiguous (Lesson 08). Use it when you control all the types up front.Common mistakes / failure modes
delete base_ptr; on a base without a virtual destructor is UB and leaks the derived part. Give polymorphic bases a virtual ~Base().std::variant instead.Base's constructor the vptr still points at Base's vtable, so the call dispatches to Base's version, not the derived override — a surprising and common bug.overrideoverride turns that into a compile error.final) is often devirtualized to a direct call — and a non-virtual call never dispatches at all.Checkpoint exercise
Shape/Circle/Rect hierarchy above and a total() over a vector<unique_ptr<Shape>>. (1) On godbolt.org with -O2, find the s->area() call site and identify the two loads (vptr, then vtable slot) and the indirect call. (2) Now write a second loop that calls area() on a single local Circle value and watch the compiler inline it to plain arithmetic — that's Lesson 09's free call. (3) Predict and then confirm: change the container to vector<Shape> and observe the slicing compile error (abstract type) or, with a concrete base, the wrong area. (4) Remove virtual from ~Shape(), run under -fsanitize=address, and watch the derived part leak.Where this points next
We have now seen the one place the cost-of-abstraction movement charges you, and named two ways to get polymorphism back without the bill. Lesson 11 steps back from individual features to assembling them: how you actually design a resource-owning type, pulling together ownership (Lesson 07), the rule of zero/five (Lesson 06), RAII (Lesson 05), const-correctness, and the choice — virtual or not — you just learned to make. Then Lesson 12 cashes the cheque written here: templates give you the open-ended reuse of polymorphism with the direct, inlinable call of Lesson 09 — compile-time dispatch at zero runtime cost.
virtual turns a call into runtime dispatch: the function chosen matches the object's dynamic type, not its static type. The machinery is a per-class vtable (an array of function pointers in read-only data) reached through a per-object vptr (8 bytes, set by the constructor). A virtual call costs two dependent loads plus an indirect branch — but the real price is the defeated inlining (and the constant-folding, hoisting, and vectorization it would have enabled), worsened by likely cache misses when objects are scattered on the heap (Lesson 08). That cost earns its keep for an open set of types varying at runtime; it is waste for a closed set in a hot loop, where templates (Lesson 12) or std::variant give polymorphism back at zero runtime cost. Handle polymorphic objects through pointers/references to avoid slicing, give such bases a virtual destructor, and use override/final to let the compiler check — and sometimes devirtualize — your intent.Interview prompts
- What does a
virtualcall compile to, step by step? (§4 — load the vptr from the object, load the function pointer from the vtable slot, then make an indirect call passingthis.) - Where do the vtable and vptr live, and what's the per-object memory cost? (§3 — one vtable per class in read-only data; one vptr per object, 8 bytes on a 64-bit machine, set by the constructor.)
- Why is a virtual call usually more expensive than the two extra loads suggest? (§5 — the dominant cost is defeated inlining, which also blocks constant-folding/hoisting/vectorization across the call, plus likely cache misses from scattered objects.)
- What is object slicing and how do you avoid it? (§6 — copying a derived object into a base value drops the derived part and vptr; handle polymorphic objects only through references/pointers/smart pointers.)
- Why must a polymorphic base class have a virtual destructor? (§6 — deleting a derived object through a base pointer with a non-virtual destructor is UB and skips the derived destructor, leaking its resources.)
- When does dynamic dispatch earn its keep, and what's the zero-cost alternative? (§6 — worth it for an open set of types varying at runtime; for a closed compile-time set in a hot loop, use templates (Lesson 12) or
std::variant, which keep calls direct and inlinable.) - What do
overrideandfinalbuy you at zero runtime cost? (§6 —overridemakes the compiler verify you really override a base virtual;finalstates no further override and can let the compiler devirtualize back to a direct, inlinable call.)