Inheritance and virtual functions
Runtime polymorphism: how it works and what it costs.
By the end of this chapter you can
- Explain what a vtable is and what a virtual call costs
- Write a base class that is safe to delete through
- Explain why a non-virtual destructor in a base class is a bug
Sometimes you need to decide at run time which code to call — the shape is a circle or a square, the sensor is a thermometer or a barometer, and the calling code should not have to know. That is runtime polymorphism, and inheritance with virtual functions is how C++ provides it.
It is also the feature most often reached for when something simpler would do, which is the subject of the next chapter. This one is about how it works.
A base class and its overrides
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual ~Shape() = default; // essential — see below
virtual double area() const = 0; // pure virtual: no implementation here
virtual const char* name() const = 0;
};
class Circle : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }
const char* name() const override { return "circle"; }
private:
double radius_;
};
class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
double area() const override { return side_ * side_; }
const char* name() const override { return "square"; }
private:
double side_;
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Square>(2.0));
for (const auto& shape : shapes) {
std::cout << shape->name() << ": " << shape->area() << '\n';
}
}Four keywords carry the design:
virtualon a base function means “the derived class may replace this, and a call through a base pointer must find the replacement”.= 0makes it pure virtual: no implementation, and the class becomes abstract — it cannot be instantiated, only derived from.overrideon the derived function asks the compiler to check that it really is overriding something. Always write it.publicinheritance means “aCircleis aShape” and is what lets aCircle*convert to aShape*.
struct Base {
virtual ~Base() = default;
virtual int value() const { return 1; }
};
struct Derived : Base {
// Missing `const` — this does not override anything.
int value() override { return 2; }
};
int main() { return 0; }Delete the override from that and it compiles, and Derived quietly has two
unrelated value functions. Every call through a Base& gets 1.
How a virtual call works
Each polymorphic class has a vtable: a static array of function pointers, one per virtual function. Each object of that class carries a hidden pointer to its class’s vtable — the vptr. A virtual call loads the vptr, indexes the table, and calls through the pointer it finds.
#include <chrono>
#include <iostream>
#include <memory>
#include <vector>
struct Plain {
int value = 1;
int get() const { return value; }
};
struct Polymorphic {
int value = 1;
virtual ~Polymorphic() = default;
virtual int get() const { return value; }
};
int main() {
std::cout << "sizeof(Plain) = " << sizeof(Plain) << '\n';
std::cout << "sizeof(Polymorphic) = " << sizeof(Polymorphic)
<< " <- the extra bytes are the vptr\n\n";
constexpr int rounds = 20'000'000;
using clock = std::chrono::steady_clock;
using ms = std::chrono::milliseconds;
Plain plain;
std::unique_ptr<Polymorphic> poly = std::make_unique<Polymorphic>();
auto start = clock::now();
long long a = 0;
for (int i = 0; i < rounds; ++i) a += plain.get();
auto mid = clock::now();
long long b = 0;
for (int i = 0; i < rounds; ++i) b += poly->get();
auto finish = clock::now();
std::cout << "direct call: " << std::chrono::duration_cast<ms>(mid - start).count() << " ms\n";
std::cout << "virtual call: " << std::chrono::duration_cast<ms>(finish - mid).count() << " ms\n";
std::cout << "(sums " << a << ", " << b << ")\n";
}Two costs, and it is worth being precise about their size.
Space: every object grows by one pointer. For a class with a dozen members
that is nothing; for a small value type in an array of millions, doubling the
size of each element is a real cost, and it is the reason std::vector<int> has
no virtual functions.
Time: an extra indirection, and — more importantly — the call cannot be inlined when the compiler does not know the dynamic type. That second effect usually dominates: not the load itself, but the optimisations it prevents.
The non-virtual destructor bug
This is the mistake that turns inheritance from a design choice into a memory bug.
#include <iostream>
#include <memory>
#include <vector>
struct Base {
~Base() { std::cout << " Base destroyed\n"; } // NOT virtual
};
struct Derived : Base {
std::vector<int> data = std::vector<int>(1000);
~Derived() { std::cout << " Derived destroyed\n"; }
};
int main() {
std::cout << "deleting a Derived through a Base pointer:\n";
Base* p = new Derived;
delete p; // undefined: ~Derived never runs
std::cout << "note that ~Derived did not run — its vector leaked\n";
}~Derived never ran, so the vector it owned was never freed. The standard calls
this undefined behaviour outright; in practice you get a leak, and with more
complex members you can get worse.
The fix is one word:
#include <iostream>
#include <vector>
struct Base {
virtual ~Base() { std::cout << " Base destroyed\n"; }
};
struct Derived : Base {
std::vector<int> data = std::vector<int>(1000);
~Derived() override { std::cout << " Derived destroyed\n"; }
};
int main() {
Base* p = new Derived;
delete p; // both destructors run, in the right order
std::cout << "no leak\n";
}The rule: if a class has any virtual function, or if anyone might delete a derived object through a pointer to it, give it a virtual destructor.
Slicing
A base class object cannot hold a derived one. Assigning or copying by value silently discards everything the derived class added:
#include <iostream>
struct Shape {
virtual ~Shape() = default;
virtual double area() const { return 0; }
};
struct Square : Shape {
double side;
explicit Square(double s) : side(s) {}
double area() const override { return side * side; }
};
void by_value(Shape s) { std::cout << " by value: " << s.area() << '\n'; }
void by_reference(const Shape& s) { std::cout << " by reference: " << s.area() << '\n'; }
int main() {
Square sq{3.0};
std::cout << "a 3x3 square has area " << sq.area() << "\n\n";
by_value(sq); // sliced: only the Shape part is copied
by_reference(sq); // not sliced: the reference still names a Square
}By value, the Square part is cut away and Shape::area runs — reporting 0 for
a square of area 9, with no warning. Polymorphic types are used through
pointers or references, never by value. That is why the container in the first
sample was vector<unique_ptr<Shape>> and not vector<Shape>.
A common defence is to make the base non-copyable, so slicing becomes a compile error rather than a wrong answer.
Calling the base version
An override may call the function it replaces:
#include <iostream>
#include <string>
struct Logger {
virtual ~Logger() = default;
virtual std::string format(const std::string& message) const {
return "[log] " + message;
}
};
struct TimestampLogger : Logger {
std::string format(const std::string& message) const override {
return "[12:00] " + Logger::format(message); // qualified: not virtual
}
};
int main() {
TimestampLogger logger;
const Logger& base = logger;
std::cout << base.format("started") << '\n';
}Logger::format(...) with the class name is a non-virtual call to that
specific version. Without the qualification it would call itself and recurse
forever.