Что решает проектирование на основе политик

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

  • Параметр шаблона для каждой оси — множество специализаций.
  • Флаги и 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]] позволяют хосту условно добавлять или опускать данные

Что дальше