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