C++ Part I · Module 17 · Runtime Polymorphism
What Runtime Polymorphism Buys You
Module 16 built hierarchies that could not do anything. A derived object had a base part at a known offset, a derivation asserted substitutability, and construction ran in four phases — but a function taking a base reference still called the base class's functions, whatever object it had been handed, which Chapter 16.3 measured and Chapter 16.6 explained. That is the gap this module closes, and it is worth being precise about what is missing, because the answer is not simply more syntax. What a hierarchy cannot yet do is let one piece of code work with a type that did not exist when that code was written. This chapter establishes that capability and measures it in the strongest form available: a client translation unit compiled to an object file, hashed, and then linked unchanged into a program containing a new implementation it had never seen — same object code, same hash, driving a model type added afterwards. Then the distinction the frozen description of this chapter asks for, which catches people out because both mechanisms are called polymorphism and only one of them happens at run time: overloading picks a function from a set the compiler can see, and this module is about the case where the set is not knowable until the program is running. And the single size measurement that accounts for what the capability costs an object, with the rest of the cost deferred to the chapter that measures it properly.
Intermediate17 min readC++Part IFundamentalsPolymorphism
Part I · Module 17 · Chapter 17.1 · What Runtime Polymorphism Buys You
1. The Problem
A simulation holds a reference model. Later it needs to hold a fast approximate model instead, or to run both and compare, or to accept one a different team supplies. The code that drives the model — steps it, reads its outputs, logs them — should not change when the model does.
Write it the obvious way and it does change:
void drive(ReferenceModel& m); // works for exactly one typeEvery new model means a new overload, a new branch, or a template instantiation — and every one of them means editing and recompiling the driver. The problem is not verbosity. It is that the driver has to be edited whenever the set of models changes, which makes the set of models a property of the driver.
The requirement: write code once, against an interface, and have it work with implementations chosen later — including after that code is compiled.
2. What Module 16 Could Not Do
Chapter 16.3 §3 measured the gap. With ordinary member functions, a call through a base reference reaches the base's function:
A. NON-virtual, through a base reference:
PlainBase::runThe object was a PlainDerived. The reference was a PlainBase&. The base version ran, because the compiler chose by the reference's static type — Chapter 16.6's name hiding. So a driver taking PlainBase& can only ever execute PlainBase's behaviour, which makes the hierarchy decorative.
3. Not the Same as Overloading
Both are called polymorphism, and the distinction is the one worth fixing first.
void log(int); // overloading: the compiler picks from
void log(const char*); // a set it can see, at compile time
void log(double);Chapter 6.7 established how that choice is made: lookup produces a candidate set, resolution ranks it, and the decision is baked into the generated code. The set of candidates is fixed when the translation unit is compiled.
| Overloading | Runtime polymorphism | |
|---|---|---|
| decided | compile time | run time |
| by | the argument types the compiler sees | the object's actual type |
| candidate set | fixed at compile time | open — extended later |
| needs a hierarchy | no | yes |
The practical difference is §5's. Overloading cannot help with a type that does not exist yet, because the compiler cannot rank a candidate it has never seen.
(There is a third kind — compile-time polymorphism via templates, where one piece of code works with many types but the set is still fixed when the code is instantiated. That is Module 18, and Chapter 18.1 compares the two properly.)
4. What Changes With One Keyword
Declare the function virtual in the base and the same call behaves differently:
struct VBase { virtual ~VBase() = default; virtual void run() const { /* ... */ } };
struct VDerived : VBase { void run() const override { /* ... */ } };
static void driveV(const VBase& b) { b.run(); }B. VIRTUAL, through a base reference:
VDerived::run
C. the same virtual call through a base POINTER:
VDerived::runThe derived implementation ran, through a reference and through a pointer. The expression's static type is still VBase; what changed is that the call now consults the object rather than the expression.
And one function serves every type in the hierarchy:
D. one function, three objects, three implementations:
VBase::run
VDerived::run
VOther::runOne driveV, three objects, three behaviours, no branching. (How the call finds the right implementation is Chapter 17.2, which measures the three instructions it takes.)
5. The Measurement That Matters
§4 shows dispatch working within one program. The claim worth testing is stronger: that code compiled before an implementation exists can drive it.
Four files. A Model interface, a client that uses it, and two versions of an implementation file — the second adding a FastModel the client has never heard of:
#include "client.h"
void runTwice(Model& m) { m.step(); m.step(); std::printf(" ran %s twice\n", m.name()); }Compile the client to an object file, twice, to establish the baseline:
sha256 of client.o:
2ee7c682aa40fd78c059972f4d6f939ad5f7cf8a25fa8956a090832e2e38fc17 client_a.o
2ee7c682aa40fd78c059972f4d6f939ad5f7cf8a25fa8956a090832e2e38fc17 client_b.oNow link that same object file against each implementation version:
=== link against V1 (one implementation) ===
ran ReferenceModel twice
ticks=2
=== link the SAME client_a.o against V2 (a new implementation added) ===
ran ReferenceModel twice
ticks=2
ran FastModel twice
ticks=20client.cpp was not recompiled, re-read, or edited. The identical client_a.o drove a FastModel that did not exist when it was compiled, and the ticks=20 proves FastModel::step ran rather than the interface's.
That is the capability, stated as precisely as it can be:
The client's machine code is independent of the set of implementations. New implementations are added without touching it.
Chapter 17.10 is the chapter on writing that client side properly; this measurement is what makes it worth doing.
6. What It Costs the Object
One number here; the rest belongs to the chapter that measures it properly.
E. sizeof(PlainBase)=1 sizeof(PlainDerived)=1
sizeof(VBase)=8 sizeof(VDerived)=8An empty class is 1 byte; adding a virtual function makes it 8. The object now carries a hidden pointer — Chapter 17.7 explains what it points at and measures the per-call cost, which turns out to be smaller and differently distributed than most people expect.
That second claim — one pointer, however many virtual functions — is worth measuring rather than asserting:
sizeof(Empty) = 1
sizeof(One) = 8 (1 virtual + dtor)
sizeof(Five) = 8 (5 virtuals + dtor)
sizeof(Twenty) = 8 (20 virtuals + dtor)
sizeof(Both) = 16 (TWO polymorphic bases)One, five and twenty virtual functions all cost the same eight bytes. The object carries a pointer to a per-class table, so the number of entries in that table is a property of the class, not of each object. Twenty virtual functions cost twenty table slots once, program-wide, and still one pointer per object.
The last row is the exception: a class with two polymorphic bases carries two pointers, 16 bytes, because it has two base subobjects each needing to answer dispatch questions — which is Chapter 16.2 §4's note about a second base not being at offset 0, seen from the other side.
Two things follow. A class with any virtual function is at least a pointer in size, so virtual on a type you create in millions is a real decision — an object of four ints doubles. And the cost is per object, not per call site and not per function, which makes a wide interface on few objects cheap and a narrow interface on many objects the case to think about.
7. Where It Is the Right Answer
The pattern is always the same: one piece of code, several implementations, the choice made at run time.
| Situation | The interface |
|---|---|
| a reference model and a fast approximate model | Model with step() |
| several bus protocols behind one checker | Transaction with kind(), addr() |
| a scoreboard that accepts any comparator | Comparator with matches() |
| output to a file, a socket, or a test buffer | Sink with write() |
| a plugin loaded from configuration | whatever the plugin implements |
And where it is not the answer: when the set of types is small, fixed, and known at compile time, a template (Module 18) or a plain function does the job with no indirection. Reaching for a hierarchy because it feels more object-oriented is how the bad hierarchies of Chapter 16.8 get built.
(The module's blurb names three classic failures, and all three are in this module: the missing virtual destructor — Chapter 17.5 — the sliced object — Chapter 17.6 — and the interface that was never abstract — Chapter 17.4. The last is the one this chapter's VBase is guilty of: it has a working run(), so a VBase can be instantiated and the interface is a suggestion rather than a contract.)
8. What This Module Will Add
In order, and each resolving something this chapter has left open:
| Chapter | The question it answers |
|---|---|
| 17.2 | how does the call find the right implementation? |
| 17.3 | how do I stop a signature typo from silently creating a new function? |
| 17.4 | how do I state a contract that must be implemented? |
| 17.5 | what leaks when I delete through a base pointer? |
| 17.6 | what happens if I copy a derived object into a base object? |
| 17.7 | what does a virtual call actually cost? |
| 17.8 | can I ask what type an object really is, and should I? |
| 17.9 | what makes a base class worth implementing? |
| 17.10 | how do I write the code that uses one? |
9. What to Actually Do
Use runtime polymorphism when the set of implementations is open — chosen or added after your code is compiled.
Distinguish it from overloading — same word, different time, and overloading cannot reach a type it has never seen.
Expect one pointer per object — measured, 1 byte becoming 8.
Do not reach for a hierarchy when the type set is fixed and small — a template or a plain function is cheaper and clearer.
And ask the §5 question before the syntax question: will already-compiled code need to drive something added later?
10. Mistakes Worth Making Once
Building a hierarchy and expecting dispatch without virtual. Measured: the base version ran.
Confusing overloading with runtime polymorphism. One is resolved at compile time from a closed set.
Expecting a template to solve the open-set problem. Instantiation still needs the type at compile time.
Adding virtual to a type you create in millions. 1 byte becomes 8, per object.
Thinking the cost is per call site. It is per object — one pointer regardless of how many virtual functions.
Using a hierarchy for three types you wrote and will not change. Chapter 16.8's catalogue.
Leaving the base instantiable when it was meant as a contract — Chapter 17.4.
11. Interview Relevance
"What problem does runtime polymorphism solve?" Code that must work with a type chosen later — including after that code is compiled. The strong answer makes the after compilation part explicit.
"How is it different from overloading?" Overloading is resolved at compile time from a candidate set the compiler can see; this is resolved at run time from the object, and the set is open.
"Can you demonstrate the difference?" The sharpest demonstration is that a client object file, unchanged and byte-identical, can drive an implementation added afterwards.
"What does it cost?" One pointer per object — measured, 1 byte becoming 8 — plus a per-call cost that Chapter 17.7 measures as mostly lost inlining rather than indirection.
"When would you not use it?" When the set of types is fixed and known at compile time; a template keeps the abstraction without the indirection.
"Why is a non-virtual hierarchy not polymorphic?" Because the call is resolved by the static type — measured as the base version running through a base reference.
12. What You Should Be Able to Say Now
- The requirement is code that works with implementations chosen later, not merely shared code.
- Measured: without
virtual, a call through a base reference ranPlainBase::runon a derived object. - Overloading is resolved at compile time from a candidate set the compiler can see, and is baked into the generated code.
- Runtime polymorphism is resolved from the object, and the implementation set is open.
- A template fixes the set at instantiation, so it does not solve the open-set problem — Module 18.
- Measured: with
virtual, the same call ranVDerived::runthrough both a reference and a pointer. - Measured: one function drove three objects to three implementations with no branching.
- Measured: a byte-identical
client.o(sha2562ee7c682…) drove aFastModelthat did not exist when it was compiled. - So the client's machine code is independent of the implementation set.
- Measured: an empty class is 1 byte; with a virtual function it is 8.
- The cost is per object, not per call site — one pointer however many virtual functions.
- The trade: you give up compile-time knowledge of which function runs, and gain an open set.
- Where the set is genuinely fixed, a template or a plain function is cheaper.
- The module's three classic failures are the missing virtual destructor, the sliced object, and the interface that was never abstract — Chapters 17.5, 17.6 and 17.4.
13. Exercises
Exercise 1 — state the requirement. Describe, without using the word virtual, what capability this module adds.
Exercise 2 — predict the call. A Base& bound to a Derived, calling run(). Give the result with and without virtual, and name the mechanism in the non-virtual case.
Exercise 3 — the two polymorphisms. Fill in the four-row comparison table, then say which one can reach a type that does not yet exist.
Exercise 4 — design the measurement. Describe an experiment proving a client's code is independent of the implementation set, and say what you would hash.
Exercise 5 — the size. Give sizeof for an empty class, the same class with one virtual function, and with five virtual functions. Explain the third.
Exercise 6 — open or closed. For each, say whether the implementation set is open: a reference model and a fast model you both wrote; bus protocols added by other teams; three shapes in a geometry library; an output sink chosen by a config file; a comparator for std::sort.
Exercise 7 — the wrong tool. Give a case where a hierarchy is the wrong answer and say what you would use instead.
Exercise 8 — the third failure. Explain why this chapter's VBase is an example of "the interface that was never abstract".
14. Summary
- The requirement is code that works with implementations chosen later — including after it is compiled.
- Measured: without
virtual, a base reference ran the base's function on a derived object — a decorative hierarchy. - Overloading is a compile-time choice from a closed set; runtime polymorphism is a run-time choice from an open one.
- Measured: with
virtual, one function drove three objects to three implementations. - Measured, the headline: a byte-identical
client.odrove aFastModeladded after it was compiled —ticks=20proving the derived implementation ran. - Measured: the object cost is 1 byte becoming 8 — one pointer per object, not per call site.
- The trade is compile-time knowledge for an open implementation set, and it is only worth it where the set is genuinely open.
- Three classic failures follow in this module: the missing virtual destructor, the sliced object, and the interface that was never abstract.
The capability is established and the mechanism is still a black box. §4 showed a call through a base reference reaching the derived implementation, and §6 showed the object growing by exactly one pointer — which is the whole clue, because the information that makes the call work has to live in the object rather than at the call site. The next chapter opens the box and measures what a virtual call actually does: at -O2 it is three instructions, and the middle one reads a function address out of a table at a fixed offset chosen when the call site was compiled. That is the entire mechanism, it explains the one pointer, and it explains why the same call site can reach an implementation that did not exist at the time — the offset is fixed, and what sits at that offset is not.
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.
