Что решает проектирование на основе политик
Классу часто нужно несколько независимых осей настройки: как выделять память, как логировать, потокобезопасен ли он, какой примитив синхронизации использует. Наивные подходы:
- Параметр шаблона для каждой оси — множество специализаций.
- Флаги и switch во время выполнения — накладные расходы на каждом вызове.
- Виртуальные базовые классы — стоимость vtable, принудительное выделение в куче.
Проектирование на основе политик (популяризованное Андреем Александреску в Modern C++ Design) заменяет все три подхода на классы политик как параметры шаблона. Каждая политика инкапсулирует одну ось поведения. Хост-класс компонует их с нулевыми накладными расходами во время выполнения.
Минимальный пример — логгер с тремя политиками
1// Политика 1: куда писать
2struct StdoutOutput {
3 static void write(std::string_view msg) { std::fputs(msg.data(), stdout); }
4};
5
6struct NullOutput {
7 static void write(std::string_view) {} // отбросить — используется в тестах или релизе
8};
9
10struct UartOutput {
11 static void write(std::string_view msg) {
12 HAL_UART_Transmit(&huart1,
13 reinterpret_cast<const uint8_t*>(msg.data()),
14 msg.size(), HAL_MAX_DELAY);
15 }
16};
17
18// Политика 2: форматирование
19struct PlainFormatter {
20 static std::string format(std::string_view level, std::string_view msg) {
21 return std::string(level) + ": " + std::string(msg) + "\n";
22 }
23};
24
25struct TimestampFormatter {
26 static std::string format(std::string_view level, std::string_view msg) {
27 return "[" + std::to_string(HAL_GetTick()) + "] "
28 + std::string(level) + ": " + std::string(msg) + "\n";
29 }
30};
31
32// Политика 3: потокобезопасность
33struct SingleThreaded {
34 struct Lock { Lock() {} }; // заглушка
35};
36
37struct MutexLocked {
38 struct Lock {
39 explicit Lock() { mtx_.lock(); }
40 ~Lock() { mtx_.unlock(); }
41 private:
42 static std::mutex mtx_;
43 };
44};
45
46// Хост-класс — собирает три независимые политики
47template <
48 typename Output = StdoutOutput,
49 typename Formatter = PlainFormatter,
50 typename Threading = SingleThreaded
51>
52class Logger {
53public:
54 void info (std::string_view msg) { log("INFO", msg); }
55 void warn (std::string_view msg) { log("WARN", msg); }
56 void error(std::string_view msg) { log("ERROR", msg); }
57
58private:
59 void log(std::string_view level, std::string_view msg) {
60 typename Threading::Lock guard;
61 Output::write(Formatter::format(level, msg));
62 }
63};
Использование — выбирайте любую комбинацию:
1// Релизная прошивка — нет вывода, нет накладных расходов
2using FirmwareLogger = Logger<NullOutput>;
3
4// Разработка — метки времени в UART
5using DebugLogger = Logger<UartOutput, TimestampFormatter>;
6
7// Юнит-тесты на рабочей станции — потокобезопасный, stdout
8using TestLogger = Logger<StdoutOutput, PlainFormatter, MutexLocked>;
9
10FirmwareLogger log;
11log.info("Калибровка АЦП завершена"); // компилируется в ничто — NullOutput устранён
Компилятор встраивает все три политики. NullOutput::write — заглушка — оптимизатор
удаляет её полностью. Никакой vtable, никаких ветвлений, никаких накладных расходов.
Политики как базовые классы — стиль Александреску
Александреску предлагал наследовать хост-класс от своих политик. Это позволяет политикам добавлять членов-данных и методы в хост:
1template <
2 typename StoragePolicy,
3 typename LoggingPolicy
4>
5class DataRecorder : public StoragePolicy, public LoggingPolicy {
6public:
7 void record(float value) {
8 LoggingPolicy::log("record", value); // политика добавляет метод log()
9 StoragePolicy::store(value); // политика добавляет метод store()
10 }
11};
Политики без данных используют оптимизацию пустого базового класса — они занимают ноль байт при использовании как базовые классы, в отличие от хранения как члены.
1struct NoLogging {
2 void log(std::string_view, float) {} // пустая база, нулевой размер в производном
3};
4
5struct FlashStorage {
6 void store(float v) { flash_write(addr_++, v); }
7private:
8 uint32_t addr_ = FLASH_BASE;
9};
10
11struct RamStorage {
12 void store(float v) { buffer_.push_back(v); }
13private:
14 std::vector<float> buffer_;
15};
16
17// FlashStorage + NoLogging — двоичный код как будто написан вручную
18DataRecorder<FlashStorage, NoLogging> recorder;
Ограничение политик с концептами C++20
Без ограничений отсутствующий метод в политике производит загадочную ошибку внутри инстанциации шаблона. Концепты превращают это в понятную ошибку в месте использования:
1template <typename T>
2concept OutputPolicy = requires(std::string_view msg) {
3 { T::write(msg) } -> std::same_as<void>;
4};
5
6template <typename T>
7concept FormatterPolicy = requires(std::string_view level, std::string_view msg) {
8 { T::format(level, msg) } -> std::convertible_to<std::string>;
9};
10
11template <typename T>
12concept ThreadingPolicy = requires {
13 typename T::Lock; // должен иметь тип Lock
14};
15
16template <
17 OutputPolicy Output = StdoutOutput,
18 FormatterPolicy Formatter = PlainFormatter,
19 ThreadingPolicy Threading = SingleThreaded
20>
21class Logger { /* то же тело, что выше */ };
Теперь плохая политика даёт сбой в месте использования:
1struct BadOutput { void write(std::string_view) {} }; // не статический
2Logger<BadOutput> log;
3// ошибка: 'BadOutput' не удовлетворяет 'OutputPolicy'
4// примечание: 'BadOutput::write(msg)' не удовлетворяет T::write(msg)
Аккумулятор — политики, разделяющие состояние через хост
Более сложный пример: конвейер, читающий сенсор, опционально фильтрующий, опционально калибрующий и выводящий результат. Каждый этап — политика.
1template <typename T>
2concept FilterPolicy = requires(T f, float v) {
3 { f.filter(v) } -> std::convertible_to<float>;
4};
5
6template <typename T>
7concept CalibrationPolicy = requires(T c, float v) {
8 { c.calibrate(v) } -> std::convertible_to<float>;
9};
10
11// Политики
12struct PassthroughFilter {
13 float filter(float v) { return v; }
14};
15
16struct MovingAverageFilter {
17 float filter(float v) {
18 buf_[idx_++ % N] = v;
19 float sum = 0;
20 for (float x : buf_) sum += x;
21 return sum / N;
22 }
23private:
24 static constexpr size_t N = 8;
25 float buf_[N] = {};
26 size_t idx_ = 0;
27};
28
29struct NoCalibration {
30 float calibrate(float v) { return v; }
31};
32
33struct LinearCalibration {
34 explicit LinearCalibration(float gain, float offset)
35 : gain_(gain), offset_(offset) {}
36 float calibrate(float v) { return v * gain_ + offset_; }
37private:
38 float gain_, offset_;
39};
40
41// Хост — объединяет сенсор + фильтр + калибровку во время компиляции
42template <
43 FilterPolicy Filter = PassthroughFilter,
44 CalibrationPolicy Calibration = NoCalibration
45>
46class SensorPipeline {
47 Filter filter_;
48 Calibration cal_;
49
50public:
51 explicit SensorPipeline(Calibration cal = {}) : cal_(std::move(cal)) {}
52
53 float process(float raw) {
54 return cal_.calibrate(filter_.filter(raw));
55 }
56};
Компоновка в месте использования:
1// Производственная прошивка — без накладных расходов, сквозной фильтр, нет калибровки
2SensorPipeline<> pipeline;
3
4// Тестовый стенд — скользящее среднее из 8 отсчётов, линейная калибровка
5SensorPipeline<MovingAverageFilter, LinearCalibration>
6 pipeline(LinearCalibration{0.00805f, -40.0f}); // таблица NTC
7
8float value = pipeline.process(adc.read());
Производственная версия компилируется в два сложения с плавающей точкой: ничего. Тестовый стенд хранит буфер из 8 элементов и применяет умножение-сложение — никаких ветвлений.
Псевдонимы типов для именованных конфигураций
Именование комбинаций политик с using предотвращает повторения в местах использования:
1namespace Profiles {
2
3using EmbeddedRelease = Logger<
4 NullOutput,
5 PlainFormatter,
6 SingleThreaded
7>;
8
9using EmbeddedDebug = Logger<
10 UartOutput,
11 TimestampFormatter,
12 SingleThreaded
13>;
14
15using DesktopTest = Logger<
16 StdoutOutput,
17 TimestampFormatter,
18 MutexLocked
19>;
20
21} // namespace Profiles
22
23Profiles::EmbeddedDebug log;
24log.warn("Переполнение DMA");
Переключение всего поведения логгера — изменение одной строки в псевдониме типа.
Выбор политики во время компиляции с if constexpr
Когда поведение политики должно выбираться на основе свойств хоста, используйте
if constexpr внутри хост-класса вместо ветвления во время выполнения:
1template <typename Threading>
2class SharedBuffer {
3 uint8_t data_[256];
4 size_t head_ = 0;
5
6public:
7 bool push(uint8_t byte) {
8 if constexpr (std::is_same_v<Threading, MutexLocked>) {
9 std::lock_guard<std::mutex> lk(mtx_);
10 return pushImpl(byte);
11 } else {
12 return pushImpl(byte);
13 }
14 }
15
16private:
17 bool pushImpl(uint8_t byte) {
18 if (head_ >= sizeof(data_)) return false;
19 data_[head_++] = byte;
20 return true;
21 }
22
23 [[no_unique_address]] std::conditional_t<
24 std::is_same_v<Threading, MutexLocked>,
25 std::mutex,
26 std::monostate // заполнитель нулевого размера
27 > mtx_;
28};
[[no_unique_address]] на члене std::monostate стоит ноль байт.
Весь путь блокировки устраняется оптимизатором, когда Threading = SingleThreaded.
Когда проектирование на основе политик — правильный выбор
| Ситуация | Причина использовать политики |
|---|---|
| Несколько независимых осей настройки | Каждая ось → одна политика; комбинации бесплатны |
| Нулевые накладные расходы во время выполнения | Никакой vtable, никаких ветвлений — чистое время компиляции |
| Класс используется с 2–4 фиксированными конфигурациями | Именованные псевдонимы using + политики лучше наследования |
| Мокирование в тестах | Замена RealHardware на MockHardware через политику |
| Встраиваемые: нет RTTI, нет исключений, нет кучи | Политики — обычные структуры — никаких накладных расходов |
Когда не использовать:
- Когда политика должна выбираться во время выполнения (конфиг пользователя, подключаемые модули) —
используйте виртуальный вызов или
std::function. - Когда больше ~4 осей политик — пространство комбинаций взрывается, именованные псевдонимы становятся неудобными.
- Когда все потребители используют одинаковую конфигурацию — простой класс проще.
Итоги
- Класс политики — параметр шаблона, инкапсулирующий одну ось поведения
- Хост-класс компонует несколько политик — каждая в своём параметре шаблона
- Все вызовы политик встраиваются; неиспользуемые пути (
NullOutput,NoCalibration) устраняются - Концепты C++20 ограничивают политики в месте использования с читаемыми сообщениями об ошибках
- Псевдонимы
usingименуют общие конфигурации — смените весь профиль в одну строку if constexpr+[[no_unique_address]]позволяют хосту условно добавлять или опускать данные
Что дальше
- Паттерн «стратегия» — аналог времени выполнения: политики, выбираемые при создании
- CRTP — статический полиморфизм и примеси — диспетчеризация методов базового класса без vtable
- Метапрограммирование шаблонов и концепты C++20 — свойства типов и ограничения концептами