Skip to content

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:

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

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.