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
- Strategy pattern — policy objects vs templates — CRTP as one approach to compile-time strategy
- Type erasure without virtual — std::function internals and hand-rolled type erasure
- Interface-based design — virtual interfaces and when they’re the right call