all_lessons/C++/10 · Polymorphism & the vtablelesson 11 / 20

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.

The thesis, here
The zero-overhead principle says you don't pay for what you don't use. 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.
Linear position
Prerequisite: Lesson 09 — you know a non-virtual method is a plain function call resolved at compile time, with a hidden 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.
The plan
Six moves. (1) The problem dynamic dispatch solves: one pointer, many concrete types, the right function chosen at runtime. (2) 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.

A pure non-virtual call would get this wrong
If 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.

Two Shape*, both static type Shape, different dynamic types: s[0] ──► +───────────+ Circle's vtable (one per class, in .rodata): | vptr ●────┼──────► +──────────────────────+ +───────────+ | [0] &Circle::area |──► code for Circle::area | r = 2.0 | | [1] &Circle::~Circle | +───────────+ +──────────────────────+ s[1] ──► +───────────+ Rect's vtable: | vptr ●────┼──────► +──────────────────────+ +───────────+ | [0] &Rect::area |──► code for Rect::area | w = 3.0 | | [1] &Rect::~Rect | | h = 4.0 | +──────────────────────+ +───────────+

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:

s->area() with s holding the address of a Circle object ───────────────────────────────────────────────────────── 1. load vptr = *(s) // read the hidden vptr from the object [mem load #1] 2. load fn = *(vptr + 0) // index slot 0 of the vtable [mem load #2] 3. call fn(s) // indirect call; pass s as `this` [indirect branch] └─ runs Circle::area, returns 3.14159… * r * r

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.

AspectNon-virtual call (Lesson 09)Virtual call (this lesson)
Target chosenat compile time (static type)at runtime (dynamic type, via vptr)
Extra memory loadsnonetwo (vptr, then vtable slot)
The call itselfdirect branch to a known addressindirect branch through a pointer
Inliningyes — call can vanish entirelyusually no — target unknown
Per-object size cost0+8 bytes (the vptr)
Branch predictiontrivialindirect-branch predictor; mispredict if target varies
Worked: putting a number on it
The two extra loads are usually cheap — the vptr sits at the front of the object you just touched, and a hot vtable stays in L1 cache (~1ns each, Lesson 08). On its own the dispatch is a handful of cycles. The expensive part is the second-order loss: an inlined 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:

1 · Extra indirection
Load the vptr from the object, then load the function pointer from the vtable: two dependent memory reads before you can even make the call. Small, but not nothing.
2 · Defeated inlining (the big one)
Because the target is unknown at compile time, the optimizer cannot inline the body, and therefore cannot constant-fold, hoist, or vectorize across the call. This indirect cost usually dwarfs the lookup itself.
3 · Possible cache miss
If the objects are scattered on the heap (Lesson 08), reading each vptr is a pointer-chase that may miss cache (~100ns vs ~1ns). And the indirect branch can mispredict when consecutive objects have different dynamic types, stalling the pipeline.

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).

Failure mode: object slicing
Copy a derived object into a base value (not a pointer or reference) and you keep only the base part — the derived data and the correct vptr are sliced off. Our 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.
Failure mode: the missing virtual destructor
Delete a derived object through a base pointer when the base destructor is not 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().
override
Put it on every derived virtual function. It's free at runtime and makes the compiler verify you are actually overriding a base virtual — catching typos and signature mismatches (e.g. a missing const) that would otherwise silently create a new, never-called function.
final
Marks a virtual function (or class) as not further overridable. Beyond intent, it can let the compiler devirtualize: if it knows no further override exists, it may turn the virtual call back into a direct, inlinable one.
Templates → Lesson 12
Compile-time polymorphism: the compiler stamps out a concrete version per type, so the call is direct and inlinable — polymorphism at zero runtime cost. The trade is a closed-at-compile-time type set and code bloat.
std::variant
A type-safe union of a known, closed set of types; std::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

Slicing on copy
Passing or storing a polymorphic object by value drops the derived part and the vptr. Always use a reference or pointer for polymorphism.
Non-virtual destructor
delete base_ptr; on a base without a virtual destructor is UB and leaks the derived part. Give polymorphic bases a virtual ~Base().
Virtual in a hot loop over a closed set
Paying for dispatch and lost inlining when the types are known at compile time. Reach for templates or std::variant instead.
Calling virtuals in a constructor
During 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.
Forgetting override
A signature mismatch silently creates a new function that never gets called through the base; override turns that into a compile error.
Assuming all calls dispatch
A virtual call where the dynamic type is known at compile time (e.g. on a local value, or after final) is often devirtualized to a direct call — and a non-virtual call never dispatches at all.

Checkpoint exercise

Try it
Build the 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.

Takeaway
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