Lesson 5 of 9
Lesson 16.2 mentioned override in passing. It deserves a lesson, because the bug it prevents is
invisible: a function that looks like an override, compiles cleanly, and is never called.
What they do
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Handler {
public:
virtual ~Handler() = default;
virtual void process(const std::string& msg) const = 0;
virtual int priority() const { return 0; }
};
class Logger : public Handler {
public:
void process(const std::string& msg) const override {
std::cout << " [log] " << msg << "\n";
}
int priority() const override { return 5; }
};
// `final` on a class: nothing may derive from it.
class Alerter final : public Handler {
public:
void process(const std::string& msg) const override {
std::cout << " [ALERT] " << msg << "\n";
}
// `final` on a function: no further class may override it.
int priority() const final { return 10; }
};
int main() {
std::vector<std::unique_ptr<Handler>> handlers;
handlers.push_back(std::make_unique<Logger>());
handlers.push_back(std::make_unique<Alerter>());
for (const auto& h : handlers) {
std::cout << "priority " << h->priority() << ":\n";
h->process("disk usage at 91%");
}
return 0;
}
priority 5:
[log] disk usage at 91%
priority 10:
[ALERT] disk usage at 91%
override says "this is meant to replace a base virtual function". If it does not, the compiler
rejects it.
final says "no further derivation". On a class, nothing may inherit from it. On a virtual
function, no further class may override that function.
The bug override prevents
// struct Base {
// virtual ~Base() = default;
// virtual void f(int) const {}
// virtual void g() const {}
// };
//
// struct A : Base { void f(double) const override {} }; // wrong parameter type
// struct B : Base { void g() {} }; // missing const, no override
// struct Sealed final : Base { void g() const final {} };
// struct C : Sealed { }; // cannot derive from final
Three different diagnostics:
error: 'void A::f(double) const' marked 'override', but does not override
warning: 'virtual void Base::f(int) const' was hidden [-Woverloaded-virtual=]
warning: 'virtual void Base::g() const' was hidden [-Woverloaded-virtual=]
error: cannot derive from 'final' base 'Sealed' in derived type 'C'
Compare the two failures carefully, because the difference is the whole argument.
A::f(double) is marked override and is therefore an error. The build stops and names the
function.
B::g() forgot const and was not marked override, so it is only a warning — and
-Woverloaded-virtual is not in -Wall or -Wextra by default. In a normal build this compiles
silently, B gets a brand-new non-virtual g(), and every call through a Base* keeps running
Base::g. The program is wrong and nothing said so.
That is the case override exists for. Write it on every overriding function, without
exception. It costs one word and converts the module's most common silent bug into a compile error.
The signature has to match exactly: parameter types, const, ref-qualifiers, and return type
(except for covariant returns). Every one of those is a chance to get it subtly wrong.
final and the performance claim
final is usually justified two ways: as documentation of design intent, and as an enabler of
devirtualization — the compiler proving which function will run and inlining it.
The first is real. The second did not survive measurement:
#include <chrono>
#include <iostream>
#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 f(int x) const = 0; };
struct Open : Base { int f(int x) const override { return x * 2; } };
struct Sealed final : Base { int f(int x) const override { return x * 2; } };
int main() {
const int N = 20000000;
std::vector<int> data(N);
std::mt19937 gen(42);
for (int& v : data) v = static_cast<int>(gen() % 1000);
Open open;
Sealed sealed;
long long sink = 0;
// The static type is known here, so `final` lets the compiler prove
// which function will run and inline it.
auto t_open = time_us([&] {
long long s = 0;
for (int v : data) s += open.f(v);
sink += s;
});
auto t_sealed = time_us([&] {
long long s = 0;
for (int v : data) s += sealed.f(v);
sink += s;
});
// Through a base reference, neither can be devirtualised.
const Base& ref_open = open;
const Base& ref_sealed = sealed;
auto t_ref_open = time_us([&] {
long long s = 0;
for (int v : data) s += ref_open.f(v);
sink += s;
});
auto t_ref_sealed = time_us([&] {
long long s = 0;
for (int v : data) s += ref_sealed.f(v);
sink += s;
});
std::cout << "through the concrete type:\n";
std::cout << " non-final: " << t_open << " us\n";
std::cout << " final: " << t_sealed << " us\n";
std::cout << "through a Base&:\n";
std::cout << " non-final: " << t_ref_open << " us\n";
std::cout << " final: " << t_ref_sealed << " us\n";
std::cout << "(sink " << sink << ")\n";
return 0;
}
Three consecutive runs, compiled -O2:
through the concrete type: non-final 7709 us final 7389 us
through a Base&: non-final 19891 us final 25805 us
through the concrete type: non-final 7522 us final 7503 us
through a Base&: non-final 19857 us final 25643 us
through the concrete type: non-final 7582 us final 7501 us
through a Base&: non-final 20179 us final 25674 us
final produced no benefit in either configuration.
Through the concrete type both versions took about 7.5 ms, because the compiler already knew the
exact type — open.f(v) has a static type of Open, so there was nothing left for final to
prove. Devirtualization happened with or without it.
Through a Base& both were slower, and the final version was consistently slower still — 25.7 ms
against 20.0 ms, reproducibly. That is the wrong direction for a supposed optimisation, which is the
signal that it is a code-layout artifact rather than a dispatch effect. A const Base& could refer
to an Open just as easily as a Sealed; marking one derived class final tells the compiler
nothing about what the reference points at.
So the honest position:
Use
finalto express design intent, not to make code faster. Devirtualization throughfinalrequires the compiler to know the static type is the final class — and when it knows that, it usually did not needfinalanyway.
The cases where final genuinely helps are whole-program optimisation and link-time optimisation,
where the compiler can see there is only one implementation in the entire program. That is a
different mechanism, and it is not something a microbenchmark in one translation unit will show.
When to use final
On a class, when deriving from it would break an invariant. A class with a non-virtual
destructor that is nonetheless meant to be copied around is a good candidate — final makes the
dangerous inheritance impossible rather than merely unwise.
On a function, when further overriding would break the class's contract. A Handler whose
priority() is fixed by regulation, say.
Not by default. final removes the ability of future code to extend yours. That is sometimes
correct and sometimes a decision you will regret in two years. Unlike override, it is not a
free win.
Covariant return types
The one signature difference override permits:
// struct Shape {
// virtual Shape* clone() const = 0;
// };
//
// struct Circle : Shape {
// Circle* clone() const override { return new Circle(*this); }
// // ^^^^^^ narrower return type — legal, and useful
// };
//
// Circle* c = /* ... */;
// Circle* copy = c->clone(); // no cast needed
The return type may be a pointer or reference to a class derived from the base's return type.
This is why clone() idioms work without casting.
It does not extend to smart pointers: std::unique_ptr<Circle> is not derived from
std::unique_ptr<Shape>, so a covariant clone returning unique_ptr does not compile. The usual
workaround is a private virtual returning a raw pointer, wrapped by a public non-virtual returning
unique_ptr.
Try it
Write a base class with four virtual functions and deliberately break the override contract four
different ways in derived classes — wrong parameter type, wrong constness, wrong return type, and
a typo in the name. Add override to each and record what the compiler says. Then mark one class
final and try to derive from it.
Checkpoint
- Write
overrideon every overriding function. It is one word and it catches silent bugs. - A mismatched signature without
overridecreates a new function; calls keep using the base. - The
constmismatch is the common one, and-Woverloaded-virtualis not in-Wall. - A conflicting return type is an error with or without
override. finalon a class forbids derivation; on a function, forbids further overriding.- Measured:
finalgave no devirtualization benefit in a single translation unit, and ran consistently slower through a base reference — a layout artifact, not a dispatch effect. - Use
finalfor design intent; devirtualization needs the static type to be the final class. finalremoves future extensibility, so it is not a free win the wayoverrideis.- Covariant returns let an override return a more derived pointer or reference.
- Covariance does not work through
std::unique_ptr; wrap a raw-pointer virtual instead.
Questions about this lesson
Log in to ask a question.