Module 7 — Concepts and Explanations¶
Read this with a compiler open. Three of the rules on this page are traps that produce no diagnostic at all, and the only way to believe them is to run them.
Module 6 ended with a program that printed the wrong salary and a comment saying "Module 7 is the fix". This is that module. It is also where C++ differs most sharply from most languages: the choice of how a call is dispatched belongs to the programmer, and the consequences of getting it wrong are silent.
1. "My loop prints the base class's number, and nothing complains"¶
The fleet bills by daily rate. A motorbike costs 60 000 paisa a day, a car 200 000.
examples/module-07/01-virtual-or-not/main.cpp writes the same tiny hierarchy
twice — once without virtual, once with — and makes four calls:
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
The sharper statement is not "the base version is used because the function is not
virtual". It is:
The function called is decided by the static type of the expression — the type the compiler sees.
const PlainVehicle&namesPlainVehicle::dailyRate, and the fact that the object behind the reference is a motorbike is not consulted.
That is static binding, and it is the default. Dynamic binding — the object's
actual type deciding — requires two things together: the function must be declared
virtual, and the call must go through a reference or a pointer to the base.
The wrong rate does not stay local. The program's invoice function uses dailyRate()
internally:
plain even calling invoice() on the motorbike object:
LEB-2002 3 days 600000 paisa <- invoice() is not virtual, so it
calls the base rate
virtual:
LEB-2002 3 days 180000 paisa <- correct
A fleet of one car and one motorbike, three days each:
non-virtual total = 1200000 (expected 780000)
virtual total = 780000 (expected 780000)
The decision is made in the base class, once, by whoever writes it — and it silently
decides the behaviour of every loop written later. That is why the choice is a design
decision rather than a detail: adding virtual to a base function changes what a large
amount of unseen code does, and leaving it off does the same.
2. "Which calls are virtual, and which are not?"¶
Three separate questions, and all three must be answered before the compiler emits a lookup.
Is the function declared virtual? Declaring it in the base is enough; every
override is virtual automatically, whether it says so or not. This is why the course
writes override rather than virtual in derived classes — see §4.
Is the call made through a reference or a pointer? A virtual call on a named object is dispatched statically, because the compiler knows the exact type. The program's first line shows this and it is not an optimisation artefact:
Does the signature match exactly? Name, parameter types, const-ness and return
type. A difference in any of them creates a new function that hides the base's —
Module 6's rule, now with teeth, because the hidden base function is still what runs
through a base reference.
class Logger {
public:
virtual ~Logger() = default;
virtual std::string name() const { return "logger"; }
protected:
// The default behaviour: record the line.
virtual void log(const std::string& level, const std::string& message) { … }
};
class WrongConst : public Logger {
public:
std::string name() override { return "not const"; } // const missing
};
Compiling that produces, on this toolchain:
error: 'std::string WrongConst::name()' marked 'override', but does not override
warning: 'virtual std::string Logger::name() const' was hidden [-Woverloaded-virtual=]
note: by 'std::string WrongConst::name()'
The warning: line is the one to learn to read. It is the compiler saying "this looks
like a mistake, and I am telling you because the derived function now hides a virtual
one". In this course -Werror is on, so it is a build failure.
3. "How does the object know at run time?"¶
Conceptually: one table of function pointers per class, and one hidden pointer per object, written into the object by each constructor as it runs. Two consequences you can measure:
sizeof(TaggedShape) = 24 bytes ← a struct with a tag, no dispatch
sizeof(Shape) = 8 (the hidden pointer alone — the vtable pointer)
sizeof(Circle) = 16 (two doubles and one hidden pointer)
Every polymorphic object carries a pointer's worth of overhead, and every virtual call
costs an indirection that cannot be inlined in the general case. That is the price.
It is almost always worth paying — a couple of nanoseconds against the cost of every
switch in your program needing to be updated together — but it is not free, and a
function called once per pixel is not the place for it.
One thing to say precisely, because assertions here are usually wrong: a virtual function table is a typical implementation, not a language guarantee. The standard specifies behaviour, not layout; the sizes above are this compiler's output on this machine, and a different ABI may differ. What the standard does guarantee is that a derived object deleted through a base pointer with a non-virtual destructor is undefined behaviour — which is §4.
4. "What happens if I delete through a base pointer?"¶
If the base destructor is not virtual, only ~Base() runs.
examples/module-07/02-virtual-destructor/main.cpp measures it with counters:
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. …
2. The same code with `virtual ~VirtualBase()`:
~VirtualDerived ran (buffer freed)
~VirtualBase ran
after delete: buffers alive = 0, bytes outstanding = 0
Note what the program does not claim: it does not say "the derived destructor is skipped every time". It says this is undefined behaviour, and what you just saw is this compiler's behaviour on this run — another compiler may crash instead. A rule whose failure mode varies by compiler needs to be prevented, not detected.
The rule to write down:
Any class with a virtual function that may be deleted through a base pointer needs a virtual destructor. The safe default is
virtual ~Base() = default;on every base class in a hierarchy, whether or not it has other virtual functions yet.
Three ways to be safe, in the order the program recommends: the = default; virtual
destructor, a protected non-virtual destructor (which makes delete p illegal, so the
mistake cannot be written), or — Module 11 — not owning raw resources at all, which
removes the failure mode instead of remembering a keyword.
5. "override and final: two keywords that shrink the silent surface"¶
override asks the compiler to check a claim you are making. It generates no code. If
the function is not in fact overriding anything, the program does not compile:
error: 'std::string WrongName::Name() const' marked 'override', but does not override
error: 'void WrongParam::log(const char*)' marked 'override', but does not override
Two rules the program demonstrates that override does not give you:
An override replaces the base implementation; it does not extend it. To keep the base's work, say so:
void log(const std::string& level, const std::string& message) override {
Logger::log(level, "billing | " + message); // the base's behaviour, then ours
}
The program prints both versions so the loss is visible:
TaggedLogger kept the base's work by calling Logger::log explicitly:
[warn] billing | payment declined
UpperCaseLogger did not … An override that forgets the base call is a
silent behaviour change …
[warn] PAYMENT DECLINED
final refuses rather than documents. On a class:
and on a single function:
error: virtual function 'virtual std::string SealedChild::auditLine() const' overriding final function
note: overridden function is 'virtual std::string Sealed::auditLine() const'
The second is more useful than it sounds. The program shows a final function that
calls a non-final virtual one: auditLine() cannot be replaced, but the policy()
it calls can — so the audit line is fixed in form and variable in substance. That is
how a base class honours a contract without freezing the behaviour inside it, and it is
the seed of the template-method pattern in
Module 15.
6. "My derived object arrived in the function and lost its data"¶
Object slicing, and the rule is one line:
A copy constructs a new object of the static type. Whatever the object used to be, the copy is only that static type.
examples/module-07/04-slicing/main.cpp puts the same Manager through the same base
type three ways:
manager.label() -> manager of Data Science (7 reports)
sizeof(Manager) -> 80
sizeof(Person) -> 40
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.
The same thing happens in a container, and this is where it does the most damage
because the loss is distributed across every push_back:
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)
Three rules prevent every case: parameters are references or pointers
(const Person&, Person*), containers hold pointers or references, not objects,
and if a polymorphic copy is genuinely needed, that is a deliberate design — a
virtual clone() implemented by each derived class — never the implicit copy
constructor. Module 10 explains why the copy constructor cannot be polymorphic, and
Module 11 supplies the ownership that makes containers of pointers manageable.
7. "Why did my virtual call in the constructor not dispatch?"¶
Because during a base constructor the derived part does not exist yet, and during a
base destructor it no longer does. examples/module-07/05-virtual-calls-in-constructors/main.cpp
prints the same virtual call at four points in one object's life:
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)
Four answers to one virtual call, decided by when rather than by what. The rule to remember is the one the program states: while a base constructor or destructor runs, the object is not (or is no longer) the derived type. Compliance is silent and consistent, which is why this is a bug people ship rather than fix.
If the function called is pure virtual, the call is undefined behaviour — covered in §8 and in Module 8. Two practical consequences, both from the program's own advice:
- if a base constructor needs derived data, take it as a constructor argument — the derived class can supply it in its member-initialiser list;
- if a base constructor needs derived behaviour, that is a design signal: the work
belongs in a separate
initialise()step, or in a factory function (Module 15) that constructs the object and then configures it.
8. "How do I get rid of this switch?"¶
Write the same program twice and compare, which is what
examples/module-07/06-switch-to-dispatch/main.cpp does. Version 1 is a tagged struct
with fields for every shape and a switch in each operation; version 2 is a base class
with a virtual area().
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 identity of the output is the point: a refactor changes the design and not the behaviour. What changes is the cost of the next change:
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;
and every one of those is in a different place … In version 2 it is one class,
written above, and this loop did not change
The program is honest about the case against writing this off as old-fashioned:
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]
That warning protects the switches that name the enum. It does not protect the design: the parallel switches still have to agree, and the invariant ("a circle has no second leg") still lives in a comment.
9. "When should I not use virtual?"¶
Four situations, and each is a real design decision rather than an optimisation:
- Value types. A
Money, aDate, aTemperature— small, copied freely, no hierarchy. Adding a virtual function gives them a hidden pointer, makes them unsortable bymemcmp-style tricks nobody should use anyway, and invites slicing. - A function that will never vary. Marking every member
virtual"because flexibility" makes every call in the class a dispatch. Virtual means I expect this to be replaced; that expectation should be true. - A hot loop. If a call is made millions of times per second and may not inline,
measure before you add the keyword — and measure after.
sizeofand a profiler are the evidence, not intuition. - A class you will
deletethrough a base pointer without a virtual destructor. Not "avoid virtual"; avoid polymorphism without the destructor rule of §4.
The last one to recognise is the pure virtual function — virtual double area() const = 0; —
which makes a class uninstantiable and the contract explicit. That is Module 8, and it
is the natural destination for a hierarchy whose base is only an interface.
Checkpoint¶
Answer these before moving on. Each is testable — write the program.
Basedeclaresvirtual void f() const;andDeriveddeclaresvoid f();. Compile it withoverrideon the derived function and state what the compiler says, in the compiler's own words.- A
std::vector<Base>holdsDerivedobjects pushed withpush_back. How manyDerivedparts does the vector contain, and what doessizeofeach element tell you? delete basePointer;where~Base()is not virtual and~Derived()frees a buffer. What happens, what does the standard say, and what are the three fixes?- Why does
banner()— a non-virtual member calling a virtual one — dispatch correctly insideBase, while the same call written inBase's constructor does not? - Name the two conditions that must both hold for a call to be dynamically bound, and the one case where a call on a named object is still statically bound even when the function is virtual.
Next: Worked examples — six programs, including the one
that measures a leak and the one that measures slicing with sizeof.