Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 2 of 9

Lesson 16.1 ended with a Rectangle reporting an area of 0 through a Shape&. One keyword fixes it.

virtual says: look at what the object actually is, not at what the reference says.

Dispatch that works

#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;                  // lesson 16.3 explains why

    virtual double area() const { return 0.0; }
    virtual std::string describe() const { return "a shape"; }

    // Non-virtual: this one is NOT dispatched dynamically.
    std::string label() const { return "Shape"; }
};

class Circle : public Shape {
    double r_;
public:
    explicit Circle(double r) : r_(r) {}
    double area() const override { return 3.14159 * r_ * r_; }
    std::string describe() const override { return "a circle of radius " + std::to_string(r_); }
    std::string label() const { return "Circle"; }        // hides, does not override
};

class Square : public Shape {
    double s_;
public:
    explicit Square(double s) : s_(s) {}
    double area() const override { return s_ * s_; }
    std::string describe() const override { return "a square of side " + std::to_string(s_); }
    std::string label() const { return "Square"; }
};

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(2.0));
    shapes.push_back(std::make_unique<Square>(3.0));

    double total = 0.0;
    for (const auto& s : shapes) {
        std::cout << s->describe() << " has area " << s->area() << "\n";
        std::cout << "  label() through Shape*: " << s->label() << "  <-- not virtual\n";
        total += s->area();
    }
    std::cout << "total area: " << total << "\n";

    std::cout << "\nobject sizes:\n";
    std::cout << "  sizeof(double):  " << sizeof(double) << "\n";
    std::cout << "  sizeof(Circle):  " << sizeof(Circle) << "  <-- one double plus a vptr\n";

    struct NoVirtual { double d; };
    std::cout << "  sizeof(NoVirtual): " << sizeof(NoVirtual) << "\n";
    return 0;
}
a circle of radius 2.000000 has area 12.5664
  label() through Shape*: Shape  <-- not virtual
a square of side 3.000000 has area 9
  label() through Shape*: Shape  <-- not virtual
total area: 21.5664

object sizes:
  sizeof(double):  8
  sizeof(Circle):  16  <-- one double plus a vptr
  sizeof(NoVirtual): 8

area() and describe() dispatched correctly; the loop over std::unique_ptr<Shape> called Circle::area and Square::area without knowing which was which. That is polymorphism, and it is the point of the whole module.

label() did not. It is not virtual, so the call went to Shape::label for both — exactly lesson 16.1's result. A derived class declaring a non-virtual function with the same name does not override it; it hides it, and which one runs depends on the static type at the call.

How it works: the vtable

The size comparison gives it away. Circle holds one double — 8 bytes — and sizeof(Circle) is 16. The extra 8 bytes are a vptr: a hidden pointer to the class's virtual function table.

Circle object                vtable for Circle
+---------------+            +---------------------------+
| vptr          | ---------> | ~Circle()                 |
+---------------+            | Circle::area()            |
| double r_     |            | Circle::describe()        |
+---------------+            +---------------------------+

Square object                vtable for Square
+---------------+            +---------------------------+
| vptr          | ---------> | ~Square()                 |
+---------------+            | Square::area()            |
| double s_     |            | Square::describe()        |
+---------------+            +---------------------------+

A call to s->area() compiles to roughly: load the vptr from the object, load the function pointer at the area slot, call it. The slot index is fixed at compile time; only the table differs per class.

Two consequences follow directly:

  • One vptr per object, not per virtual function. A class with twenty virtual functions costs the same 8 bytes as one with a single one.
  • One vtable per class, shared by every instance. The table itself is not per-object cost.

What it costs, measured

#include <chrono>
#include <iostream>
#include <memory>
#include <random>
#include <vector>

template <typename F>
long long time_us(F f) {
    auto t0 = std::chrono::steady_clock::now();
    f();
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
}

struct Base {
    virtual ~Base() = default;
    virtual int compute(int x) const = 0;
};

struct Doubler : Base {
    int compute(int x) const override { return x * 2; }
};

struct Plain {
    int compute(int x) const { return x * 2; }
};

int main() {
    const int N = 20000000;

    // Real data, so neither loop can be folded into a closed form.
    std::vector<int> data(N);
    std::mt19937 gen(42);
    for (int& v : data) v = static_cast<int>(gen() % 1000);

    std::unique_ptr<Base> poly = std::make_unique<Doubler>();
    Plain plain;

    long long sink = 0;
    auto t_virtual = time_us([&] {
        long long s = 0;
        for (int v : data) s += poly->compute(v);
        sink += s;
    });
    auto t_direct = time_us([&] {
        long long s = 0;
        for (int v : data) s += plain.compute(v);
        sink += s;
    });

    std::cout << N << " calls over real data:\n";
    std::cout << "  virtual (cannot inline): " << t_virtual << " us\n";
    std::cout << "  direct  (inlined):       " << t_direct << " us\n";
    std::cout << "(sink " << sink << ")\n";
    return 0;
}

A representative run, compiled -O2:

20000000 calls over real data:
  virtual (cannot inline): 15627 us
  direct  (inlined):       9052 us
(sink 39956727008)

1.7× slower over 20 million calls, for a function body that is one multiply.

The important part is why, and it is not the indirect jump. Modern CPUs predict indirect branches well. The cost is that the compiler cannot inline through a virtual call, because it does not know at compile time which function will run. The direct version's compute was inlined into the loop and then vectorised; the virtual version was a real function call, 20 million times.

So the overhead scales with how trivial the function is:

  • A one-line getter called in a tight loop: the virtual version can be several times slower.
  • A function doing real work: the dispatch is lost in the noise.

Which gives the practical rule: do not make trivial accessors virtual, and do not worry about virtual dispatch for anything substantial.

An earlier draft of this benchmark summed i * 2 over a counted loop, and the direct version measured 0 µs — -O2 had proved the whole loop was a closed-form sum and deleted it. Summing over real data is what makes the comparison honest. That is lesson 13.5's warning arriving in a different lesson: when a measurement reports zero, suspect the optimiser.

override — use it every time

// class Circle : public Shape {
//     double area() override { ... }     // ERROR: Shape::area is const
// };
//
// error: 'double Circle::area()' marked 'override', but does not override

Without override, that function would silently become a new, non-overriding member — and every call through a Shape* would keep using the base version. The program compiles, runs, and is wrong.

override turns that into a compile error. It costs one word. Lesson 16.5 covers it and final properly; the habit starts now.

Calling the base version deliberately

// std::string Circle::describe() const {
//     return Shape::describe() + ", specifically a circle";
//     //     ^^^^^^^^^^^^^^^^^ explicit qualification disables virtual dispatch
// }

Qualifying with Base:: calls that exact function. Useful for extending rather than replacing behaviour — and it is the only way, since an unqualified call would recurse infinitely.

When dispatch does not happen

Three cases catch people:

In a constructor or destructor. While Shape's constructor runs, the object is not yet a Circle — the vptr still points at Shape's vtable. A virtual call from a base constructor calls the base version:

// Shape::Shape() {
//     std::cout << describe();     // calls Shape::describe, never Circle::describe
// }

This is not a bug in the language; it is the only safe answer, because Circle's members do not exist yet. But it means the "call a virtual function to initialise" pattern from other languages does not work in C++.

Through a value, rather than a reference or pointer. A Shape by value is a Shape — there is nothing else it could be. That is lesson 16.6's slicing.

When the type is statically known. Circle c; c.area(); compiles to a direct call; the compiler can see exactly which function that is and will devirtualise it.

Try it

Build a plugin-style hierarchy: an abstract Filter with apply(text), and three implementations (uppercase, reverse, truncate). Chain them over a vector of unique_ptr<Filter>. Then measure sizeof each filter with and without any virtual functions, and explain the difference.

Checkpoint

  • virtual dispatches on the object's dynamic type, not the reference's static type.
  • A non-virtual function with the same name in a derived class hides rather than overrides.
  • Each polymorphic object carries a vptr — measured: 8 bytes, one per object regardless of how many virtual functions exist.
  • One vtable exists per class, shared by all instances.
  • Measured cost: 1.7× over 20 million calls — and the cost is lost inlining, not the jump.
  • Do not make trivial accessors virtual; do not worry about dispatch for substantial work.
  • Always write override — without it a signature mismatch silently creates a new function.
  • Base::f() calls a specific version, bypassing dispatch; use it to extend rather than replace.
  • Virtual calls in a constructor or destructor call the base version — the derived part does not exist yet.
  • An empty polymorphic class costs a full pointer, where an empty non-polymorphic one costs 1 byte.
  • A benchmark reporting 0 µs has been deleted by the optimiser — measure over real data.

Questions about this lesson

Log in to ask a question.