C++ Part I · Module 17 · Runtime Polymorphism
Designing a Polymorphic Interface
Eight chapters have treated the base class as given. Model has a step, Sink has a write, and the question of whether those were the right operations never came up, because the subject was the mechanism. This chapter is the judgement, and the frozen description of it supplies the structure in three words: an interface worth implementing is narrow, complete, and free of assumptions about any one derived type. Each of those turns out to be a specific, checkable property rather than a slogan. Narrow has a number attached, and the number is smaller than people expect, because every operation you add is an operation every present and future implementation must supply. Complete has a test, and the test was already measured two chapters ago — a chain of dynamic_cast attempts in client code is evidence that the interface was too narrow to do its job. And free of assumptions is the one that produces the hierarchies nobody can extend, because a base class absorbs the shape of whichever implementation was written first unless somebody deliberately stops it. The chapter also covers a technique that inverts the usual arrangement: making the public interface non-virtual and the customisation points virtual and private, which keeps the sequence of a process in the base while letting each implementation supply the steps. It rests on a property of C++ that surprises nearly everybody, and this chapter measures it.
Advanced19 min readC++Part IFundamentalsPolymorphism
Part I · Module 17 · Chapter 17.9 · Designing a Polymorphic Interface
1. What "Worth Implementing" Means
A polymorphic base class is a contract offered to people who will write implementations of it — possibly including people you will never meet, and certainly including you in two years. The question this chapter answers is what makes such a contract one they can actually honour.
The frozen brief gives three properties, and each is checkable:
| Property | The check |
|---|---|
| narrow | how many operations must a new implementation supply? |
| complete | can a client do its job using only the interface? |
| free of assumptions | does any operation presume a particular implementation's structure? |
Those three pull against each other — narrowing tends to hurt completeness — and §6 is the shape of the compromise.
2. The Starting Point
Everything Module 17 established, assembled:
class Model {
public:
virtual ~Model() = default; // Chapter 17.5
virtual void step() = 0; // Chapter 17.4 -- pure, so it must be supplied
virtual const char* name() const = 0;
};Three lines of obligation. The destructor is virtual because Chapter 17.5 measured what happens otherwise. The operations are pure because Chapter 17.4 §5 measured that a half-implemented derived class is itself abstract — a missing implementation becomes a compile error rather than a wrong answer — and Chapter 17.6 §9 measured that an abstract base makes slicing impossible.
That is the floor. Everything below is about what else goes in and what stays out.
3. Narrow
Every operation in the interface is an obligation on every implementation, forever. Ten operations means a new implementation writes ten functions before it can exist, and nine of them may be irrelevant to it.
The number is smaller than people expect, and the reason is that interfaces accumulate for a bad reason: somebody needed something, the base was the reachable place to put it, and the base grew. That is Chapter 16.8 §6's shape — the base that keeps growing — applied to a contract instead of a class.
Three questions that keep it narrow:
Does every implementation genuinely need this, or just the one I am looking at? If one, it belongs in that implementation, reached some other way.
Is this operation, or is it two? configureAndStart() is two obligations wearing one name, and an implementation that can do one but not the other has to fake it.
And could a client compose this from the others? If runFor(n) is a loop over step(), it does not need to be virtual — §7.
A useful target: three to five virtual operations. Not a rule, but a number worth having in mind, because an interface approaching ten is almost always two interfaces.
4. Complete
The opposite failure, and Chapter 17.8 §7 already measured its symptom:
void handle(Model& m) {
if (auto* f = dynamic_cast<FastModel*>(&m)) { f->fastThing(); }
else if (auto* s = dynamic_cast<SlowModel*>(&m)) { s->slowThing(); }
}A dynamic_cast chain in client code is evidence that the interface is too narrow to do the client's job. The client needed something, the interface did not offer it, and the client went around the interface to get it. Chapter 17.8 §7 gave the three objections: slow, silently incomplete when a type is added, and a hand-written dispatch.
So completeness has a practical test: can every intended client do its whole job through the interface alone? Write the client first if you are unsure — it is the most reliable way to discover which operations are actually needed, and it tends to produce a narrower interface than designing the base in isolation, because a client only asks for what it uses.
5. Free of Assumptions About One Implementation
The subtlest of the three, and the one that produces unextendable hierarchies.
A base class absorbs the shape of whichever implementation was written first, because that implementation is the only evidence available while the base is being designed. The symptoms are recognisable:
An operation only one implementation can answer meaningfully. virtual int cacheHitRate() const = 0; on a Model interface — every implementation must supply it, and most have nothing to say. The others return 0, or -1, or throw, and now the interface contains an operation whose contract is "ignore this unless the object is a CachedModel".
A parameter or return type from one implementation's vocabulary. A Model whose step() takes a FastModelConfig has built one implementation's structure into everybody's obligation.
An ordering or lifecycle assumption. virtual void prepare() = 0; virtual void step() = 0; with an unstated rule that prepare is called exactly once, first — which is a sequence the base knows about and does not enforce. §7 is the fix.
And a protected data member, which Chapter 16.4 §3 measured destroying a base's invariant from three lines of derived code. In an interface it is worse than a design smell: it is a representation every implementation must adopt.
The diagnostic question: if a second, completely different implementation appeared tomorrow, which operations would it struggle to answer? Those are the assumptions.
6. The Three Pull Against Each Other
Narrowing makes an interface easier to implement and more likely to be incomplete. Completing it makes it wider. There is no arrangement that maximises all three, so the compromise is the design.
Two moves that genuinely help rather than trading one against another.
Split the interface. If half the operations serve one kind of client, that is two interfaces. Chapter 17.8 §8's Debuggable is the pattern — a separate interface an implementation may also provide, queried deliberately, where "no" is a normal answer.
And move the composite operations out of the virtual set — §7, which is the main technique and the reason the target number in §3 is achievable.
7. The Non-Virtual Interface
Invert the usual arrangement. Public functions are non-virtual; the customisation points are virtual and private.
class Model {
public:
virtual ~Model() = default;
void run(int cycles) { // the fixed public sequence
begin();
for (int i = 0; i < cycles; ++i) step();
end();
}
private:
virtual void begin() { /* a default */ }
virtual void step() = 0; // private AND pure virtual
virtual void end() { /* a default */ }
};The public interface is one function. The obligations are the private virtuals. And the sequence — begin, then step repeatedly, then end — is owned by the base and cannot be rearranged by an implementation.
This depends on a property of C++ that surprises nearly everyone, so it is measured:
A. a private virtual CAN be overridden by a derived class:
FastModel::begin
FastModel::end (t=30)
(ran 3 cycles)The derived class's overrides ran, through the base's run(), even though the virtuals are private in Model. Model::begin and Model::end did not print, because FastModel replaced them.
And they remain uncallable. From derived code:
error: 'step' is a private member of 'Model'From outside:
error: 'step' is a private member of 'Model'Overriding and access are independent. A private virtual function is overridable but not callable — which is exactly the property a customisation point wants.
That is the whole justification for the idiom, and the benefits follow from it:
The base owns the sequence. An implementation cannot forget to call begin or do the steps in the wrong order, because it never calls anything.
The public interface can change without changing the obligations, and vice versa. Adding a parameter to run touches one function.
The base can add shared work — validation, logging, timing — around every implementation's steps, in one place.
And it removes the ordering assumption §5 warned about: the lifecycle is code in the base rather than a rule in a comment.
(Use protected rather than private for a hook a derived class genuinely needs to call — typically to invoke the base's version as shared scaffolding, which Chapter 17.4 §10 measured working via qualification. private is the default; protected is the exception you justify.)
8. What Does Not Belong in the Interface
Collecting the exclusions, each with the chapter that measured why.
Anything with a default argument. Chapter 17.2 §9 measured a virtual call taking its body from the dynamic type and its default argument from the static type, producing a combination in neither class's source. Put the default in the non-virtual public function.
Data members. The interface is a contract, not a representation. Chapter 16.4 §4 is the general argument.
Operations one implementation needs — §5.
Composite operations expressible from the others — §7.
And anything the base's constructor needs to call. Chapter 16.5 §6 measured that the derived part does not exist during base construction, so a virtual call from a base constructor reaches the base's version. If a base needs a value only the implementation knows, it takes it as a constructor parameter.
9. When Not to Use a Hierarchy at All
The honest closing question, because Chapter 16.7 §7's procedure applies to interfaces too.
If the implementation set is closed and known, a template keeps the abstraction and the inlining — Chapter 17.7 §5 measured that lost inlining, not dispatch, is the real cost. Module 18 is that mechanism.
If the interface has exactly one operation, a callable — a function reference, or std::function — is often the whole answer, and Chapter 15.6 §10 measured that a functor's call is inlinable where an indirect one is not. A one-method interface is frequently a function in a trench coat.
And if clients keep asking what type they have, Chapter 17.8 §9's std::variant over a closed set gives exhaustiveness checking and no hierarchy.
The condition for a hierarchy remains Chapter 17.1 §7's: the implementation set is genuinely open, and already-compiled client code must drive implementations added later.
10. What to Actually Do
Start from the floor: virtual destructor, pure virtual operations, abstract base.
Aim for three to five virtual operations and treat approaching ten as evidence of two interfaces.
Write a client first to discover which operations are actually needed.
Make the public interface non-virtual and the hooks virtual and private where there is a sequence to own.
Use protected for a hook only when a derived class must call the base's version.
Keep defaults, data members, composite operations and single-implementation operations out.
Ask what a second, completely different implementation would struggle to answer — those are your assumptions.
And check Chapter 16.7 §7's procedure and Chapter 17.1 §7's condition before deciding a hierarchy is the right mechanism at all.
11. Mistakes Worth Making Once
A wide interface. Every operation is an obligation on implementations that do not exist yet.
An operation only one implementation can answer. The rest return a sentinel or throw.
Designing the base with one implementation in view. It absorbs that implementation's shape.
A dynamic_cast chain in a client. Chapter 17.8 §7 — the interface is too narrow.
A default argument on a virtual function. Chapter 17.2 §9's hybrid.
Data members in an interface. A contract is not a representation.
An unstated lifecycle rule. Put the sequence in a non-virtual function.
A virtual call from the base's constructor. Chapter 16.5 §6 — the derived part does not exist.
Assuming a private virtual cannot be overridden. Measured: it can; it just cannot be called.
Building a hierarchy for a closed type set. A template keeps the inlining.
12. Interview Relevance
"What makes a good polymorphic base class?" Narrow, complete, and free of assumptions about any one implementation — with a check for each. Giving the three properties and the tension between them is the strong answer.
"How many virtual functions should an interface have?" Few — three to five as a working target — because each is an obligation on every future implementation.
"What does a dynamic_cast chain in client code tell you?" The interface is incomplete for that client's job.
"What is the non-virtual interface idiom?" Public non-virtual entry points with private virtual hooks, so the base owns the sequence and the client's interface is decoupled from the implementers' obligations.
"Can a private virtual function be overridden?" Yes — overriding and access are independent. Measured: the derived overrides ran through the base's public function, and neither derived nor outside code could call them. Very few candidates know this.
"Where do default arguments go?" On the non-virtual public function, never on a virtual one.
"When would you not use a hierarchy?" A closed type set (use a template), a one-operation interface (use a callable), or clients that keep asking the type (use a closed-set union).
13. What You Should Be Able to Say Now
- An interface worth implementing is narrow, complete, and free of assumptions about one implementation — three checkable properties.
- The floor is a virtual destructor, pure virtual operations, and therefore an abstract base — Chapters 17.5, 17.4, 17.6 §9.
- Every operation is an obligation on every present and future implementation.
- Three to five virtual operations is a working target; ten suggests two interfaces.
- A
dynamic_castchain in a client is evidence of incompleteness — Chapter 17.8 §7. - Writing a client first discovers the operations actually needed and tends to narrow the interface.
- A base absorbs the first implementation's shape unless you deliberately resist it.
- The diagnostic: what would a second, completely different implementation struggle to answer?
- Narrow and complete are in tension; splitting the interface and the non-virtual interface idiom are the two moves that help rather than trade.
- Measured: a private virtual was overridden and ran through the base's public function.
- Measured: neither derived nor outside code could call it —
'step' is a private member of 'Model'. - So overriding and access are independent, which is what makes the idiom work.
- The base owns the sequence, so an implementation cannot reorder or skip steps.
- Keep out: default arguments (Chapter 17.2 §9), data members, composite operations, single-implementation operations, and anything the base's constructor must call (Chapter 16.5 §6).
- Use
protectedfor a hook only when the base's version must be callable. - A hierarchy is the right mechanism only when the implementation set is genuinely open — otherwise a template, a callable, or a closed-set union.
14. Exercises
Exercise 1 — the three checks. State each property and the question that checks it.
Exercise 2 — narrow it. Given a Model interface with nine virtual functions, say which you would remove and where each would go.
Exercise 3 — the completeness test. Write a client for your interface and say which operations it revealed as necessary.
Exercise 4 — spot the assumption. For virtual int cacheHitRate() const = 0; on a Model, say what a non-caching implementation must do and why that is a design failure.
Exercise 5 — the NVI shape. Convert a three-virtual-function interface into a non-virtual public interface with private virtual hooks.
Exercise 6 — the surprising fact. Say whether a private virtual can be overridden, whether it can be called, and by whom — with the measured diagnostics.
Exercise 7 — the exclusions. Name five things that do not belong in an interface and the chapter that measured why for each.
Exercise 8 — private or protected. Give a case where a hook should be protected rather than private.
Exercise 9 — not a hierarchy. For three designs, say whether a hierarchy, a template, a callable, or a closed-set union fits, with the condition that decides it.
15. Summary
- Narrow, complete, free of assumptions — three checkable properties, in tension with each other.
- The floor: virtual destructor, pure virtual operations, abstract base.
- Narrow because every operation is a permanent obligation on implementations that do not exist yet; three to five is a working target.
- Complete because a
dynamic_castchain in a client is the measured symptom of an interface that could not do the job. - Free of assumptions because a base absorbs the first implementation's shape — ask what a second one would struggle to answer.
- Measured: a private virtual can be overridden but not called, by derived code or anyone else.
- That independence is what makes the non-virtual interface idiom work: the base owns the sequence, and the client's interface is decoupled from the implementers' obligations.
- Keep out default arguments, data members, composite and single-implementation operations, and anything the base constructor must call.
- And confirm a hierarchy is the right mechanism at all — the condition is a genuinely open implementation set.
The interface is designed and the module has one chapter left, which is the other half of the same subject. Every measurement so far has been taken from inside the hierarchy — what a base declares, what a derived class must supply, what a call costs. None of it has described the code that actually uses an interface, and that code has its own set of decisions, most of which have been established piecemeal and never collected: hold the object by reference or by owning pointer and never by value, store collections as owning pointers, take parameters as const Base&, and never ask what type you are holding. The last chapter puts the client side together, with the measurement that makes the whole module worth the trouble — the one from Chapter 17.1, run properly: a client compiled once, hashed, and linked unchanged against an implementation written afterwards.
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.
