Skip to content

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& names PlainVehicle::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:

  plainBike.dailyRate()   = 60000   <- the motorbike's own function

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:

error: cannot derive from 'final' base 'LastLogger' in derived type 'EvenBetterLogger'

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:

  1. Value types. A Money, a Date, a Temperature — small, copied freely, no hierarchy. Adding a virtual function gives them a hidden pointer, makes them unsortable by memcmp-style tricks nobody should use anyway, and invites slicing.
  2. 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.
  3. 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. sizeof and a profiler are the evidence, not intuition.
  4. A class you will delete through 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.

  1. Base declares virtual void f() const; and Derived declares void f();. Compile it with override on the derived function and state what the compiler says, in the compiler's own words.
  2. A std::vector<Base> holds Derived objects pushed with push_back. How many Derived parts does the vector contain, and what does sizeof each element tell you?
  3. 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?
  4. Why does banner() — a non-virtual member calling a virtual one — dispatch correctly inside Base, while the same call written in Base's constructor does not?
  5. 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.