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
virtualdispatches 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.