The C++ Textbook

Part 5 · Generic programming

Static polymorphism

Getting the shape of inheritance without the runtime cost.

By the end of this chapter you can

  • Implement CRTP and explain what it replaces
  • Compare the cost of virtual dispatch with a template
  • Decide when runtime polymorphism is the right answer anyway

Part 3 gave you one way to say “these types share an interface”: a base class with virtual functions. The call goes through a pointer stored in the object, and which function runs is decided while the program is running.

Templates give you another way. The interface is checked at compile time, the call goes straight to the function, and there is no base class in the object at all. This chapter is about when each is right — and the answer is not “always the fast one”.

Two ways to call the same thing
#include <iostream>

struct Dynamic {
    virtual ~Dynamic() = default;
    virtual int scale(int x) const = 0;
};

struct DynamicDouble : Dynamic {
    int scale(int x) const override { return x * 2; }
};

struct StaticDouble {
    int scale(int x) const { return x * 2; }
};

template <class T>
int apply(const T& op, int x) { return op.scale(x); }

int main() {
    DynamicDouble d;
    const Dynamic& base = d;
    StaticDouble s;

    std::cout << apply(base, 21) << ' ' << apply(s, 21) << '\n';
}

Both print 42, and — press Assembly — both compile to movl $42. Neither call survives at all.

That is worth pausing on, because it contradicts the usual story. base refers to a DynamicDouble that was constructed three lines earlier and never left main, so the compiler knows its dynamic type exactly. Knowing that, it replaces the virtual call with a direct one and inlines it, the same as for StaticDouble. A vtable is not a cost the compiler is obliged to pay.

It becomes one as soon as the compiler loses sight of the dynamic type — which happens the moment the reference crosses a function boundary the optimiser cannot see through.

The cost of a virtual call, examined

“Virtual calls are slow” is folklore worth pinning down. The direct cost is one extra load and an indirect branch, which a modern branch predictor usually gets right. The real cost is that the compiler cannot inline through it — and everything the inliner would have done next does not happen either.

The same loop, dispatched two ways
#include <iostream>
#include <memory>

struct Op {
    virtual ~Op() = default;
    virtual int apply(int x) const = 0;
};
struct AddOne : Op {
    int apply(int x) const override { return x + 1; }
};

struct StaticAddOne {
    int apply(int x) const { return x + 1; }
};

template <class F>
long long sum_applied(const F& op, int rounds) {
    long long total = 0;
    for (int i = 0; i < rounds; ++i) total += op.apply(i);
    return total;
}

long long virtual_total(const Op& op)          { return sum_applied(op, 1000); }
long long direct_total(const StaticAddOne& op) { return sum_applied(op, 1000); }

int main() {
    std::unique_ptr<Op> dynamic_op = std::make_unique<AddOne>();
    StaticAddOne static_op;
    std::cout << virtual_total(*dynamic_op) << ' ' << direct_total(static_op) << '\n';
}

Both print 500500. Press Assembly and find the two functions. On GCC at -O2, direct_total is two instructions:

movl  $500500, %eax
ret

The loop is gone. apply was inlined, the body became total += i + 1, and a thousand iterations of a known recurrence folded into one constant at compile time.

virtual_total is thirty-odd instructions with a real loop in the middle. GCC does something clever there — it loads the address of AddOne::apply, compares the vtable entry against it, and runs an inlined fast path when they match. That is speculative devirtualization, and it is the compiler guessing. It still has to keep the loop, and it still has to keep the indirect call for the case where the guess is wrong, because a const Op& could refer to anything.

CRTP: a base class that knows its derived type

The interesting case is when you want what inheritance gives you — shared code in a base, customised in a derived — without the indirection. The trick is to tell the base which derived class it belongs to, as a template argument.

The curiously recurring template pattern
#include <iostream>
#include <sstream>
#include <string>

template <class Derived>
class Formattable {
public:
    // Written once, in terms of a `write` the derived class supplies.
    // The call is direct: no virtual, no vtable.
    std::string to_string() const {
        std::ostringstream out;
        self().write(out);
        return out.str();
    }

    std::string boxed() const {
        std::string body = to_string();
        return "[" + body + "]";
    }

private:
    const Derived& self() const { return static_cast<const Derived&>(*this); }
};

class Version : public Formattable<Version> {
public:
    Version(int major, int minor) : major_(major), minor_(minor) {}
    void write(std::ostream& os) const { os << major_ << '.' << minor_; }

private:
    int major_, minor_;
};

class Celsius : public Formattable<Celsius> {
public:
    explicit Celsius(double degrees) : degrees_(degrees) {}
    void write(std::ostream& os) const { os << degrees_ << " \u00b0C"; }

private:
    double degrees_;
};

int main() {
    Version v{1, 10};
    Celsius t{21.5};

    std::cout << v.boxed() << ' ' << t.boxed() << '\n';
    std::cout << "sizeof(Version): " << sizeof(Version) << " bytes\n";
}

class Version : public Formattable<Version> is the curious recurrence: the base is parameterised on the class that derives from it. That is legal because the base only needs Version to be complete when its members are instantiated — which happens when they are called, long after the class is finished.

boxed() is the payoff. It is written once, calls to_string(), which calls self().write(out), which is Version::write — resolved at compile time and inlinable all the way down. A virtual write would have stopped the inliner at the first step.

Look at the size. Version is 8 bytes: two ints and nothing else. An empty base class contributes no storage, and there is no vtable pointer because there are no virtual functions. The same interface built with a virtual compare would be 16 bytes on a 64-bit machine — a vtable pointer, two ints, and padding.

Making the mistake impossible
#include <iostream>

template <class Derived>
class Counted {
public:
    static int live() { return live_; }
    int id() const { return id_; }

private:
    friend Derived;                 // only Derived may construct the base
    Counted() : id_(++next_) { ++live_; }
    ~Counted() { --live_; }

    int id_;
    static inline int next_ = 0;
    static inline int live_ = 0;
};

class Session : public Counted<Session> {};
class Job     : public Counted<Job> {};

int main() {
    Session a, b;
    Job j;
    std::cout << "sessions live: " << Session::live() << '\n';
    std::cout << "jobs live:     " << Job::live() << '\n';
    std::cout << "second session id: " << b.id() << ", first job id: " << j.id() << '\n';
    (void)a;
}

The private constructor plus friend Derived is the standard belt-and-braces CRTP guard. It also demonstrates the second thing CRTP is used for: each instantiation of Counted<T> has its own statics, so Session and Job get independent counters from one piece of code.

Concepts do the interface checking

CRTP’s weakness used to be error messages: pass the wrong type and you got a failure deep inside the base. Concepts fix that, and they also let you write static polymorphism with no base class at all.

An interface with no inheritance
#include <concepts>
#include <iostream>
#include <string>

template <class T>
concept Shape = requires(const T& s) {
    { s.area() } -> std::convertible_to<double>;
    { s.name() } -> std::convertible_to<std::string>;
};

struct Circle {
    double r;
    double area() const { return 3.14159265 * r * r; }
    std::string name() const { return "circle"; }
};

struct Square {
    double side;
    double area() const { return side * side; }
    std::string name() const { return "square"; }
};

void report(const Shape auto& s) {
    std::cout << s.name() << " has area " << s.area() << '\n';
}

int main() {
    report(Circle{1.0});
    report(Square{2.0});
}

Circle and Square share no base class, no header, and no knowledge of each other. They satisfy Shape by having the right members, which is what people mean by duck typing — except that it is checked, and the check happens before the program runs.

So when is virtual still right?

Every time the set of types is not known when the code is compiled.

What a template cannot do
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Widget {
    virtual ~Widget() = default;
    virtual std::string render() const = 0;
};

struct Button : Widget { std::string render() const override { return "[button]"; } };
struct Label  : Widget { std::string render() const override { return "label"; } };
struct Spacer : Widget { std::string render() const override { return "   "; } };

int main() {
    // One container, three different types, chosen at run time.
    std::vector<std::unique_ptr<Widget>> layout;
    layout.push_back(std::make_unique<Label>());
    layout.push_back(std::make_unique<Spacer>());
    layout.push_back(std::make_unique<Button>());

    for (const auto& w : layout) std::cout << w->render();
    std::cout << '\n';
}

A std::vector<T> holds one type. This vector holds three, and which three is decided by code that runs — read from a config file, chosen by a user, loaded from a plugin. No amount of if constexpr gets you there, because the decision is not available to the compiler.

The dividing line, then:

Use a template when Use virtual when
the type is known at the call site the type is chosen at run time
you want inlining and zero overhead you need one container of mixed types
the interface is checked, not shared you want a stable ABI across a library boundary
all code is available as source implementations ship separately, as plugins
compile time is not the constraint compile time and code size matter

That last row is the cost nobody mentions. A template instantiates a fresh copy of its code for every type it is used with. Ten types means ten copies in the binary and ten times the compilation work. Virtual dispatch compiles once and links once, and there is a size at which that stops being a fair trade.

Check yourself

Practice