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