What policy-based design solves
A class often needs several independent axes of customisation: how it allocates memory, how it logs, whether it is thread-safe, what synchronisation primitive it uses. The naive approaches are:
- Template parameter for each axis — proliferates specialisations.
- Runtime flags and switch statements — overhead on every call.
- Virtual base classes — vtable cost, forced heap allocation.
Policy-based design (popularised by Andrei Alexandrescu in Modern C++ Design) replaces all three with policy classes as template parameters. Each policy encapsulates one axis of behaviour. The host class composes them with zero runtime overhead.
Minimal example — a logger with three policies
1// Policy 1: where to write
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) {} // discard — used in tests or release
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// Policy 2: formatting
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// Policy 3: thread safety
33struct SingleThreaded {
34 struct Lock { Lock() {} }; // no-op
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// Host class — assembles three independent policies
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};
Usage — pick any combination:
1// Release firmware — no output, no overhead
2using FirmwareLogger = Logger<NullOutput>;
3
4// Development — timestamps to UART
5using DebugLogger = Logger<UartOutput, TimestampFormatter>;
6
7// Desktop unit tests — thread-safe, stdout
8using TestLogger = Logger<StdoutOutput, PlainFormatter, MutexLocked>;
9
10FirmwareLogger log;
11log.info("ADC calibration complete"); // compiles to nothing — NullOutput is eliminated
The compiler inlines all three policies. NullOutput::write is a no-op — the
optimiser removes it completely. No vtable, no branches, no overhead.
Policies as base classes — the Alexandrescu style
Alexandrescu had the host class inherit from its policies. This allows policies to add data members and member functions to the host:
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); // policy adds log() member
9 StoragePolicy::store(value); // policy adds store() member
10 }
11};
Policies with no data can use empty base class optimisation — they occupy zero bytes when used as base classes, unlike if they were stored as members.
1struct NoLogging {
2 void log(std::string_view, float) {} // empty base, zero size in derived
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 — binary as if hand-written
18DataRecorder<FlashStorage, NoLogging> recorder;
Constraining policies with C++20 Concepts
Without constraints, a missing method in a policy produces a cryptic error deep in the template instantiation. Concepts turn that into a clear, upfront error:
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; // must have a Lock type
14};
15
16template <
17 OutputPolicy Output = StdoutOutput,
18 FormatterPolicy Formatter = PlainFormatter,
19 ThreadingPolicy Threading = SingleThreaded
20>
21class Logger { /* same body as before */ };
Now a bad policy fails at the call site:
1struct BadOutput { void write(std::string_view) {} }; // not static
2Logger<BadOutput> log;
3// error: 'BadOutput' does not satisfy 'OutputPolicy'
4// note: 'BadOutput::write(msg)' does not satisfy T::write(msg)
Accumulator — policies sharing state via the host
A more complex example: a pipeline that reads a sensor, optionally filters, optionally calibrates, and outputs the result. Each stage is a policy.
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// Policies
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// Host — combines sensor + filter + calibration at compile time
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};
Composing at the call site:
1// Production firmware — no overhead, passthrough filter, no calibration
2SensorPipeline<> pipeline;
3
4// Test rig — 8-sample moving average, linear calibration
5SensorPipeline<MovingAverageFilter, LinearCalibration>
6 pipeline(LinearCalibration{0.00805f, -40.0f}); // NTC lookup
7
8float value = pipeline.process(adc.read());
The production version compiles to two float additions: nothing. The test rig keeps an 8-element buffer and applies a multiply-add — no branching.
Type aliases for named configurations
Naming policy combinations with using prevents repetition at call sites:
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 overrun");
Switch the entire logger behaviour by changing one line — the type alias.
Policy selection at compile time with if constexpr
When policy behaviour must be selected based on the host’s properties, use
if constexpr inside the host class rather than runtime branching:
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 // zero-size placeholder
27 > mtx_;
28};
[[no_unique_address]] on a std::monostate member costs zero bytes.
The entire lock path is eliminated by the optimiser when Threading = SingleThreaded.
When policy-based design is the right choice
| Situation | Reason to use policies |
|---|---|
| Multiple independent axes of customisation | Each axis → one policy; combinations are free |
| Zero runtime overhead required | No vtable, no branch — pure compile-time |
| A class is used with 2–4 fixed configurations | Named using aliases + policies beat inheritance |
| Mocking in tests | Swap RealHardware for MockHardware via a policy |
| Embedded: no RTTI, no exceptions, no heap | Policies are plain structs — no runtime cost |
When not to use it:
- When the policy must be selected at runtime (user config, pluggable modules) —
use virtual dispatch or
std::functioninstead. - When there are more than ~4 policy axes — the combination space explodes and named aliases become unwieldy.
- When all consumers use the same configuration — a plain class is simpler.
Summary
- A policy class is a template parameter that encapsulates one axis of behaviour
- The host class composes multiple policies — each in its own template parameter
- All policy calls are inlined; unused paths (
NullOutput,NoCalibration) are eliminated - C++20 Concepts constrain policies at the call site with readable error messages
usingaliases name common configurations — switch the whole profile in one lineif constexpr+[[no_unique_address]]let the host conditionally add or omit data
What’s next
- Strategy pattern — runtime equivalent: policies selected at construction
- CRTP — static polymorphism and mixins — base class method dispatch without vtable
- Template metaprogramming and C++20 Concepts — type traits and concept constraints