Skip to content

Module 8 — Worked Examples

Every example asks you to predict before it shows you the output, because the prediction is the exercise and the output is only the marking scheme. Where a program prints more than is useful here, an excerpt is marked with ….

The programs are in examples/module-08/, each folder with its own expected-output.txt. Build any of them with:

g++ -std=c++17 -Wall -Wextra main.cpp -o program && ./program

Example 1 — a contract, and the loop it makes possible [L4 Analyze]

The problem: three shapes, and two functions — totalArea and largest — that must keep working when a fourth shape is added next term.

Before you read on, predict: describe(), totalArea() and largest() are written against Shape, which has no data and no implementations. When the program creates a second collection of different shapes and calls the same functions, how many of those three functions must change?

// examples/module-08/01-shape-abstraction/main.cpp
class Shape {
public:
    virtual ~Shape() = default;              // mandatory, and §5 says why

    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());
    }
};

Output (excerpt):

Three shapes, one loop:
  circle  area 12.57  perimeter 12.57
  rectangle  area 12.00  perimeter 14.00
  right triangle  area 6.00  perimeter 12.00

Functions written against the contract, not the classes:
  totalArea() = 30.57
  largest()   = circle  area 12.57  perimeter 12.57

Now the same four lines, unchanged, over a set of shapes that did
not exist when those functions were written:
  totalArea() = 7.14

Four things this program would not work without:
  Shape shape;                   error: cannot declare variable 'shape'
                                 to be of abstract type 'Shape'
  virtual ~Shape() = default;    deleting through Shape* would leak
  `override` on all three        a typo would hide the pure virtual, and
                                 the derived class would still be abstract
                                 — with the error pointing at the wrong line

Why it behaves that way: the contract is enforced by the compiler, not by convention. A subclass that forgets area() does not "fall back" to anything; it stays abstract, and the failure appears where someone tries to instantiate it. describe() needs no dispatch of its own because it calls the virtual functions — the base class owns the algorithm and the subclasses supply the variation. That is the arrangement §3 of Concepts calls a non-virtual interface.

What to take away: one interface, four implementations, zero changes to the callers. This is the ability that Modules 15 and 16 are built on — and the program is explicit that the abstraction did not solve ownership: the alternative version needs a delete loop, and "Module 11: std::vector<std::unique_ptr<Shape>> shapes;" is where that becomes safe.


Example 2 — an interface with no data, and a caller that knows nothing [L4 Analyze]

The problem: notifications by email and SMS, with channel-specific rules (an address is well-formed; an SMS has a length limit), and a service that must not know about either.

Before you read on, predict: the service also checks that the message is not empty, and one of the three implementations in the program records messages instead of sending them. Which of those three implementations has to be written differently to be used as a test double — the recorder, the service, or the interface?

// examples/module-08/02-interface-contracts/main.cpp
class Notifier {                    // no data members, no constructor, one function
public:
    virtual ~Notifier() = default;
    virtual bool send(const std::string& recipient, const std::string& message) = 0;
};

class NotificationService {
public:
    explicit NotificationService(Notifier& notifier) : notifier_(notifier) {}
    bool announce(const std::string& to, const std::string& message) { … }
private:
    Notifier& notifier_;            // a reference: the service does not own the channel
};

Output (excerpt):

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 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

And what the interface costs: `Notifier` cannot be instantiated
  Notifier notifier;      error: cannot declare variable 'notifier' to be
                          of abstract type 'Notifier'
which is the point. There is no such thing as a message that is sent by
no channel, so there should be no way to write one.

Why it behaves that way: the two rules live in two places, deliberately. The empty-message rule is the service's, so it applies to every channel including ones nobody has written; the address format and the length limit are the channel's, because only the channel knows them. The recorder needs no special treatment at all — it implements the same one function and becomes a channel. That is the practical payoff of the whole idea: a test double is just another implementation, which is why Modules 14 and 16 can test code that would otherwise require a network.

What to take away: the interface is one function because that is the capability the caller needs. Every function added here would be a promise every future channel must keep — and §6 of Concepts is about what happens when one of them cannot.


Example 3 — a contract that includes failure [L5 Design]

The problem: a checkout that must try several payment methods in order, decide by itself which can handle the amount, and report why nothing worked when nothing does.

Before you read on, predict: a Rs 900 000 bill is attempted through wallet → card → cash. The wallet has a balance and the card has a limit. Which methods are asked, in what order, and what does the checkout print if the cash method is removed from the list?

// examples/module-08/03-payment-methods/main.cpp
struct PaymentResult {                  // a value type: it is copied, not derived from
    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;
};

Output (excerpt):

A checkout that tries wallet, then card, then cash:

  bill 900000.00
      tried wallet: cannot handle 900000.00
      tried card: cannot handle 900000.00
      tried cash: collected 900000.00
    PAID via CASH-1 — collected 900000.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.

Why it behaves that way: canHandle moves each method's rule into the method, so the checkout asks rather than guesses — and a fourth method needs no change to the checkout. The refusal path is designed, not incidental: because failure carries a message, the final REFUSED line can say what each method said, and a caller can act on it. The program's own closing paragraph names the whole benefit in one sentence:

What you would have to do to add a bank transfer tomorrow:
  write one class implementing three functions, add one line to the
  vector in main, and change no existing file.

What to take away: three functions, each of which the caller genuinely needs — a name, a predicate, an attempt. Note that the alternative designs (a bare bool pay(), a switch on a method-type tag, an exception per refusal) all lose something specific: the reason, the extensibility, or the ability to ask before attempting.


Example 4 — the two abstractions side by side [L4 Analyze]

The problem: one class that must both be a kind of report and be printable. Which abstraction for which role?

Before you read on, predict: Report has id_ and a non-virtual render(); Printable has one pure virtual oneLine() and nothing else. A class derives from both. How many base subobjects does it contain, and why is the answer not "two"?

// examples/module-08/04-abstract-base-and-interface/main.cpp
class Report {                              // state + partial implementation
public:
    explicit Report(int id) : id_(id) {}
    virtual ~Report() = default;
    std::string render() const;             // written once; calls the virtuals below
protected:
    virtual std::string title() const = 0;
    virtual std::vector<Row> rows() const = 0;
private:
    int id_;
};

class Printable {                           // no state, all pure virtual
public:
    virtual ~Printable() = default;
    virtual std::string oneLine() const = 0;
};

Output (excerpt):

The abstract base owns the layout; the subclasses own the content:
REPORT 101: Attendance — OOP using C++
------------------------------------------------
  Ayesha   present
…
totals: 4 present, 2 absent

The interface answers one question, and nothing else:
  attendance for OOP using C++: 4/6 present

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.

Two details visible in the code above, worth naming:
  - `render()` is not virtual, and does not need to be. It is correct
    for every subclass, because it calls the virtuals;
  - the pure virtual extension points are `protected`, so the public
    interface of a Report is: render(), id(), and nothing to override
    by accident.

Why it behaves that way: an abstract base class with state and an interface with none are two different tools, and the program's own summary is the decision rule — state + partial algorithm versus a capability and nothing else. The reason the class has no diamond is that only Report carries state; interfaces carry none, so several of them cannot duplicate anything.

What to take away: the two roles stay separable because each is expressed as its own abstraction, and callers depend on the smallest one they need. printOnOneLine() would work on an invoice next term with no change — which is the test of whether an abstraction was worth writing.


Example 5 — a pure virtual function that still has a body [L3 Modify]

The problem: two exporters, almost identical output, with a shared footer that is too small to justify another class.

Before you read on, predict: footer() is declared = 0 and given a definition outside the class. Does Exporter become instantiable? Can the CSV exporter call the base's footer()?

// 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
    virtual std::string footer() const = 0;      // pure, WITH a definition below
    std::string exportLines(const std::vector<Row>& rows) const;   // written once
};

std::string Exporter::footer() const {
    return "(footnote: generated by the base class's default footer)";
}

Output (excerpt):

csv:
,,,,
  Ayesha,90
,,,,
rows exported by csv
(footnote: generated by the base class's default footer)

text:
----------------------------
  Ayesha,90
----------------------------
end of text export

Each subclass supplied three functions; `exportLines` was written once,
in the base, and neither subclass repeats it.

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.

Why it behaves that way: abstractness is decided by the declaration = 0, not by whether a body exists. The body is simply an implementation that a subclass may reach by qualification — Exporter::footer() — and that the compiler will never call virtually, because there is no way to have an Exporter to call it on.

What to take away: the feature is real and narrow. The program's own limits are the right ones to remember — worth it when two or three subclasses differ in one statement and the shared part is too small for a class; not worth it when nobody calls the body, in which case it is dead code with a confusing signature. The one mandatory case is a pure virtual destructor, which must be defined because it always runs; omitting it produces a link error, not a compile error, and on some toolchains the message does not even name the symbol.


Next: Misconceptions and mistakes — the errors that mark an abstraction's boundaries, and the one that arrives at link time.