The problem with virtual dispatch

Virtual functions are the standard C++ mechanism for runtime polymorphism — but they carry a cost:

  • Vtable pointer per object — every instance of a polymorphic class stores a hidden pointer (8 bytes on 64-bit) to its vtable
  • Indirect call — each virtual call loads the vtable pointer, loads the function pointer from the table, then calls through it — 2 pointer dereferences before the first instruction of your function
  • No inlining — the compiler can’t inline through a virtual call in the general case; it can’t see which override will be called

On a Cortex-M4 at 168 MHz, an indirect call costs 3–5 ns — negligible for most code. But for a DSP inner loop calling a processing function at 1 MHz sample rate, 3 ns per call is 0.5% of your CPU budget on that one dispatch.

CRTP eliminates dispatch overhead entirely — the “virtual” call resolves at compile time.


The CRTP pattern

CRTP is a template idiom where a class derives from a template instantiation of itself:

 1template <typename Derived>
 2class Base {
 3public:
 4    void interface() {
 5        // Call derived implementation via static_cast — resolved at compile time
 6        static_cast<Derived*>(this)->implementation();
 7    }
 8};
 9
10class Derived : public Base<Derived> {
11public:
12    void implementation() {
13        // actual logic
14    }
15};

The trick: Base<Derived> knows the concrete type at compile time, so the static_cast is resolved without a vtable lookup.


Zero-cost sensor interface

The classic embedded use case: a sensor interface that the hot path calls per sample.

 1// CRTP base — defines the interface
 2template <typename Sensor>
 3class ISensor {
 4public:
 5    float read() {
 6        return static_cast<Sensor*>(this)->readImpl();
 7    }
 8
 9    bool isReady() {
10        return static_cast<Sensor*>(this)->isReadyImpl();
11    }
12
13protected:
14    ~ISensor() = default;  // prevent deletion through base pointer
15};
16
17// Concrete sensor — implements the interface
18class Bmp280 : public ISensor<Bmp280> {
19    I2C_HandleTypeDef* hi2c_;
20    float lastTemp_ = 0.0f;
21
22public:
23    explicit Bmp280(I2C_HandleTypeDef* hi2c) : hi2c_(hi2c) {}
24
25    float readImpl() {
26        uint8_t raw[3];
27        HAL_I2C_Mem_Read(hi2c_, BMP280_ADDR, REG_TEMP, 1, raw, 3, 10);
28        lastTemp_ = compensate(raw);
29        return lastTemp_;
30    }
31
32    bool isReadyImpl() {
33        return (readStatus() & STATUS_MEASURING) == 0;
34    }
35};
36
37class NtcThermistor : public ISensor<NtcThermistor> {
38    ADC_HandleTypeDef* hadc_;
39public:
40    explicit NtcThermistor(ADC_HandleTypeDef* hadc) : hadc_(hadc) {}
41
42    float readImpl() {
43        HAL_ADC_Start(hadc_);
44        HAL_ADC_PollForConversion(hadc_, 10);
45        uint16_t raw = HAL_ADC_GetValue(hadc_);
46        return adcToTemp(raw);
47    }
48
49    bool isReadyImpl() { return true; }
50};

The pipeline is templated on the sensor:

 1template <typename Sensor>
 2class MeasurementPipeline {
 3    Sensor& sensor_;
 4public:
 5    explicit MeasurementPipeline(Sensor& s) : sensor_(s) {}
 6
 7    float measure() {
 8        if (!sensor_.isReady()) return NAN;
 9        return sensor_.read();   // static dispatch — no vtable, fully inlined
10    }
11};
12
13Bmp280 bmp(hi2c1);
14MeasurementPipeline<Bmp280> pipeline(bmp);
15float temp = pipeline.measure();

The call to sensor_.read() compiles to a direct call (or inlined body) with zero overhead. The compiler sees the full implementation and can apply any optimisation.


Providing default implementations

CRTP lets the base provide default behaviour that derived classes can selectively override:

 1template <typename Derived>
 2class ISensor {
 3public:
 4    float read() {
 5        return static_cast<Derived*>(this)->readImpl();
 6    }
 7
 8    // Default: sensor is always ready — override if the sensor has a ready flag
 9    bool isReadyImpl() { return true; }
10
11    // No default for readImpl — derived must provide it
12    // (Can't make it pure virtual in CRTP, but can use static_assert)
13};

This is the CRTP version of a “non-pure virtual with default”: the derived class can override isReadyImpl or inherit the default.


Mixin composition

CRTP enables mixins — behaviour injected via base class, composable without multiple inheritance of concrete implementations.

 1// Mixin: adds logging to any class
 2template <typename Derived>
 3class LoggableMixin {
 4public:
 5    void log(std::string_view msg) {
 6        auto& self = static_cast<Derived&>(*this);
 7        uart_printf("[%s] %s\r\n", self.name(), msg.data());
 8    }
 9};
10
11// Mixin: adds enable/disable toggling
12template <typename Derived>
13class ToggleableMixin {
14    bool enabled_ = true;
15public:
16    void enable()  { enabled_ = true; }
17    void disable() { enabled_ = false; }
18    bool isEnabled() const { return enabled_; }
19};
20
21// Concrete class — composes mixins
22class FanController
23    : public LoggableMixin<FanController>
24    , public ToggleableMixin<FanController>
25{
26public:
27    const char* name() const { return "FAN"; }
28
29    void setSpeed(uint8_t percent) {
30        if (!isEnabled()) { log("disabled, ignoring setSpeed"); return; }
31        log("setSpeed");
32        pwm_set(TIM3, TIM_CHANNEL_1, percent);
33    }
34};
35
36FanController fan;
37fan.setSpeed(75);   // logs "[FAN] setSpeed" and sets PWM
38fan.disable();
39fan.setSpeed(50);   // logs "[FAN] disabled, ignoring setSpeed"

Each mixin adds behaviour without touching the others. Adding a third mixin (e.g., DiagnosableMixin) requires no changes to FanController’s existing code — just add a base class.


CRTP for static interface enforcement

You can’t make CRTP functions pure virtual, but you can use static_assert to give a compile-time error if the derived class forgets to implement the interface:

 1template <typename Derived>
 2class IProcessor {
 3public:
 4    float process(float sample) {
 5        // Verify that Derived has processImpl at instantiation time
 6        static_assert(
 7            requires(Derived d, float f) { d.processImpl(f); },
 8            "Derived must implement processImpl(float)"
 9        );
10        return static_cast<Derived*>(this)->processImpl(sample);
11    }
12};

Or without C++20 concepts, use decltype in the base:

1template <typename Derived>
2class IProcessor {
3public:
4    float process(float sample) {
5        return static_cast<Derived*>(this)->processImpl(sample);
6    }
7    // Will fail to compile if processImpl doesn't exist — error at the call site
8};

CRTP vs virtual — comparison

Property Virtual CRTP
Dispatch overhead vtable indirect call None (direct/inlined)
Inlining Not possible in general Fully inlinable
Runtime polymorphism Yes No
Heterogeneous collection vector<Base*> Not directly
Object size overhead +8 bytes (vptr) None
Compile time Fast Slower (templates)
Error messages Clear Can be cryptic
Mixins Multiple inheritance of interfaces Clean mixin pattern

Choose virtual when: you need runtime polymorphism — strategies selectable at runtime, heterogeneous collections, plugin architectures.

Choose CRTP when: the concrete type is always known at compile time, performance in tight loops matters, or you’re composing behaviour via mixins.


Heterogeneous collections with CRTP

CRTP can’t go into a vector<ISensor*> directly (each ISensor<T> is a different type). If you need runtime heterogeneity, add a type-erasing wrapper:

 1// Thin virtual wrapper — pays virtual cost only at the collection boundary
 2class SensorHandle {
 3public:
 4    virtual float read() = 0;
 5    virtual ~SensorHandle() = default;
 6};
 7
 8template <typename Sensor>
 9class SensorWrapper : public SensorHandle {
10    Sensor& sensor_;
11public:
12    explicit SensorWrapper(Sensor& s) : sensor_(s) {}
13    float read() override { return sensor_.read(); }  // CRTP dispatch inside
14};
15
16Bmp280 bmp(hi2c1);
17NtcThermistor ntc(hadc1);
18SensorWrapper<Bmp280>        bmpHandle(bmp);
19SensorWrapper<NtcThermistor> ntcHandle(ntc);
20
21std::array<SensorHandle*, 2> sensors = {&bmpHandle, &ntcHandle};
22for (auto* s : sensors) s->read();  // one virtual call, then CRTP inside

This pattern (type-erasing wrapper over a CRTP object) pays virtual cost once per call from outside the system, while internal processing remains dispatch-free.


Summary

  • CRTP derives a class from Base<Derived>, giving the base access to the concrete type
  • static_cast<Derived*>(this)->method() resolves at compile time — no vtable, fully inlined
  • Default implementations in the base let derived classes selectively override
  • Mixins via CRTP compose orthogonal behaviours without concrete multiple inheritance
  • CRTP doesn’t support runtime heterogeneity — use a thin virtual wrapper at the boundary if needed

What’s next