Запах цепочки if-else
В прошивке и настольном C++ накапливается этот паттерн:
1float filter(float sample, FilterType type) {
2 if (type == FilterType::None) return sample;
3 else if (type == FilterType::LowPass) return lowPass(sample);
4 else if (type == FilterType::Median) return median(sample);
5 else if (type == FilterType::Kalman) return kalman(sample);
6 return sample;
7}
Добавление нового фильтра означает редактирование этой функции — трогать проверенный код, рисковать регрессиями, раздувать функцию, которая не имеет отношения к математике фильтров. Та же цепочка появляется в сериализаторах, алгоритмах сжатия, коммуникационных протоколах и бэкендах отображения.
Паттерн «стратегия» заменяет цепочку на заменяемый объект поведения. Вызывающий выбирает, какую стратегию использовать; алгоритм инкапсулирован внутри неё.
Стратегия времени выполнения — виртуальный вызов
Классическая реализация GoF: абстрактный базовый класс определяет интерфейс, конкретные классы реализуют его, контекст хранит указатель.
1// Интерфейс — что делает фильтр
2class IFilter {
3public:
4 virtual float process(float sample) = 0;
5 virtual ~IFilter() = default;
6};
7
8// Конкретные стратегии
9class LowPassFilter : public IFilter {
10 float prev_ = 0.0f;
11 float alpha_;
12public:
13 explicit LowPassFilter(float alpha) : alpha_(alpha) {}
14 float process(float sample) override {
15 prev_ = alpha_ * sample + (1.0f - alpha_) * prev_;
16 return prev_;
17 }
18};
19
20class MedianFilter : public IFilter {
21 std::array<float, 5> window_{};
22 size_t pos_ = 0;
23public:
24 float process(float sample) override {
25 window_[pos_++ % window_.size()] = sample;
26 auto sorted = window_;
27 std::sort(sorted.begin(), sorted.end());
28 return sorted[sorted.size() / 2];
29 }
30};
31
32// Контекст — использует любую стратегию, которую хранит
33class SensorPipeline {
34 IFilter* filter_;
35public:
36 explicit SensorPipeline(IFilter* filter) : filter_(filter) {}
37
38 void setFilter(IFilter* filter) { filter_ = filter; }
39
40 float read(float raw) {
41 return filter_->process(raw);
42 }
43};
Использование:
1LowPassFilter lp(0.1f);
2MedianFilter med;
3
4SensorPipeline pipeline(&lp);
5pipeline.read(adcSample());
6
7// Смена стратегии во время выполнения
8pipeline.setFilter(&med);
9pipeline.read(adcSample());
Когда использовать: стратегия должна выбираться во время выполнения (конфиг пользователя, согласование протокола, тип сенсора не известен при компиляции).
Стоимость: один виртуальный вызов на каждый process. На Cortex-M4 на 168 МГц
косвенный вызов через vtable стоит ~3–5 нс — незначительно, если только не вызывается
в плотном внутреннем цикле на мегасэмплах.
Стратегия времени компиляции — параметр шаблона
Если стратегия фиксирована во время компиляции (или на инстанциацию), используйте параметр шаблона. Никакой vtable, никакого виртуального вызова, никаких накладных расходов.
1template <typename Filter>
2class SensorPipeline {
3 Filter filter_;
4public:
5 float read(float raw) {
6 return filter_.process(raw);
7 }
8};
9
10// Использование
11SensorPipeline<LowPassFilter> pipeline; // LowPassFilter встроен
12SensorPipeline<MedianFilter> pipeline2; // другой двоичный, другая оптимизация
Классы фильтров больше не нуждаются в наследовании от IFilter — утиная типизация через
шаблоны. process просто должен существовать с правильной сигнатурой.
1// Наследование не нужно для шаблонной стратегии
2class KalmanFilter {
3 // ... состояние
4public:
5 float process(float sample) {
6 // Обновление фильтра Калмана
7 return estimate_;
8 }
9};
10
11SensorPipeline<KalmanFilter> kpipeline;
Когда использовать: конкретная стратегия известна при компиляции. Распространено во встраиваемых системах, где один алгоритм выбирается для всей сборки прошивки и никогда не меняется во время выполнения. Шаблонные стратегии позволяют полное встраивание и автовекторизацию.
Стоимость: размер кода — каждый SensorPipeline<X> — отдельный двоичный.
На маленьких МК с 64 кБ flash инстанциация 5 стратегий фильтра с SensorPipeline
производит 5 отдельных копий класса контекста.
Проектирование на основе политик — несколько ортогональных стратегий
Контекст часто имеет больше одной оси вариации. Шаблонные политики позволяют компоновать их независимо:
1template <typename Filter, typename Logger, typename Output>
2class SensorPipeline {
3 Filter filter_;
4 Logger logger_;
5 Output output_;
6public:
7 void run(float raw) {
8 float filtered = filter_.process(raw);
9 logger_.log(filtered);
10 output_.send(filtered);
11 }
12};
13
14// Комбинируйте на своё усмотрение
15using ProductionPipeline = SensorPipeline<KalmanFilter, NullLogger, UartOutput>;
16using DebugPipeline = SensorPipeline<LowPassFilter, UartLogger, LcdOutput>;
17
18ProductionPipeline prod;
19DebugPipeline debug;
NullLogger — пустая структура с методом log, ничего не делающим — компилятор
полностью устраняет её. Это «нулевой объект» в форме типа: нулевая стоимость в
производстве, полное логирование в отладке, выбирается при компиляции.
CRTP-стратегия — статический полиморфизм с синтаксисом наследования
CRTP предоставляет виртуальный синтаксис с нулевыми накладными расходами:
1// CRTP-база — интерфейс через static_cast к производному
2template <typename Derived>
3class FilterBase {
4public:
5 float process(float sample) {
6 return static_cast<Derived*>(this)->processImpl(sample);
7 }
8};
9
10class LowPassFilter : public FilterBase<LowPassFilter> {
11 float prev_ = 0.0f;
12 float alpha_;
13public:
14 explicit LowPassFilter(float alpha) : alpha_(alpha) {}
15 float processImpl(float sample) {
16 prev_ = alpha_ * sample + (1.0f - alpha_) * prev_;
17 return prev_;
18 }
19};
20
21// Шаблонный контекст с CRTP-базой — нет виртуального вызова
22template <typename F>
23class SensorPipeline {
24 F filter_;
25public:
26 float read(float raw) { return filter_.process(raw); }
27};
Вызов process разрешается в processImpl через статический cast — полностью
встраивается во время компиляции. Полезно, когда нужна иерархия наследования (для
документации и ясности API) без накладных расходов.
Выбор правильного подхода
| Критерий | Виртуальный в рантайме | Параметр шаблона | CRTP |
|---|---|---|---|
| Стратегия выбирается во время выполнения | Да | Нет | Нет |
| Нулевые накладные расходы | Нет (vtable) | Да (встроен) | Да (static cast) |
| Хранить разные стратегии в контейнере | Да (вектор IFilter*) |
Нет | Нет |
| Размер кода на инстанциацию | Общий | N копий | N копий |
| Типобезопасность | Динамическая (ошибка в рантайме) | Статическая (ошибка при компиляции) | Статическая |
| Иерархия наследования | Да | Нет | Да |
Правило большого пальца:
- Один алгоритм, выбранный при запуске: параметр шаблона
- Алгоритм переключается во время выполнения: виртуальный вызов
- Базовый класс фреймворка с переопределениями в производных: CRTP (если важна производительность) или виртуальный (если важна ясность)
Устранение switch — паттерн реестра
Стратегии времени выполнения часто требуют фабрики. Избегайте switch, строя реестр:
1#include <functional>
2#include <map>
3#include <string>
4#include <memory>
5
6using FilterFactory = std::function<std::unique_ptr<IFilter>()>;
7
8class FilterRegistry {
9 std::map<std::string, FilterFactory> factories_;
10public:
11 void registerFilter(std::string name, FilterFactory f) {
12 factories_[std::move(name)] = std::move(f);
13 }
14
15 std::unique_ptr<IFilter> create(const std::string& name) const {
16 auto it = factories_.find(name);
17 if (it == factories_.end()) return nullptr;
18 return it->second();
19 }
20};
21
22// Регистрация (при запуске или через статический инициализатор)
23FilterRegistry registry;
24registry.registerFilter("lowpass", [] { return std::make_unique<LowPassFilter>(0.1f); });
25registry.registerFilter("median", [] { return std::make_unique<MedianFilter>(); });
26
27// Без switch — открыт для новых стратегий без изменения кода реестра
28auto filter = registry.create(configValue);
Добавление новой стратегии — написать новый класс и один вызов регистрации — существующий код не трогается.
Итоги
- Стратегия в рантайме: виртуальная база + конкретные классы + контекст с указателем — переключение во время выполнения, один вызов через vtable
- Шаблонная стратегия: параметр типа на контексте — нулевая стоимость, встроена, должна быть известна при компиляции
- На основе политик: несколько ортогональных параметров шаблона — компоновка стратегий независимо, нулевые стратегии бесплатны
- CRTP-стратегия: статический вызов с синтаксисом наследования — разрешение при компиляции, иерархия наследования без vtable
- Реестр: словарь фабричных функций — добавление стратегий без правки существующего кода
Что дальше
- CRTP — статический полиморфизм и примеси — полный паттерн CRTP в деталях
- Паттерны «фабрика» и «строитель» — централизация создания объектов
- Внедрение зависимостей без фреймворка — внедрение стратегий через конструктор