Skip to content
VLSI Mentor

C++ Part I · Module 17 · Runtime Polymorphism

virtual Functions and Dynamic Dispatch

Chapter 17.1 showed a call through a base reference reaching a derived implementation, and left the mechanism as a black box with one clue in it: the object grew by exactly eight bytes. That clue is the whole answer, because it says where the information lives. A call site compiled today cannot contain the address of a function written next year, so the address cannot be at the call site — it has to be reachable from the object, and the eight bytes are how. This chapter opens the box and measures what a virtual call does, which at full optimisation turns out to be three instructions: load a table pointer out of the object, read this particular function's entry from the table at an offset the compiler picked when it compiled the call, and branch to whatever address it found. The reason that arrangement solves Chapter 17.1's problem is visible in the second instruction. The offset is fixed at compile time and the contents at that offset are not, so the same call site reaches a different implementation for every type that fills the table differently — including types that did not exist when the offset was chosen. The chapter then covers the parts of the rule that are easy to get wrong rather than hard to understand: which calls dispatch and which do not, what happens to a virtual call made while the object is still being constructed, and why every listing in this module writes a destructor the base class did not obviously need.

Intermediate18 min readC++Part IFundamentalsPolymorphism

Part I · Module 17 · Chapter 17.2 · virtual Functions and Dynamic Dispatch

1. Declaring One

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct VBase {
        virtual ~VBase() = default;
        virtual void run() const { std::printf("     VBase::run\n"); }
    };
    struct VDerived : VBase {
        void run() const override { std::printf("     VDerived::run\n"); }
    };

Three things about that, in decreasing order of obviousness.

virtual goes on the base declaration. It is what makes the function dispatchable, and it is inherited — a derived class's run is virtual whether or not you repeat the keyword.

override goes on the derived declaration. It is optional and you should always write it; Chapter 17.3 measures what it catches.

And the base has a virtual destructor for a reason this chapter does not explain. Every listing in this module has one; Chapter 17.5 measures what happens without it.

2. What Changes

Chapter 17.1 §2 measured the non-virtual case:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. NON-virtual, through a base reference:
     PlainBase::run

With virtual, the same shape of call:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
B. VIRTUAL, through a base reference:
     VDerived::run
C. the same virtual call through a base POINTER:
     VDerived::run

A non-virtual call is decided by the type of the expression. A virtual call is decided by the type of the object.

The names for those are static and dynamic dispatch, and the static type of the expression in B and C is VBase in both cases — which is the point. Nothing about the expression changed; the answer came from elsewhere.

3. Where "Elsewhere" Is

Chapter 17.1 §6 measured the clue:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(PlainBase)=1  sizeof(PlainDerived)=1
sizeof(VBase)=8      sizeof(VDerived)=8

Eight bytes appeared when the first virtual function did, and Chapter 17.1 §6 measured that it stays eight for five or twenty virtual functions. One pointer, per object, pointing at something shared per class.

That is a necessary arrangement rather than an implementation whim. A call site compiled now cannot hold the address of a function compiled later, so the address must be found through the object — and the cheapest thing an object can carry is one pointer to a per-class table of addresses.

4. The Three Instructions

Measured at -O2:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    int viaVirtual(const VB& b)    { return b.get(); }      // dispatch
    int viaPlain  (const Plain& p) { return p.get(); }      // direct
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
viaVirtual : 3 instructions
    ldr  x8, [x0]          ; load the table pointer out of the object
    ldr  x1, [x8, #16]     ; read this function's entry -- offset fixed at compile time
    br   x1                ; branch to whatever address was there

viaPlain   : 2 instructions
    mov  w0, #2
    ret                    ; inlined to a constant -- no call at all

Three instructions, and the middle one is the mechanism. #16 is a constant the compiler chose when it compiled this call site: the slot in the table where get lives for this hierarchy. Every class in the hierarchy puts its get at that same slot of its own table.

5. The Sequence

A virtual call resolves in three steps: load the object's table pointer, read this function's entry from the table, then branch to that address.What a virtual call does, measuredat -O201FIND THE TABLE POINTERldr x8, [x0] — it lives in theobject's first 8 bytes02READ THIS FUNCTION'S ENTRYldr x1, [x8, #16] — a fixed offsetchosen at compile time03BRANCH TO THAT ADDRESSbr x1 — three instructions in total
Figure 1 — load the object's table pointer, read this function's entry from the table, then branch to that address. One extra indirection against a direct call. The cost that usually matters more is not the indirection but that the target is unknown at compile time, so the call cannot be inlined.

And now Chapter 17.1 §5's result is no longer surprising. The client's object file contained ldr x1, [x8, #16] and nothing else about any implementation. A FastModel added later is a new class with its own table, which puts its step at the same slot — so the already-compiled instruction finds it without having been told it exists.

The offset is fixed at compile time. What sits at that offset is not. That is the entire trick.

(This is a model of how implementations do it, not a guarantee the standard makes. The standard specifies the behaviour — the derived implementation runs — and every mainstream compiler implements it this way. Chapter 17.7 is careful about that distinction and about what the cost actually is.)

6. One Function, Any Number of Types

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
D. one function, three objects, three implementations:
     VBase::run
     VDerived::run
     VOther::run

One driveV(const VBase&), three objects, three behaviours. No branching, no type tags, no if. The call site is identical in all three cases; the tables differ.

That is the capability in its usable form, and the reason a base class is sometimes called an interface: it fixes the slots, and each implementation fills them.

7. Which Calls Dispatch

The rule is narrow, and the exceptions are where mistakes live.

ExpressionDispatches?
basePtr->run()yes
baseRef.run()yes
baseObject.run() on an actual VBase objectno — the object is a VBase
derived.run() where the static type is the derived classthe derived one, but still through the table (§9)
b.VBase::run() — explicitly qualifiedno — qualification suppresses dispatch
a non-virtual functionno, ever — Chapter 16.6's hiding
passing by valueno — Chapter 17.6's slicing

The qualified form is worth knowing because it is how a derived implementation calls the one it overrides:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    void VDerived::run() const { VBase::run(); /* then extra work */ }

Chapter 16.2 §8 flagged Base::name(...) as the legitimate use of qualification, and this is it.

8. Dispatch Happens Even When the Type Is Known

A detail that surprises people, measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
viaDerived(const VD& d) { return d.get(); }
    3 instructions: ldr / ldr / br      -- still dispatched

The static type is exactly VD, whose get is known — and the compiler still went through the table. The reason is that VD is not final: some further-derived class could exist in another translation unit, and a reference to VD may refer to one of its objects.

Knowing the static type exactly is not enough to remove the dispatch. What is enough is promising that no further derivation exists, which is what final does — Chapter 17.3 covers the keyword and Chapter 17.7 measures what it saves.

(A note on my own tooling: the script I used to classify these cases initially reported the dispatched ones as devirtualised, because it searched for blr and missed that br x1 is also an indirect branch — a tail call. The instruction listings above are the evidence; the label was wrong.)

9. What Dispatches and What Does Not: Default Arguments

A trap that belongs here precisely because it is about which implementation a call reaches, and the answer turns out to be two different things at once.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct Base {
        virtual ~Base() = default;
        virtual void emit(int n = 1) const { std::printf("Base::emit n=%d\n", n); }
    };
    struct Derived : Base {
        void emit(int n = 99) const override { std::printf("Derived::emit n=%d\n", n); }
    };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. through a Base&, no argument : Derived::emit n=1
B. through Derived, no argument : Derived::emit n=99
C. through a Base&, explicit 5  : Derived::emit n=5

Read line A carefully. Derived::emit ran — the body was chosen by the object's dynamic type, exactly as §4 described. And n is 1 — the default argument was chosen by the expression's static type, from Base.

A virtual call takes its body from the dynamic type and its default arguments from the static type. The result is a combination that exists in neither class's source.

Derived::emit has been invoked with an argument its author never intended, and n=99 appears only when the call goes through a Derived expression. Same object, same function, two answers.

Zero warnings at -Wall -Wextra -Wpedantic. The two at -Weverything are -Wweak-vtables, about vtable emission, and have nothing to do with this — I read them.

The rule is simple and absolute: never give a virtual function a default argument. If a default is genuinely wanted, put it in a non-virtual public function that supplies the value and then calls the virtual one — which is Chapter 17.9's non-virtual-interface shape, arrived at here by a different route.

(One related rule that behaves well, for contrast: a derived override may return a more derived pointer or reference than the base declares — a covariant return type. Measured, Fast* clone() const override overriding Model* clone() const works, and a call through a Fast expression has static type Fast* with no cast needed, while through a Model& it is Model* pointing at a Fast. That one is a genuine convenience rather than a trap.)

10. Why Every Base Here Has a Virtual Destructor

Every listing in this module writes:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    virtual ~VBase() = default;

The short version, because the measurement belongs to Chapter 17.5: if a class has any virtual function, something will eventually hold it by base pointer or reference, and deleting through that pointer without a virtual destructor fails to run the derived destructor. Chapter 17.5 measures exactly what is lost.

Treat it as a package: the first virtual you write in a class obliges you to write virtual ~T() too. That is the reflex the frozen description of Chapter 17.5 asks for, and this chapter is where the habit starts.

11. A Virtual Call During Construction

Chapter 16.5 §6 established the ordering: while the base constructor runs, the derived part does not exist yet. The consequence for dispatch is the one people get wrong, and it follows without needing a new rule.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct Base {
        Base() { setup(); }                      // a virtual call from a constructor
        virtual void setup() { /* base version */ }
    };
    struct Derived : Base {
        int* buf = nullptr;
        void setup() override { buf = new int[64]; }   // never reached from Base()
    };

Measured, with a destructor doing the same thing for symmetry:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. constructing and destroying a Derived:
    Base ctor  -> Base::setup
    fully constructed -> Derived::setup (tag=0xABCD)
    Base dtor  -> Base::teardown
B. the same object through a Base&, after construction:
    Base ctor  -> Base::setup
    via Base& -> Derived::setup (tag=0xABCD)
    Base dtor  -> Base::teardown

Three different answers for the same virtual call on the same object, depending on when it is made. From the base constructor: Base::setup. Once construction is complete — whether through the object or through a Base& — Derived::setup, with tag correctly initialised. From the base destructor: Base::teardown again.

The reason is that the object's dynamic type changes as construction proceeds. During Base's constructor the object genuinely is a Base — its table pointer refers to Base's table, because Derived's part is not yet initialised and Derived::setup would read fields that do not exist. Destruction runs the same film backwards: by the time ~Base executes, Chapter 16.5 §2's phase order has already destroyed the derived part, so the object is a Base again.

That is a deliberate design choice rather than an accident, and it is the safe one — the alternative would be dispatching into a function whose object is half-built. And nothing warns: 0 own-file warnings at -Wall -Wextra -Wpedantic, and the nine at -Weverything are C++98-compatibility and padding notes, none of them about this. I read them.

So the rule, and it is a consequence rather than an exception:

Never call a virtual function from a constructor or destructor and expect the derived override. The object is not yet, or no longer, the derived type.

The fix is Chapter 16.5 §6's: whatever the base needs, it takes as a constructor parameter. Chapter 17.9 covers the interface design that avoids wanting this.

12. What to Actually Do

Put virtual on the base declaration and override on every derived one.

Write virtual ~T() = default in any class with a virtual function — Chapter 17.5.

Call through a reference or pointer — by value does not dispatch (Chapter 17.6).

Use Base::f() qualification deliberately, to call the overridden version, and never by accident.

Do not expect an exact static type to remove the dispatch — measured, it does not; final is what does.

Never call a virtual function from a constructor or destructor expecting the override.

And read ldr / ldr / br as the whole mechanism — one load for the table, one for the slot, one branch.

13. Mistakes Worth Making Once

Forgetting virtual in the base. Measured: the base's function runs — Chapter 16.6's hiding.

Putting virtual only in the derived class. It makes that function dispatchable for further derivations, but a Base& call still will not reach it.

Omitting the virtual destructor. Chapter 17.5.

Calling through a base object rather than a reference or pointer. No dispatch, and Chapter 17.6 measures why.

Writing b.Base::run() by accident. Qualification suppresses dispatch silently.

Expecting the exact static type to be devirtualised. Measured: still three instructions.

Calling a virtual function from a constructor. The base version runs, on purpose.

Assuming the vtable picture is standardised. It is a model of every mainstream implementation, not a guarantee.

14. Interview Relevance

"What does virtual do?" Makes the call consult the object's dynamic type rather than the expression's static type. The strong answer uses both terms correctly.

"How does the call find the right function?" Through a per-object pointer to a per-class table; the call site reads a fixed slot. Measured as three instructions: ldr / ldr / br.

"Why does that let a new type work with already-compiled code?" Because the offset is fixed at compile time and the table contents are not.

"Does the object store the function addresses?" No — one pointer per object to a table shared by the class. Measured: 1, 5 and 20 virtual functions all give 8 bytes.

"Does a call on an exact derived type still dispatch?" Yes, unless the class or function is final — measured, still three instructions. This one separates candidates who have looked at the assembly.

"What happens if you call a virtual function from a constructor?" The base version runs, because the derived part is not constructed yet. A good follow-up.

"How do you call the overridden version?" Qualify it: Base::f().

15. What You Should Be Able to Say Now

  1. virtual goes on the base declaration and is inherited; override goes on the derived one.
  2. A non-virtual call is decided by the expression's static type; a virtual call by the object's dynamic type.
  3. Measured: the same call gave PlainBase::run without virtual and VDerived::run with it, through both a reference and a pointer.
  4. Measured: the object grows by 8 bytes at the first virtual function, and stays 8 for twenty of them.
  5. The information must be in the object, because a call site compiled now cannot hold an address generated later.
  6. Measured, three instructions: ldr x8,[x0] / ldr x1,[x8,#16] / br x1.
  7. The offset is fixed at compile time; the contents are not — which is the whole mechanism.
  8. A direct call was 2 instructions — mov w0,#2; ret — inlined to a constant.
  9. Measured: one function drove three objects to three implementations with no branching.
  10. Only calls through a reference or pointer dispatch — not by value, not on a base object, not when qualified.
  11. Qualification (Base::f()) suppresses dispatch, which is how an override calls the overridden version.
  12. Measured: an exact static type still dispatched — 3 instructions — because a further-derived class could exist. final is what removes it.
  13. A virtual call from a constructor reaches the base version, because the object is not yet the derived type — Chapter 16.5 §6's ordering.
  14. The vtable picture is a model of every mainstream implementation, not a standard guarantee.
  15. The first virtual in a class obliges a virtual destructor — Chapter 17.5.

16. Exercises

Exercise 1 — declare it. Write a Sink base with a virtual write(const char*) and two implementations, with the keywords in the right places.

Exercise 2 — predict the call. For a Derived object reached as Base&, Base*, Base by value, and Base& with a qualified call, say which implementation runs.

Exercise 3 — the three instructions. Write them out and say what each does, then explain which one makes open extension possible.

Exercise 4 — the size. Give sizeof for a class with no virtuals, one virtual, and twenty, and explain the pattern.

Exercise 5 — the exact type. Explain why a call on a const VD& still dispatches, and what would remove it.

Exercise 6 — the constructor. Given a base whose constructor calls a virtual setup(), say which version runs and why, then fix the design.

Exercise 7 — call the base. Write a derived run() that does the base's work and then more.

Exercise 8 — the model's limits. State one thing the standard guarantees and one thing the vtable picture only describes.

17. Summary

  • virtual makes a call consult the object's dynamic type instead of the expression's static type.
  • Measured: PlainBase::run without it, VDerived::run with it, through a reference and a pointer.
  • Measured: the object gains 8 bytes at the first virtual function and no more for twenty.
  • The address must be reachable through the object, because the call site is compiled first.
  • Measured, three instructions: load the table pointer, read a fixed-offset slot, branch.
  • The offset is fixed and the contents are not, which is exactly why already-compiled code drives implementations added later.
  • Only references and pointers dispatch — by value, base objects, and qualified calls do not.
  • Measured: an exact static type still dispatched; final is what removes it.
  • A virtual call during construction reaches the base version — a consequence of Chapter 16.5's ordering, not a special rule.

The mechanism works and it is now trivially easy to break. override was written on every derived function in this chapter and never explained, and the reason it matters is that a virtual function is matched to its base by signature, so a derived declaration that differs in any way — a parameter type, a missing const, a misspelled name — is not an override at all. It is a new function that hides the base one, which Chapter 16.6 measured as a silent redirection, and in a polymorphic hierarchy the consequence is worse: the base's version keeps running through every base reference, so the behaviour you wrote is simply never reached. The next chapter measures that happening, measures what the compiler says about it — which, for a pleasant change in this curriculum, turns out to be something useful by default — and measures what override converts it into.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.