Skip to content
VLSI Mentor

C++ Part I · Module 17 · Runtime Polymorphism

Consuming Polymorphic Interfaces

Nine chapters have been written from inside the hierarchy. What a base declares, what an implementation must supply, what a call costs, what leaks when a destructor is not virtual — all of it about the types being defined. None of it describes the code that actually uses an interface, and that code has its own small set of decisions, every one of which has been established somewhere in the last two modules and never collected in one place. Hold the object by reference or by owning pointer and never by value, because by value you get a different object. Store collections as owning pointers, because a container of base values slices every element on insertion. Take parameters as const Base reference. Never ask what type you are holding. Those rules are short and the reasons behind them are measurements rather than conventions, which is what this chapter assembles. It closes with the result that makes the entire module worth its trouble, run properly rather than described: a client translation unit compiled once and hashed, then linked without recompilation into a program containing an implementation that did not exist when the client was built — same object file, same hash, driving behaviour written afterwards. That is the capability Chapter 17.1 promised, and this is the chapter that shows what the code on the other side of it looks like.

Advanced19 min readC++Part IFundamentalsPolymorphism

Part I · Module 17 · Chapter 17.10 · Consuming Polymorphic Interfaces

1. The Client's Four Jobs

Given Chapter 17.9's interface, the code that uses it has to do four things, and each has one right answer:

JobThe answer
hold an implementationa base reference, or an owning smart pointer
invoke behaviourcall through the interface; never branch on type
substitute one for anotherchange the construction, nothing else
extend with a new onechange nothing at all

§7 is the one that is worth measuring rather than asserting.

2. The Shape

A simulation holds models through a base reference and calls the interface, so reference and fast implementations are interchangeable without the simulation changing.Simulationknows only the baseModel interfacepure virtual step()Two modelsreference and fastcallsimplements12
Figure 1 — what runtime polymorphism buys the caller. The simulation holds its models through a base reference and calls the interface, so a reference implementation and a fast one are interchangeable without a line of the simulation changing. Dispatch happens at the call; the choice of implementation happens once, somewhere else.

The arrow that is not in the picture is the point: nothing goes from the client to the implementations. The client depends on the interface; the implementations depend on the interface; neither depends on the other. That is what makes §7 possible.

3. Holding One: By Reference

When the client does not own the object — the common case for a function:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    void runTwice(Model& m) { m.step(); m.step(); }

Model& or const Model&, never Model. Chapter 17.6 §4 measured why in one line: a by-value parameter received a Model constructed from the base part, and virtual calls reached Model's implementations rather than the derived ones.

So this is not Chapter 6.5's efficiency argument applied to polymorphism. It is a correctness requirement: by value, you get a different object.

4. Holding One: By Owning Pointer

When the client owns the object and its lifetime exceeds a single call:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    std::unique_ptr<Model> model = std::make_unique<FastModel>();
    std::vector<std::unique_ptr<Model>> models;

Three facts from earlier chapters converge on this being the right form.

It works on an abstract type. Chapter 17.4 §8 measured std::unique_ptr<Model> owning a derived object through an abstract base pointer, normally.

A container of values slices. Chapter 17.6 §7:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
vector<Model>  -> v[0].name()=Model        (sliced)
vector<Model*> -> pv[0]->name()=FastModel  (preserved)

And ownership belongs in the type. Chapter 14.8 §6's member table — unique_ptr<Base> when the member is polymorphic — plus Chapter 17.5's measurement that the base needs a virtual destructor for this to release anything.

std::vector<std::unique_ptr<Base>> is the standard shape for a collection handled polymorphically. A raw-pointer vector preserves identity too, but Chapter 14.1 §9 measured it leaking every element.

Use std::shared_ptr<Model> only when ownership is genuinely shared — Chapter 14.5 §10's test — and remember Chapter 17.5 §6 measured that shared_ptr's accidental rescue of a missing virtual destructor depends on the static type at construction.

5. Invoking: One Call Site, Any Implementation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    for (auto& m : models) m->step();
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
D. one function, three objects, three implementations:
     VBase::run
     VDerived::run
     VOther::run

One call site, no branching, no type tags. Chapter 17.2 §4 measured it as three instructions, and Chapter 17.7 §5 measured the indirection at about 0.006 ns against a comparable direct call — so there is no cost argument for doing anything cleverer.

The loop is also the right granularity. Chapter 17.7 §11's advice applies here: dispatch once per unit of real work, not once per trivial operation. m->runFor(1000) dispatches once; a client loop calling m->step() a thousand times dispatches a thousand times. Both are cheap, and the first is cheaper and reads better.

6. Substituting: Change the Construction

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    std::unique_ptr<Model> model = std::make_unique<ReferenceModel>();
    //                           = std::make_unique<FastModel>();        // one line

Everything downstream is unchanged, because nothing downstream named the type. That is the property worth stating as a rule:

Push the choice of implementation to one place — ideally the edge of the program — and keep the type name out of everything else.

In practice that means the concrete type appears in exactly two kinds of place: a factory that reads configuration and returns std::unique_ptr<Model>, and the tests. Everywhere else holds the interface.

A practical consequence for testing, since it is the most common reason to substitute: a client written this way takes a test double with no modification at all. A RecordingModel that counts calls is just another implementation, and the client cannot tell.

7. Extending: The Measurement

The claim worth testing is the strongest one — that a client needs no change at all when an implementation is added. Not a recompile, not a rebuild. Nothing.

Four files: the interface, the client, and two versions of an implementation file.

Azvya Education Pvt. Ltd.VLSI Mentor
client.cpp — compiled once, never edited
    #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 that the output is deterministic:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sha256 of client.o:
   2ee7c682aa40fd78c059972f4d6f939ad5f7cf8a25fa8956a090832e2e38fc17  client_a.o
   2ee7c682aa40fd78c059972f4d6f939ad5f7cf8a25fa8956a090832e2e38fc17  client_b.o

Now link that same object file against each version:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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=20

client.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 ticks=20 proves FastModel::step ran rather than anything of the interface's.

The mechanism is Chapter 17.2 §4's middle instruction. client.o contains ldr x1, [x8, #16] — a fixed slot. FastModel is a new class with its own table that puts its step at the same slot. The offset was chosen at compile time and the contents were not.

8. What the Client Must Not Do

Four prohibitions, each with the measurement behind it.

Never hold by value. Chapter 17.6 §4 — you get a different object.

Never std::vector<Base>. Chapter 17.6 §7 — every element sliced on insertion.

Never ask what type you are holding. Chapter 17.8 §7 measured a dynamic_cast chain being slow, silently incomplete when a type is added, and a hand-written dispatch. And Chapter 17.9 §4 established what it means: the interface is too narrow for this client's job, so the fix is in the interface rather than in the client.

And never name a concrete implementation outside the factory and the tests. Every mention is a place that must change when the set of implementations changes, which is exactly what §7 measured not having to do.

One more that is a client-side decision rather than a prohibition: do not reach for std::shared_ptr<Base> as the default owning type. Chapter 14.5 §10 gave the test — use it only when the last owner is genuinely unknowable — and Chapter 17.5 §6 measured a reason specific to hierarchies: a shared_ptr whose static type at construction was the base runs only the base's destructor, so it neither shares the unique_ptr cost profile nor reliably covers for a missing virtual destructor. std::unique_ptr<Base> is the default; shared ownership is the exception you justify.

9. Owning a Copy

A client that needs its own copy of something polymorphic cannot use the copy constructor — Chapter 17.6 §6 measured assignment keeping the data and dropping the identity. The interface supplies a clone, and Chapter 17.6 §10 measured the client side working:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    std::vector<std::unique_ptr<Model>> copies;
    for (const auto& m : models) copies.push_back(m->clone());
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. the originals:            FastModel id=7 / SlowModel id=1
B. a deep copy via clone():  FastModel id=7 / SlowModel id=1
C. copy id=999  original id=7   distinct objects? 1

Both types survived, with their data, as independent objects. Compare §4's vector<Model> result, where every element came back as Model.

Note what the client did not do: it did not know either type. m->clone() is an ordinary virtual call, and each implementation copies itself.

10. A Complete Client

Assembling everything:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    // the only place a concrete type is named
    std::unique_ptr<Model> makeModel(const Config& cfg)
    {
        if (cfg.fast) return std::make_unique<FastModel>(cfg);
        return std::make_unique<ReferenceModel>(cfg);
    }

    class Simulation {
    public:
        void add(std::unique_ptr<Model> m) { models_.push_back(std::move(m)); }
        void run(int cycles) {
            for (auto& m : models_) m->runFor(cycles);     // one dispatch per model
        }
        void report(std::ostream& os) const {
            for (const auto& m : models_) os << m->name() << '\n';
        }
    private:
        std::vector<std::unique_ptr<Model>> models_;
    };

Every decision in that listing is one of the measured ones: owning pointers in the container (§4), std::move on a by-value unique_ptr parameter (Chapter 14.3 §2's you now own this), one dispatch per unit of work (§5), no concrete type named (§6), and nothing asking what it holds (§8).

11. What to Actually Do

Hold by Base& for parameters, std::unique_ptr<Base> for ownership.

Store std::vector<std::unique_ptr<Base>> for collections.

Call through the interface and dispatch once per unit of real work.

Name concrete types only in a factory and in tests.

Use clone() when the client needs its own copy.

Never hold by value, never vector<Base>, never ask the type.

Use shared_ptr only for genuinely shared ownership.

And read a dynamic_cast in your client as a bug report against the interface.

12. Mistakes Worth Making Once

A by-value Base parameter. Measured: a different object, with the base's implementations.

std::vector<Base>. Measured: sliced on every insertion.

A raw-pointer container. Preserves identity and leaks — Chapter 14.1 §9.

A dynamic_cast chain. Slow, silently incomplete, and a hand-written dispatch.

Naming concrete types throughout the client. Every mention is a place to edit later.

Copy-constructing a polymorphic object. Measured: data kept, identity dropped.

Dispatching per trivial operation instead of per unit of work.

shared_ptr by default. Chapter 14.5's cost, for ownership you do not share.

Assuming the mechanism validates the contract. It dispatches faithfully to a derived class that broke it.

13. Interview Relevance

"How do you hold a polymorphic object?" By reference for parameters, by owning smart pointer for ownership — never by value, because by value you get a different object.

"Why not std::vector<Base>?" Every element is sliced on insertion. std::vector<std::unique_ptr<Base>> is the standard shape.

"How do you add a new implementation?" Write the class. Measured: the client object file, byte-identical, drove an implementation that did not exist when it was compiled.

"What makes that possible?" The call site reads a fixed table slot chosen at compile time, and the contents of that slot are not fixed.

"How do you copy one?" A virtual clone() returning an owning pointer; the copy constructor slices.

"What should a client never do?" Hold by value, use a container of values, or ask what type it holds.

"What does a dynamic_cast in client code mean?" The interface is too narrow — it is a bug report against the base class.

14. What You Should Be Able to Say Now

  1. The client has four jobs: hold, invoke, substitute, extend — with one right answer each.
  2. Nothing connects the client to the implementations — both depend only on the interface.
  3. Hold parameters as Base& — measured, by value you get a different object.
  4. Hold ownership as std::unique_ptr<Base>, which works on an abstract base.
  5. Measured: vector<Model> sliced every element; vector<Model*> preserved identity — so use vector<unique_ptr<Base>>.
  6. A raw-pointer container preserves identity and leaks — Chapter 14.1 §9.
  7. One call site serves every implementation — measured, three objects, three behaviours, no branching.
  8. Dispatch once per unit of real work, not per trivial operation.
  9. Substitute by changing the construction — name concrete types only in a factory and in tests.
  10. A client written this way takes a test double unmodified.
  11. Measured, the headline: a byte-identical client.o (sha256 2ee7c682…) drove a FastModel added afterwards, with ticks=20 proving the new implementation ran.
  12. The mechanism is the fixed table slot — chosen at compile time, contents not.
  13. Four prohibitions: no by-value, no vector<Base>, no type queries, no concrete names outside the factory and tests.
  14. Measured: clone() deep-copied a mixed container, preserving both types as independent objects.
  15. The client is cheap and the interface is hard — Chapter 17.9's three properties are where the judgement lives.
  16. The mechanism does not validate the contract — it dispatches faithfully to a derived class that broke it, which is Module 16's problem.

15. Exercises

Exercise 1 — the four jobs. Name them and give the one right answer for each.

Exercise 2 — fix the signatures. Correct void drive(Model m);, std::vector<Model> models;, and Model copyOf(const Model& m) { return m; }.

Exercise 3 — the container. Say what vector<Model>, vector<Model*> and vector<unique_ptr<Model>> each give you and cost you.

Exercise 4 — design the measurement. Describe how you would prove a client is independent of the implementation set, and say what you would hash and what you would link.

Exercise 5 — the mechanism. Explain, in terms of the three dispatch instructions, why an already-compiled client can drive a new implementation.

Exercise 6 — substitution. Write a factory returning std::unique_ptr<Model> from a config, and say how many other files change when you add a third model.

Exercise 7 — the test double. Write a RecordingModel that counts step() calls, and say what the client has to change to accept it.

Exercise 8 — the copy. Write the client-side loop that deep-copies a container of models, and say why the copy constructor cannot do it.

Exercise 9 — the bug report. A colleague's client contains a dynamic_cast chain. Say what you would change and in which file.

16. Summary

  • Four client jobs — hold, invoke, substitute, extend — each with one measured answer.
  • Hold by Base& or std::unique_ptr<Base>; never by value, because by value you get a different object.
  • Measured: vector<Model> sliced every element and vector<Model*> did not — so vector<unique_ptr<Base>> is the shape.
  • One call site serves every implementation, at a measured indirection of about 0.006 ns.
  • Substitute by changing the construction and keep concrete type names in a factory and in tests only.
  • Measured, the headline: a byte-identical client.o drove a FastModel that did not exist when it was compiled — ticks=20.
  • The fixed table slot is why: the offset is chosen at compile time and its contents are not.
  • Never hold by value, use a container of values, or ask the type — the last is a bug report against the interface.
  • clone() is how a client copies polymorphically — measured preserving both types independently.

That completes Module 17, and with it the object-oriented half of this curriculum. Module 11 gave a class an invariant, Module 12 made its construction and destruction reliable, Module 13 made it copyable and movable on purpose, Module 14 put its ownership in its interface, Module 15 gave it the notation a reader expects, Module 16 established what a derivation asserts, and this module made a hierarchy do something: one call site, any number of implementations, the choice made at run time, and new implementations added without touching the code that drives them.

The whole arrangement rests on one condition, stated in Chapter 17.1 §7 and repeated whenever a cheaper alternative came up: the set of implementations must be genuinely open. Where it is not — where you have three types, you wrote all three, and the list will not grow — the module has been quietly pointing somewhere else. Chapter 17.1 §3 named it, Chapter 17.7 §10 named it as the answer when dispatch cost matters, Chapter 17.8 §9 named it for closed type sets, and Chapter 17.9 §9 named it as the alternative that keeps the abstraction and the inlining together. Module 18 is Templates and Generic Programming: ten chapters on writing one piece of code that works with many types where the types are known when the code is compiled — a different trade with a different cost profile, which is why std::vector<int> and std::vector<Transaction> are separate generated types with no dispatch between them, and why the standard library's containers and algorithms are built this way rather than on the hierarchy this module just finished.

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.