Module 7 — 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-07/, each folder with its own
expected-output.txt. Build any of them with:
Example 1 — the same hierarchy, twice, and one keyword apart [L3 Modify]¶
The problem: a fleet bills by daily rate, and the rates differ by vehicle. Write
the hierarchy twice — once without virtual, once with — and find out exactly what
changes.
Before you read on, predict: plainMotorbike.dailyRate() is called directly, and
then the same object is passed to long long bill(const PlainVehicle&). Do the two
calls print the same number? And what does a fleet total over one car and one
motorbike, three days each, come to in each version?
// examples/module-07/01-virtual-or-not/main.cpp
class PlainVehicle {
public:
PlainVehicle(std::string plate, int days) : plate_(std::move(plate)), days_(days) {}
virtual ~PlainVehicle() = default; // see programme 02 for why this one is virtual
long long dailyRate() const { return 200000; } // ← NOT virtual
};
class PlainMotorbike : public PlainVehicle {
public:
long long dailyRate() const { return 60000; } // ← hides, does not override
};
Output (excerpt):
Calling on the real type — both hierarchies agree:
plainBike.dailyRate() = 60000 <- the motorbike's own function
virtualBike.dailyRate() = 60000 <- the motorbike's own function
The same two objects, reached through a base reference:
plainBike through PlainVehicle& = 200000 <- WRONG. Static type decides: PlainVehicle::dailyRate
virtualBike through VirtualVehicle& = 60000 <- the object's actual type decides
A fleet of one car and one motorbike, three days each:
non-virtual total = 1200000 (expected 780000)
virtual total = 780000 (expected 780000)
Why it behaves that way: a call on a named object knows its exact type, so even
the non-virtual version runs the motorbike's function — "it works on my machine" is
easily mistaken for correctness. The moment the same object is reached through a base
reference, the compiler has only the reference's type to work with, and with no
virtual it binds statically. 1200000 versus 780000 is the size of that mistake over
two vehicles and three days; the invoice function multiplies it, because it calls
dailyRate() internally.
What to take away: virtual does not change what a call on the real type does. It
changes what a call through a base pointer or reference does — and therefore it decides
the behaviour of every loop anyone writes later.
Example 2 — the destructor that decides whether cleanup happens [L3 Modify]¶
The problem: a factory returns Base* owning a Derived that allocated a buffer.
Find out whether delete basePointer; frees it.
Before you read on, predict: the program counts live buffers before new, after
new, and after delete, first with a non-virtual destructor and then with a virtual
one. Give both counts — and say how many destructors run in each case.
// examples/module-07/02-virtual-destructor/main.cpp
Base* p = new Derived; // Derived allocates 4096 bytes
delete p; // which destructor(s) run?
Output (excerpt):
1. `delete` through a base pointer, non-virtual destructor:
after new: buffers alive = 1, bytes outstanding = 4096
~Base ran
after delete: buffers alive = 1, bytes outstanding = 4096
Only ~Base ran. 4096 bytes are still held, and nothing in the
program will ever free them: the pointer that knew where they were
is gone.
This is undefined behaviour. What you just saw is this compiler's
behaviour on this run, not a guarantee …
2. The same code with `virtual ~VirtualBase()`:
~VirtualDerived ran (buffer freed)
~VirtualBase ran
after delete: buffers alive = 0, bytes outstanding = 0
Why it behaves that way: delete on a base pointer with a non-virtual destructor
is undefined behaviour — the standard says so, which is why the program refuses to
promise a symptom. What is always true is the second half: with a virtual destructor,
the most-derived destructor runs first and the whole chain unwinds, exactly as the
Module 6 trace predicted.
What to take away: a virtual destructor is not a nicety for classes that "might" be
deleted polymorphically; it is the default for any base class, written as
virtual ~Base() = default;. And note the third option the program names: Module 11's
smart pointers remove the failure mode instead of asking you to remember the keyword.
Example 3 — override and final [L2 Apply]¶
The problem: a logging hierarchy where one call site reaches four implementations, and where a one-character signature mistake would be invisible without help.
Before you read on, predict: name() in the base is std::string name() const.
A derived class writes std::string Name() const override; — capital N. Does this
compile, and if not, exactly what does the compiler say?
// examples/module-07/03-override-and-final/main.cpp
std::string name() const override; // matches: this overrides
std::string Name() const override; // different name: error
std::string name() override; // not const: error
void log(const char* level, const std::string& m) override; // error
Output (excerpt):
One call site, four implementations:
upper -> [info] INVOICE GENERATED
tagged:billing -> [info] billing | invoice generated
last -> <info> invoice generated (from a final class)
logger -> [info] invoice generated
An override REPLACES the base function …:
TaggedLogger kept the base's work by calling Logger::log explicitly:
[warn] billing | payment declined
UpperCaseLogger did not, so `[warn]` survives only because it wrote
the brackets itself. An override that forgets the base call is a
silent behaviour change, which is why `override` is not optional:
[warn] PAYMENT DECLINED
`final` on a class — this would not compile:
error: cannot derive from 'final' base 'LastLogger' in derived type
'EvenBetterLogger'
`final` on one function — SealedChild may override policy() but:
error: virtual function 'virtual std::string SealedChild::auditLine()
const' overriding final function
note: overridden function is 'virtual std::string Sealed::auditLine()
const'
Why it behaves that way: override is a compile-time check with no runtime cost.
Without it, the four wrong signatures above compile, hide the base's function, and
leave the base's behaviour running through every base pointer — which is the same
failure mode as Example 1, produced by a capital letter. final is the reverse
mechanism: instead of asking the compiler to check your claim, you forbid a claim
anyone else might make. The final-function/flexible-callee pattern is the interesting
one: auditLine() cannot be replaced, but policy() inside it can.
What to take away: write override on every function you intend to override, and
final where a promise must hold. Both move a silent mistake into the compiler's field
of view, which is the only place mistakes are cheap.
Example 4 — slicing, measured [L4 Analyze]¶
The problem: the same Manager object reaches the same base type three ways —
by reference, by pointer, by value — and one of them loses data.
Before you read on, predict: sizeof(Manager) is printed as 80 and
sizeof(Person) as 40. What does the by-value call print, and what does a
std::vector<Person> containing two employees — one of them a manager — print?
Then predict the two sizeof numbers for the vector.
// examples/module-07/04-slicing/main.cpp
class Person {
public:
explicit Person(std::string name) : name_(std::move(name)) {}
virtual ~Person() = default;
virtual std::string label() const { return "person: " + name_; }
};
void byValue(Person person); // ← the parameter is an object, not a reference
void byRef(const Person& person);
void byPointer(const Person* person);
Output (excerpt):
byRef -> manager of Data Science (7 reports)
byPointer -> manager of Data Science (7 reports)
byValue -> person: Ayesha Khan [sizeof 40]
The byValue line is not a bug in `label()`: the parameter really is
a Person. `byValue` constructed one, so the virtual call had only a
Person to dispatch to, and the team and the report count were never
copied. Note the sizeof numbers above: the copy is the smaller one.
std::vector<Person> (2 elements, 80 bytes):
person: Ayesha Khan
person: Bilal Ahmed
Both lines say "person: ". The manager's label was lost at
push_back, and nothing in the program can recover it.
std::vector<const Person*> (8 bytes per element):
manager of Data Science (7 reports)
Why it behaves that way: a copy constructs an object of the static type. The
virtual function is not broken — it had nothing to dispatch to, because the derived
part was never copied. The sizeof numbers make the loss concrete: 40 bytes copied
from an 80-byte object, and 80 bytes of vector holding two objects instead of 16 bytes
holding two pointers.
What to take away: const Person&, Person*, and containers of pointers. And a
second, subtler point: the vector of pointers is correct and introduces a lifetime
question, because a pointer may outlive what it points at. That question is Module 11's.
Example 5 — a virtual call at four points in one object's life [L4 Analyze]¶
The problem: the same virtual call, made from a constructor, a destructor and a normal member function, gives four different answers.
Before you read on, predict: Base() calls describe(), Derived() calls it too,
and ~Base() calls it. Write the four lines — which class's describe() runs in each?
// examples/module-07/05-virtual-calls-in-constructors/main.cpp
class Base {
public:
Base() { std::cout << " Base() calls describe() -> " << describe() << '\n'; }
virtual ~Base() { std::cout << " ~Base() calls describe() -> " << describe() << '\n'; }
virtual std::string describe() const { return "base (nothing more is known yet)"; }
std::string banner() const { return "banner: " + describe(); } // safe: fully built
};
class Derived : public Base {
public:
Derived() { std::cout << " Derived() calls describe() -> " << describe() << '\n'; }
~Derived() override { std::cout << " ~Derived() calls describe() -> " << describe() << '\n'; }
std::string describe() const override { return "derived (all my members are ready)"; }
};
Output (excerpt):
Base() calls describe() -> base (nothing more is known yet)
Derived() calls describe() -> derived (all my members are ready)
--- built. Now the same call is a normal virtual call:
banner() -> banner: derived (all my members are ready)
~Derived() calls describe() -> derived (all my members are ready)
~Base() calls describe() -> base (nothing more is known yet)
Why it behaves that way: during Base()'s body the object is a Base — the
Derived members have not been initialised, so dispatching to Derived::describe()
would read half-built state. During ~Base()'s body it is a Base again, for the same
reason in reverse. banner() is the control experiment: an ordinary member function
running on a fully built object dispatches normally. Note that ~Derived() still sees
the derived type, because nothing has been torn down at that point.
What to take away: "the same virtual call, four different answers, decided by where
the object is in its lifetime and not by the type of the pointer." Never call a virtual
function from a constructor or destructor; if a base constructor needs derived data,
take it as an argument; if it needs derived behaviour, the work belongs in an
initialise() step or a factory.
Example 6 — the refactor this module exists for [L5 Design]¶
The problem: one shape hierarchy, written first as a tagged struct with a switch
in every operation, then as a base class with a virtual area(). Same numbers, twice.
Before you read on, predict: how many places must change to add a pentagon to
version 1? Count them before reading the answer, and predict what sizeof(Shape)
becomes in version 2.
// examples/module-07/06-switch-to-dispatch/main.cpp
// Version 1: struct TaggedShape { Kind kind; double a, b; }; area() is a free
// function with a switch.
// Version 2: class Shape { virtual double area() const = 0; … };
Output (excerpt):
Version 1 — one tag, three parallel switches:
circle area 12.57 valid true
A field that means nothing for its kind:
circle with b = 99 is still circle with area 12.57 — `b` is simply ignored by every switch.
sizeof(TaggedShape) = 24 bytes
Version 2 — the same three shapes, the same numbers:
A shape that holds only its own data, and cannot be given a
field that does not apply to it: there is no `b` on a Circle to
set wrongly.
sizeof(Circle) = 16 (two doubles and one hidden pointer)
sizeof(Shape) = 8 (the hidden pointer alone — the vtable pointer)
The change that separates them. Adding a pentagon to version 1 means:
- a new enumerator;
- a new `case` in areaOf();
- a new `case` in nameOf();
- a new `case` in isValid();
- a decision about which field carries the side;
… In version 2 it is one class, written above, and this loop did not change
Why it behaves that way: the two versions compute the same arithmetic. What differs is where change lands. In version 1, adding a kind means editing four places that must agree, plus deciding which of the two generic fields carries the new kind's measurement — and a mistake in any one of them is a wrong answer, not a compile error. In version 2 it is one new class. The cost is 8 bytes per object and one indirection per call, and the program does not pretend otherwise:
One honest caveat. The compiler is not helpless in version 1: because
the tag is an `enum class`, a `switch` that misses a `case` produces
warning: enumeration value 'Pentagon' not handled in switch [-Wswitch]
The warning protects the switches that name the enum; it does not protect the design, the invariant, or the fields that mean nothing for their kind.
What to take away: a refactor preserves output and changes the cost of the next change. That is why the two versions print identical numbers — and why version 2 survives a term of coursework while version 1 does not.
Next: Misconceptions and mistakes — including the two failures that produce no message at all.