Проблема с виртуальным диспетчингом
Виртуальные функции — стандартный механизм 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 не поддерживает гетерогенность времени выполнения — используйте тонкую виртуальную обёртку на границе при необходимости
Что дальше
- Паттерн «стратегия» — объекты политик vs шаблоны — CRTP как подход к стратегии времени компиляции
- Стирание типов без виртуальных функций — устройство std::function и самодельное стирание типов
- Проектирование на основе интерфейсов — виртуальные интерфейсы и когда они — правильный выбор