Проблема с виртуальным диспетчингом

Виртуальные функции — стандартный механизм C++ для полиморфизма времени выполнения, но они несут цену:

  • Указатель на vtable в каждом объекте — каждый экземпляр полиморфного класса хранит скрытый указатель (8 байт на 64-битной платформе) на его vtable
  • Косвенный вызов — каждый виртуальный вызов загружает указатель на vtable, загружает указатель на функцию из таблицы, затем вызывает через него — 2 разыменования указателя до первой инструкции функции
  • Нет инлайнинга — компилятор не может встроить код через виртуальный вызов в общем случае; он не знает, какой override будет вызван

На Cortex-M4 на 168 МГц косвенный вызов стоит 3–5 нс — незначительно для большинства кода. Но для внутреннего цикла DSP, вызывающего функцию обработки на частоте 1 МГц выборок, 3 нс на вызов — это 0.5% вашего CPU-бюджета только на один диспетчинг.

CRTP полностью устраняет накладные расходы на диспетчинг — «виртуальный» вызов разрешается во время компиляции.


Паттерн CRTP

CRTP — это шаблонная идиома, где класс наследует от шаблонной инстанциации самого себя:

 1template <typename Derived>
 2class Base {
 3public:
 4    void interface() {
 5        // Вызов реализации derived через static_cast — разрешается во время компиляции
 6        static_cast<Derived*>(this)->implementation();
 7    }
 8};
 9
10class Derived : public Base<Derived> {
11public:
12    void implementation() {
13        // фактическая логика
14    }
15};

Хитрость: Base<Derived> знает конкретный тип во время компиляции, поэтому static_cast разрешается без обращения к vtable.


Интерфейс сенсора без накладных расходов

Классический встраиваемый use-case: интерфейс сенсора, который горячий путь вызывает на каждую выборку.

 1// CRTP-база — определяет интерфейс
 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;  // запрет удаления через указатель на базу
15};
16
17// Конкретный сенсор — реализует интерфейс
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};

Конвейер шаблонизирован по сенсору:

 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();   // статический диспетчинг — нет vtable, полный инлайнинг
10    }
11};
12
13Bmp280 bmp(hi2c1);
14MeasurementPipeline<Bmp280> pipeline(bmp);
15float temp = pipeline.measure();

Вызов sensor_.read() компилируется в прямой вызов (или встроенное тело) с нулевыми накладными расходами. Компилятор видит полную реализацию и может применить любую оптимизацию.


Реализации по умолчанию

CRTP позволяет базе предоставлять поведение по умолчанию, которое производные классы могут избирательно переопределять:

 1template <typename Derived>
 2class ISensor {
 3public:
 4    float read() {
 5        return static_cast<Derived*>(this)->readImpl();
 6    }
 7
 8    // По умолчанию: сенсор всегда готов — переопределите, если у сенсора есть флаг готовности
 9    bool isReadyImpl() { return true; }
10
11    // Нет реализации по умолчанию для readImpl — производный обязан предоставить
12    // (Нельзя сделать чисто виртуальным в CRTP, но можно использовать static_assert)
13};

Это CRTP-аналог «невиртуальной функции с умолчанием»: производный класс может переопределить isReadyImpl или унаследовать умолчание.


Компоновка примесей

CRTP обеспечивает примеси (mixins) — поведение, внедряемое через базовый класс, компонуемое без множественного наследования конкретных реализаций.

 1// Примесь: добавляет логирование в любой класс
 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// Примесь: добавляет переключение включить/выключить
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// Конкретный класс — компонует примеси
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("отключён, игнорируем setSpeed"); return; }
31        log("setSpeed");
32        pwm_set(TIM3, TIM_CHANNEL_1, percent);
33    }
34};
35
36FanController fan;
37fan.setSpeed(75);   // логирует "[FAN] setSpeed" и устанавливает ШИМ
38fan.disable();
39fan.setSpeed(50);   // логирует "[FAN] отключён, игнорируем setSpeed"

Каждая примесь добавляет поведение, не затрагивая другие. Добавление третьей примеси (например, DiagnosableMixin) не требует изменений существующего кода FanController — просто добавьте базовый класс.


CRTP для проверки статического интерфейса

Нельзя сделать CRTP-функции чисто виртуальными, но можно использовать static_assert для ошибки времени компиляции, если производный класс забыл реализовать интерфейс:

 1template <typename Derived>
 2class IProcessor {
 3public:
 4    float process(float sample) {
 5        // Проверить наличие processImpl в Derived при инстанциации
 6        static_assert(
 7            requires(Derived d, float f) { d.processImpl(f); },
 8            "Derived должен реализовать processImpl(float)"
 9        );
10        return static_cast<Derived*>(this)->processImpl(sample);
11    }
12};

Или без концептов C++20, через decltype в базе:

1template <typename Derived>
2class IProcessor {
3public:
4    float process(float sample) {
5        return static_cast<Derived*>(this)->processImpl(sample);
6    }
7    // Не скомпилируется, если processImpl не существует — ошибка на месте вызова
8};

CRTP vs виртуальные функции — сравнение

Свойство Виртуальные CRTP
Накладные расходы на диспетчинг Косвенный вызов через vtable Нет (прямой/инлайн)
Инлайнинг Невозможен в общем случае Полный инлайнинг
Полиморфизм времени выполнения Да Нет
Гетерогенная коллекция vector<Base*> Не напрямую
Накладные расходы по размеру +8 байт (vptr) Нет
Время компиляции Быстро Медленнее (шаблоны)
Сообщения об ошибках Понятные Могут быть криптическими
Примеси Множественное наследование интерфейсов Чистый паттерн примесей

Выбирайте виртуальные, когда: нужен полиморфизм времени выполнения — стратегии, выбираемые во время выполнения, гетерогенные коллекции, плагинные архитектуры.

Выбирайте CRTP, когда: конкретный тип всегда известен во время компиляции, производительность в узких циклах важна, или вы компонуете поведение через примеси.


Гетерогенные коллекции с CRTP

CRTP нельзя поместить в vector<ISensor*> напрямую (каждый ISensor<T> — это отдельный тип). Если нужна гетерогенность времени выполнения, добавьте стирающую типы обёртку:

 1// Тонкая виртуальная обёртка — платит цену виртуального вызова только на границе коллекции
 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-диспетчинг внутри
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();  // один виртуальный вызов, затем CRTP внутри

Этот паттерн (стирающая типы обёртка над CRTP-объектом) платит виртуальную цену один раз на вызов снаружи системы, тогда как внутренняя обработка остаётся без диспетчинга.


Итоги

  • CRTP: класс наследует от Base<Derived>, давая базе доступ к конкретному типу
  • static_cast<Derived*>(this)->method() разрешается во время компиляции — нет vtable, полный инлайнинг
  • Реализации по умолчанию в базе позволяют производным избирательно переопределять
  • Примеси через CRTP компонуют ортогональные поведения без конкретного множественного наследования
  • CRTP не поддерживает гетерогенность времени выполнения — используйте тонкую виртуальную обёртку на границе при необходимости

Что дальше