Module 8 — Concepts and Explanations¶
Read this with a compiler open. This module is short and unusually load-bearing: almost every design decision in Modules 12–16 is written against the interfaces you build here.
Module 7 gave you dispatch. This module takes one more step, and it is the step that turns a base class into a contract: a class that cannot be instantiated at all.
1. "What if the base class genuinely has no answer?"¶
Some base classes have a sensible default for every operation. Most do not. What is the area of a shape in general? What does it mean to send a notification in general?
Write = 0 and the function becomes pure virtual: it has no implementation the
compiler will use, and the class containing it becomes abstract.
// examples/module-08/01-shape-abstraction/main.cpp
class Shape {
public:
virtual ~Shape() = default; // mandatory: see §5
virtual std::string name() const = 0; // every shape must answer
virtual double area() const = 0;
virtual double perimeter() const = 0;
// Written once, against the contract. It never learns the word Circle.
std::string describe() const {
return name() + " area " + fixed2(area()) + " perimeter " + fixed2(perimeter());
}
};
An abstract class cannot be instantiated, and this is the compiler's wording for it — captured from this toolchain:
error: cannot declare variable 'shape' to be of abstract type 'Shape'
| ^~~~~
note: because the following virtual functions are pure within 'Shape':
note: 'virtual double Shape::area() const'
Read the note: lines first. They are a checklist: these are the functions nothing has
implemented yet. If a derived class of yours fails with this error, the same note appears
and names exactly which override is missing or misspelled — which is why the error is
useful rather than annoying.
The diagram of the hierarchy the program builds:
┌───────────────────────────────┐
│ Shape │ abstract
├───────────────────────────────┤ (no data members)
│ ▲ │
│ + name() const = 0 │
│ + area() const = 0 │
│ + perimeter() const = 0 │
│ + describe() const │ ← not virtual: correct as written
│ + virtual ~Shape() = default │
└───────────────▲───────────────┘
┌─────────────┬───────┴──────┬────────────────┐
┌───────┴──────┐┌─────┴───────┐┌─────┴──────────────┐
│ Circle ││ Rectangle ││ RightTriangle │
├──────────────┤├─────────────┤├────────────────────┤
│ - radius_ ││ - w_, h_ ││ - a_, b_ │
├──────────────┤├─────────────┤├────────────────────┤
│ + name() ││ + name() ││ + name() │
│ + area() ││ + area() ││ + area() │
│ + perimeter()││ + perimeter()││ + perimeter() │
└──────────────┘└─────────────┘└────────────────────┘
describe() is not virtual, and it does not need to be. It is correct for every
subclass because it calls the virtual functions — and it is written once. That sentence
is the whole promise of an abstract base class, and §3 gives the general form.
Predict before running it: the program calls totalArea() and largest() on a
collection of shapes, then creates a second, different collection and calls the very same
functions. How much of totalArea() has to change for the second collection to work?
Now the same four lines, unchanged, over a set of shapes that did
not exist when those functions were written:
circle area 3.14 perimeter 6.28
totalArea() = 7.14
Nothing changes. Not "little": nothing. The functions were written against the contract, so they work on shapes that did not exist when they were compiled.
2. "What exactly is the contract?"¶
A contract is a statement about what a function promises, and design by contract is the habit of writing it down before implementing it. Three words, and each answers a different question:
| Term | Question it answers | Example |
|---|---|---|
| Precondition | What must be true of the arguments before the call? | amount > 0; the message is not empty |
| Postcondition | What is guaranteed on return? | success ⇒ a reference that can be quoted to the customer |
| Invariant | What stays true for the life of the object? | a wallet's balance is never negative |
examples/module-08/03-payment-methods/main.cpp is a contract with failure in it, and the
interesting decision is how failure is returned. The method's own limit is a fact only
the method knows, so the interface asks the object rather than guessing:
struct PaymentResult { // a value type, not an interface: it is copied
bool succeeded = false;
std::string reference;
std::string message;
static PaymentResult ok(std::string reference, std::string message) { … }
static PaymentResult refused(std::string message) { … }
};
class PaymentMethod {
public:
virtual ~PaymentMethod() = default;
virtual std::string name() const = 0;
virtual bool canHandle(long long amountPaisa) const = 0; // the method's own rule
virtual PaymentResult pay(long long amountPaisa) = 0; // the attempt, with a reason
};
A checkout that tries wallet, then card, then cash:
bill 15000.00
tried wallet: cannot handle 15000.00
tried card: authorised 15000.00
PAID via CARD-9001 — authorised 15000.00
The same Rs 900,000 bill, through a checkout that has no cash:
tried wallet: cannot handle 900000.00
tried card: cannot handle 900000.00
REFUSED — no method could complete the payment
What the checkout never had to know:
- that the wallet has a balance, or that it has one at all;
- that the card has a credit limit, or what it is;
- how many methods there are. `pay` loops over a vector.
Three design decisions are visible in that output, and each is worth stealing:
canHandleexists so the caller never guesses. The rules for what a method accepts live in one place — the method — andCheckoutasks instead of switching on types.- Failure is a value, not a bare
false.PaymentResultcarries a reference, a message and a reason, so the caller can print why nothing worked. (std::optionaland exceptions are the alternatives; Module 13 is where exceptions are the right answer.) - The interface is small. Three functions, and every one of them is something the caller genuinely needs. Every entry in a contract is a tax on every implementation.
3. "Abstract base class, or interface?"¶
Two shapes of abstraction, and one rule separates them.
ABSTRACT BASE CLASS INTERFACE
state + partial implementation no state, all pure virtual
──────────────────────────── ──────────────────────────
class Report { class Printable {
public: public:
virtual ~Report() = default; virtual ~Printable() = default;
std::string render() const; // once virtual std::string oneLine() const = 0;
protected: };
virtual std::string title() const = 0;
virtual std::vector<Row> rows() const = 0;
private:
int id_; ← nothing to protect
};
examples/module-08/04-abstract-base-and-interface/main.cpp builds both, and one
paragraph from its own output is the rule:
**You may derive from several interfaces. Deriving from several stateful
bases gives an object the base twice** — Module 6's diamond, where renaming
through one base leaves the other unchanged. Interfaces have no state, so the
problem cannot arise.
So AttendanceReport is both a Report and a Printable, and that is the normal,
recommended use of multiple inheritance in C++. The output shows the roles staying
separate:
Both objects are usable as both, and neither role leaks into the other:
printReport() needs Report, and never learns what kind of report it has;
printOnOneLine() needs Printable, and never learns that a report is
behind it. It would work on an invoice, a student card,
or anything else written next term with one function.
Which to write — the program's own summary, which is more useful than a rule of thumb:
ABSTRACT BASE (state + partial implementation) when several types share
data or a fixed algorithm with variable parts. `render()` is that
algorithm: it is written once, and every subclass inherits both the
layout and the guarantee of having one. Cost: subclasses are locked
into the base's design.
INTERFACE (no state, all pure virtual) when a caller needs a capability
and nothing else — print, send, charge, compare. Cost: nothing is
shared, so implementations repeat themselves; the benefit is that a
class can have the capability without adopting a base class, and
without any risk of the diamond.
Two details worth naming, both visible in Report:
render()is not virtual and does not need to be: it is correct for every subclass because it calls the virtuals. A non-virtual public function calling protected virtual ones is the non-virtual interface (NVI) technique — the base owns the algorithm, the subclass supplies the parts. Module 15 calls its pattern version template method.- The extension points are
protected. The public interface of aReportisrender()andid(); a caller cannot accidentally callrows()and get a fragment. Protected virtuals make the extension seams visible instead of advertising them.
A naming note, since this course is used by people who will read other people's code:
IThing and ThingInterface are both common in the wild. This course does not use
them — it names interfaces after the capability (Printable, Notifier,
PaymentMethod) — because the suffix says what a class can do, and a prefix says only
which folder the author came from.
4. "Can a pure virtual function still have a body?"¶
Yes, and = 0 says two things at once — people usually hear only the first:
- This class is abstract; subclasses must supply an implementation.
- …and they may call mine, if I chose to write one.
// examples/module-08/05-pure-virtual-with-a-definition/main.cpp
class Exporter {
public:
virtual ~Exporter() = default;
virtual std::string name() const = 0; // pure, no definition
// Pure, WITH a definition below. Both subclasses produce slightly different
// output, and the shared nine lines are written once.
virtual std::string footer() const = 0;
};
std::string Exporter::footer() const { // ← a body for a pure virtual
return "(footnote: generated by the base class's default footer)";
}
csv:
,,,,
Ayesha,90
,,,,
rows exported by csv
(footnote: generated by the base class's default footer)
text:
----------------------------
Ayesha,90
----------------------------
end of text export
Both exporters are concrete, both had to implement footer(), and the CSV one chose to
call the base's version explicitly — Exporter::footer() — rather than repeat nine lines.
And the base still cannot be instantiated:
Exporter exporter; error: cannot declare variable 'exporter' to be
of abstract type 'Exporter'
A definition does not make a pure virtual function non-pure. `= 0`
decides abstractness; the body only decides what a subclass can call.
The program is equally clear about when this is not worth it: a body nobody calls is dead code with a confusing signature. Write it when two or three subclasses differ in one statement and the shared part is too small to justify a separate class.
The one case where the definition is mandatory: a pure virtual destructor.
class Sink {
public:
virtual ~Sink() = 0; // pure…
virtual void write(const std::string&) = 0;
};
Sink::~Sink() = default; // …and it STILL must be defined
Because the destructor of a base is always run — by every derived destructor, and by the end of every complete object's life. Omit the definition and you get a link error, not a compile error. On the toolchain this course is verified on, that surfaces as a bare line with no symbol name:
It is worth knowing that this is what a missing destructor definition can look like — a
linker complaining about nothing in particular, with the symbol name nowhere in the output
— because the usual message on other toolchains is the more helpful
undefined reference to 'Sink::~Sink()'. Either way the diagnosis is the same: something
declared is never defined, and for a pure virtual destructor the something is the
destructor body. That is why a pure virtual destructor is rare, and why the course prefers
virtual ~Base() = default;.
5. "Why must an abstract base always have a virtual destructor?"¶
Module 7 answered this for hierarchies in general; an abstract base is the case where it is
never optional. Consider what an abstract base is: a type that exists so that objects of
other types can be used through it. Used through it means held by Base* or Base&, and
any structure that holds them — a container, a factory, a unique_ptr — will eventually
destroy them through that handle.
If ~Shape() is not virtual, three lines of code that look completely ordinary leak, and
nothing in the program reports it. Write it in every abstract base as you create the class,
before you have a reason:
One alternative is worth knowing for interface-only types: a protected non-virtual
destructor makes delete basePointer a compile error, so the mistake cannot be written —
the object can only be destroyed by something that knows its real type, which in practice
means a smart pointer arranged for it (Module 11).
6. "How big should an interface be?"¶
Small. Two reasons, and the second is the one that bites.
Every function in an interface must be implemented by every concrete class. A
Notifier with five functions means a new channel that can only send SMS must write four
functions that either lie or throw. examples/module-08/02-interface-contracts/main.cpp
keeps its interface at one function, and its output shows what that buys:
The same service, three channels — and no line of it knows which:
sending through: email
announce ok -> true
announce bad addr -> false (not an email address: ayesha)
sending through: sms
announce ok -> true
announce too long -> false
(message is 200 characters; the limit is 160)
sending through: recording
nothing was sent. What the recorder captured:
ayesha@example.edu | Fee due on Friday
A wide interface imports assumptions. Each function in a base class is a promise the
base's author makes on behalf of implementations that do not exist yet. The PaymentMethod
interface promises a limit, an attempt and a reason; a hypothetical "cash on
delivery" method would have to invent a limit it does not have. Splitting the concern —
Chargeable, Refundable — keeps each implementation honest, at the cost of more types
and a dynamic_cast or two at the boundary. This is the interface segregation
principle, named properly in Module 15.
The practical test: can every implementation honour every function, without lying? The first function you cannot implement is the one that says the interface is too wide.
7. "Where does the running system's design smell go?"¶
Module 6 left a sentence in the payroll example: Contractor's constructor calls
Employee(id, name, 0) — because a contractor has no monthly salary at all — and the
comment said Module 8 replaces "a base field that is meaningless for some derived classes
with an interface that has no such field".
Here is that replacement, and it is the same move as the payment methods.
BEFORE — a base with a field that is meaningless for one of its kinds
┌──────────────────────────────┐
│ Employee │
├──────────────────────────────┤
│ - id_ │
│ - name_ │
│ - monthlyPaisa_ ← a lie for a contractor
├──────────────────────────────┤
│ + monthlyPay() │ ← hides in the subclass, no dispatch
└──────────────▲───────────────┘
┌────────┴────────┐
SalariedEmployee Contractor (and a base Employee, meaningless too)
AFTER — capability interfaces that have no such field
┌────────────────────┐ ┌──────────────────────┐
│ Identifiable │ │ Payable │
├────────────────────┤ ├──────────────────────┤
│ + id() const = 0 │ │ + monthlyPay() = 0 │
│ + name() const = 0 │ │ + virtual ~Payable() │
└─────────▲──────────┘ └──────────▲───────────┘
└──────────┬───────────────────┘
┌────────┴─────────┐
│ SalariedEmployee│ implements both
└──────────────────┘
┌──────────────────┐
│ Contractor │ implements both; no salary field exists
└──────────────────┘
Three things change, and all three are improvements you can state in one sentence each:
monthlyPaisa_stops existing, so no derived class is constructed with a value it does not have.monthlyPay()becomes virtual with no base implementation, so a payroll loop overPayable&cannot get the wrong number — Module 7's defect becomes unrepresentable.- A class that is only identifiable, such as a
Volunteer, implementsIdentifiableand notPayable, which the old hierarchy could not express at all.
The cost is honest and worth naming: the shared id_ and name_ storage is gone, so both
implementations repeat two members. When that repetition is worse than the lie, keep an
abstract base class with the shared state and no meaningless field — Report in §3 is
exactly that shape.
8. "How deep should a hierarchy be?"¶
The question §3 leaves open. An abstract base used as an interface adds one level and stops, which is why interface hierarchies tend to be flat. An abstract base with state and a partial algorithm invites another level, and then one more.
What depth costs is not lines of code, it is assumptions per level. Every level is entitled to assume the level below it, so a change at the root is a change to every leaf's contract. The concrete symptoms:
- a class at level four that has to override an operation in order to disable it — the tell that the operation belongs higher up or nowhere;
- a level whose only contribution is convenience constructors;
- two subclasses at the same level with more in common with each other than with their parent;
protecteddata, which lets a leaf corrupt the state its parent maintains.
Two ways to flatten, both used later in this course: move the varying part into a separate interface (§7's move), or replace the deeper level with a strategy object held by composition (Module 9 and Module 15). The rule of thumb the course uses: if the word "is-a" needs a qualification to be true, the hierarchy is one level too deep.
9. "What does the abstraction cost me?"¶
Four things, each of which should be chosen rather than discovered:
- You cannot have a
Shape. No value semantics, noShape shape;, nostd::vector<Shape>. Objects live behind pointers or references, and therefore somewhere there is an ownership decision — Module 11's subject, and the one thingexamples/module-08/01-shape-abstraction/explicitly hands forward. - You cannot ask what type it really is — cheaply. A
dynamic_castis available and is a design signal: once a caller needs to know the concrete type, the interface has stopped doing its job. Theswitch-to-dispatch refactor of Module 7 is the remedy. - You pay an indirection. Module 7's measurement: one pointer per object, one lookup
per call. For a
Shapecomputed once per frame this is nothing; for a per-pixel operation it is the wrong design. - You are committed to run-time dispatch for this decision. If the set of implementations is fixed at compile time and performance matters, a template parameter gives the same substitution with no indirection — Module 12 is that alternative, and it is often the better one for small, value-like types.
The trade in one line: an interface buys the ability to add implementations without editing callers, and costs you value semantics, cheap type knowledge and an indirection. Write one when the adding matters; do not write one for a type with one implementation.
Checkpoint¶
class Base { public: virtual void f() = 0; };— write the two statements= 0makes, and say which of them a definition offwould change.- A concrete class derived from
Shapefails to compile withcannot declare variable … to be of abstract type. What are the two most likely causes, and which line of the error tells them apart? - Name one thing an interface can do that a stateful abstract base cannot, and one thing a stateful abstract base can do that an interface cannot.
- Why is a virtual destructor mandatory in an abstract base and merely good practice in some other hierarchies?
- A
Printerinterface hasprint(),flush(),scan(),staple()andemail(). Which function is the evidence that it is too wide, and what would you do about it?
Next: Worked examples — five programs, including one that shows what a contract prevents, and one that shows the two kinds of abstraction side by side.