Skip to content

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:

  1. canHandle exists so the caller never guesses. The rules for what a method accepts live in one place — the method — and Checkout asks instead of switching on types.
  2. Failure is a value, not a bare false. PaymentResult carries a reference, a message and a reason, so the caller can print why nothing worked. (std::optional and exceptions are the alternatives; Module 13 is where exceptions are the right answer.)
  3. 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 a Report is render() and id(); a caller cannot accidentally call rows() 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:

  1. This class is abstract; subclasses must supply an implementation.
  2. …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:

collect2.exe: error: ld returned 116 exit status

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.

std::vector<std::unique_ptr<Shape>> shapes;      // ownership through the base

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:

class Shape {
public:
    virtual ~Shape() = default;
    …
};

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:

  1. monthlyPaisa_ stops existing, so no derived class is constructed with a value it does not have.
  2. monthlyPay() becomes virtual with no base implementation, so a payroll loop over Payable& cannot get the wrong number — Module 7's defect becomes unrepresentable.
  3. A class that is only identifiable, such as a Volunteer, implements Identifiable and not Payable, 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;
  • protected data, 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:

  1. You cannot have a Shape. No value semantics, no Shape shape;, no std::vector<Shape>. Objects live behind pointers or references, and therefore somewhere there is an ownership decision — Module 11's subject, and the one thing examples/module-08/01-shape-abstraction/ explicitly hands forward.
  2. You cannot ask what type it really is — cheaply. A dynamic_cast is available and is a design signal: once a caller needs to know the concrete type, the interface has stopped doing its job. The switch-to-dispatch refactor of Module 7 is the remedy.
  3. You pay an indirection. Module 7's measurement: one pointer per object, one lookup per call. For a Shape computed once per frame this is nothing; for a per-pixel operation it is the wrong design.
  4. 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

  1. class Base { public: virtual void f() = 0; }; — write the two statements = 0 makes, and say which of them a definition of f would change.
  2. A concrete class derived from Shape fails to compile with cannot declare variable … to be of abstract type. What are the two most likely causes, and which line of the error tells them apart?
  3. 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.
  4. Why is a virtual destructor mandatory in an abstract base and merely good practice in some other hierarchies?
  5. A Printer interface has print(), flush(), scan(), staple() and email(). 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.