The if-else chain smell
Firmware and desktop C++ both accumulate this pattern:
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}
Adding a new filter means editing this function — touching tested code, risking regressions, bloating a function that had nothing to do with filter math. The same chain appears in serialisers, compression algorithms, communication protocols, and display backends.
The Strategy pattern replaces the chain with a swappable behaviour object. The caller picks which strategy to use; the algorithm is encapsulated inside it.
Runtime strategy — virtual dispatch
The classic GoF implementation: an abstract base class defines the interface, concrete classes implement it, the context holds a pointer.
1// Interface — what a filter does
2class IFilter {
3public:
4 virtual float process(float sample) = 0;
5 virtual ~IFilter() = default;
6};
7
8// Concrete strategies
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// Context — uses whichever strategy it holds
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};
Usage:
1LowPassFilter lp(0.1f);
2MedianFilter med;
3
4SensorPipeline pipeline(&lp);
5pipeline.read(adcSample());
6
7// Switch strategy at runtime
8pipeline.setFilter(&med);
9pipeline.read(adcSample());
When to use this: the strategy must be selected at runtime (user config, communication protocol negotiation, sensor type not known at compile time).
Cost: one virtual dispatch per process call. On a Cortex-M4 at 168 MHz,
a vtable indirect call costs ~3–5 ns — negligible unless called in a tight
inner loop at megasample rates.
Compile-time strategy — template parameter
If the strategy is fixed at compile time (or per instantiation), use a template parameter. No vtable, no virtual dispatch, no runtime overhead.
1template <typename Filter>
2class SensorPipeline {
3 Filter filter_;
4public:
5 float read(float raw) {
6 return filter_.process(raw);
7 }
8};
9
10// Use
11SensorPipeline<LowPassFilter> pipeline; // LowPassFilter inlined
12SensorPipeline<MedianFilter> pipeline2; // different binary, different optimisation
The filter classes no longer need to inherit from IFilter — duck typing via
templates. process just has to exist with the right signature.
1// No inheritance needed for template strategy
2class KalmanFilter {
3 // ... state
4public:
5 float process(float sample) {
6 // Kalman update
7 return estimate_;
8 }
9};
10
11SensorPipeline<KalmanFilter> kpipeline;
When to use this: the concrete strategy is known at compile time. Common in embedded where you pick one algorithm for the entire firmware build and never change it at runtime. Template strategies enable full inlining and auto-vectorisation.
Cost: code size — each SensorPipeline<X> is a separate binary. On small
MCUs with 64 kB flash, instantiating 5 filter strategies with SensorPipeline
each produces 5 separate copies of the context class.
Policy-based design — multiple orthogonal strategies
A context often has more than one axis of variation. Template policies let you compose them independently:
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// Mix and match
15using ProductionPipeline = SensorPipeline<KalmanFilter, NullLogger, UartOutput>;
16using DebugPipeline = SensorPipeline<LowPassFilter, UartLogger, LcdOutput>;
17
18ProductionPipeline prod;
19DebugPipeline debug;
NullLogger is an empty struct whose log method does nothing — the compiler
eliminates it entirely. This is the “null object” in type form: zero cost in
production, full logging in debug, selected at compile time.
CRTP strategy — static polymorphism with inheritance syntax
CRTP (Curiously Recurring Template Pattern) provides virtual-like syntax with zero dispatch overhead:
1// CRTP base — interface via static_cast to derived
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// Template context using CRTP base — no virtual call
22template <typename F>
23class SensorPipeline {
24 F filter_;
25public:
26 float read(float raw) { return filter_.process(raw); }
27};
The process call resolves to processImpl via the static cast — fully
inlined at compile time. Useful when you want the inheritance hierarchy (for
documentation and API clarity) without runtime cost.
Choosing the right approach
| Criterion | Runtime virtual | Template param | CRTP |
|---|---|---|---|
| Strategy selected at runtime | Yes | No | No |
| Zero dispatch overhead | No (vtable) | Yes (inlined) | Yes (static cast) |
| Store mixed strategies in container | Yes (IFilter* vector) |
No | No |
| Code size per instantiation | Shared | N copies | N copies |
| Type safety | Dynamic (runtime error) | Static (compile error) | Static |
| Inheritance hierarchy | Yes | No | Yes |
Rule of thumb:
- One algorithm, chosen at startup: template parameter
- Algorithm switches at runtime: virtual dispatch
- Framework base class with derived overrides: CRTP (if performance matters) or virtual (if clarity matters)
Eliminating the switch — registry pattern
Runtime strategies often need a factory. Avoid a switch by building a registry:
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// Registration (at startup, or via static initialiser)
23FilterRegistry registry;
24registry.registerFilter("lowpass", [] { return std::make_unique<LowPassFilter>(0.1f); });
25registry.registerFilter("median", [] { return std::make_unique<MedianFilter>(); });
26
27// No switch — open to new strategies without modifying registry code
28auto filter = registry.create(configValue);
Adding a new strategy means writing a new class and one registration call — no existing code is touched.
Summary
- Runtime strategy: virtual base + concrete classes + context holding a pointer — switch at runtime, one vtable call per use
- Template strategy: type parameter on the context — zero cost, inlined, must be known at compile time
- Policy-based: multiple orthogonal template parameters — compose strategies independently, null policies are free
- CRTP strategy: static dispatch with inheritance syntax — compile-time resolution, inheritance hierarchy without vtable
- Registry: map of factory functions — add strategies without touching existing code
What’s next
- CRTP — static polymorphism and mixins — the full CRTP pattern in depth
- Factory & builder patterns — centralising object creation
- Dependency injection without a framework — injecting strategies via constructor