What template metaprogramming is for

Template metaprogramming (TMP) lets you write code that is parameterised on types — and constrain which types are valid. The goal is to catch type errors at compile time rather than at runtime, and to generate specialised code per type without writing it by hand.

Practical uses:

  • Accept std::span<uint8_t> and std::vector<uint8_t> in one function
  • Restrict a container to trivially copyable types (safe for DMA transfer)
  • Enable different code paths depending on whether a type is signed or floating-point
  • Zero-cost abstractions via if constexpr

Type traits — querying types at compile time

<type_traits> provides predicates about types:

 1#include <type_traits>
 2
 3// Is T an integer?
 4std::is_integral<int>::value          // true
 5std::is_integral<float>::value        // false
 6std::is_integral_v<int>               // C++17 shorthand — same as above
 7
 8// Is T trivially copyable? (safe to memcpy)
 9std::is_trivially_copyable_v<float>   // true
10std::is_trivially_copyable_v<std::vector<int>> // false — has destructor
11
12// Is T a pointer?
13std::is_pointer_v<int*>    // true
14std::is_pointer_v<int>     // false

Use them to guard DMA-safe transfers:

1template <typename T>
2void dmaTransfer(std::span<T> data, DMA_HandleTypeDef* hdma) {
3    static_assert(std::is_trivially_copyable_v<T>,
4                  "DMA transfer requires trivially copyable type");
5    HAL_DMA_Start(hdma, reinterpret_cast<uint32_t>(data.data()),
6                  dmaDestination, data.size() * sizeof(T));
7}

The error fires at compile time, not when the firmware is running.


SFINAE — Substitution Failure Is Not An Error

Before C++20, the way to conditionally enable a function template was SFINAE:

 1// Enable this overload only if T is an integer
 2template <typename T,
 3          typename = std::enable_if_t<std::is_integral_v<T>>>
 4T clamp(T value, T lo, T hi) {
 5    return value < lo ? lo : value > hi ? hi : value;
 6}
 7
 8// Enable this overload only if T is floating-point
 9template <typename T,
10          typename = std::enable_if_t<std::is_floating_point_v<T>>>
11T clamp(T value, T lo, T hi) {
12    if (std::isnan(value)) return lo;   // handle NaN for floats
13    return value < lo ? lo : value > hi ? hi : value;
14}

SFINAE error messages are notoriously bad:

error: no matching function for call to 'clamp(std::string, ...)'
note: template argument deduction/substitution failed
...15 lines of template instantiation trace...

C++20 Concepts replace SFINAE with readable constraints and readable errors.


C++20 Concepts — readable constraints

A concept is a named boolean predicate on types:

 1#include <concepts>
 2
 3// Built-in concepts
 4std::integral<int>              // true
 5std::floating_point<float>      // true
 6std::copyable<std::vector<int>> // true
 7std::trivially_copyable<float>  // true
 8
 9// Define your own
10template <typename T>
11concept Numeric = std::integral<T> || std::floating_point<T>;
12
13template <typename T>
14concept DmaSafe = std::is_trivially_copyable_v<T>;

Constraining a template with a concept:

 1// Three equivalent syntaxes
 2
 3// Requires clause
 4template <typename T>
 5    requires Numeric<T>
 6T clamp(T value, T lo, T hi);
 7
 8// Concept in template parameter list
 9template <Numeric T>
10T clamp(T value, T lo, T hi);
11
12// Abbreviated function template (C++20)
13auto clamp(Numeric auto value, Numeric auto lo, Numeric auto hi);

Error message with concepts:

error: no matching function for call to 'clamp(std::string, ...)'
note: constraints not satisfied
note: 'std::string' does not satisfy 'Numeric'

Readable, actionable error.


Sensor interface constraint

 1// Define what a "Sensor" must support
 2template <typename T>
 3concept Sensor = requires(T s) {
 4    { s.read()    } -> std::convertible_to<float>;
 5    { s.isReady() } -> std::convertible_to<bool>;
 6    { s.name()    } -> std::convertible_to<std::string_view>;
 7};
 8
 9// Only compiles if T satisfies Sensor
10template <Sensor S>
11class SamplingPipeline {
12    S& sensor_;
13    std::vector<float> history_;
14public:
15    explicit SamplingPipeline(S& s) : sensor_(s) {}
16
17    void sample() {
18        if (sensor_.isReady())
19            history_.push_back(sensor_.read());
20    }
21
22    std::span<const float> history() const { return history_; }
23};
24
25struct Bmp280 {
26    float read()            { return readTemp(); }
27    bool  isReady()         { return (readStatus() & 0x08) == 0; }
28    std::string_view name() { return "BMP280"; }
29};
30
31SamplingPipeline<Bmp280> pipeline(bmp);  // compiles — Bmp280 satisfies Sensor

If Bmp280 is missing a method, the error names the missing requirement:

error: 'Bmp280' does not satisfy 'Sensor'
note: 'read()' is required but missing

if constexpr — compile-time branching

if constexpr selects a code path at compile time. The non-selected branch is not compiled — no overhead, no linker errors for branches that wouldn’t work with the given type:

 1template <typename T>
 2void serialize(T value, std::span<uint8_t> out) {
 3    if constexpr (std::is_same_v<T, float>) {
 4        uint32_t bits;
 5        std::memcpy(&bits, &value, 4);
 6        out[0] = bits >> 24; out[1] = bits >> 16;
 7        out[2] = bits >> 8;  out[3] = bits;
 8    } else if constexpr (std::is_integral_v<T>) {
 9        for (size_t i = sizeof(T); i > 0; --i)
10            out[i - 1] = value & 0xFF, value >>= 8;
11    } else {
12        static_assert(sizeof(T) == 0, "unsupported type for serialize");
13    }
14}

The static_assert(false) in the else branch is a compile-time error that fires only when an unsupported type is used — not for every instantiation.


Writing a custom type trait

Define traits for your own domain types:

 1// Mark types as safe for DMA transfer
 2template <typename T>
 3struct is_dma_safe : std::is_trivially_copyable<T> {};
 4
 5// Specialise for a type that's not trivially copyable but is safe
 6// (e.g., a type with a non-trivial destructor that doesn't affect the data)
 7template <>
 8struct is_dma_safe<MySpecialBuffer> : std::true_type {};
 9
10template <typename T>
11inline constexpr bool is_dma_safe_v = is_dma_safe<T>::value;
12
13// Use as a concept
14template <typename T>
15concept DmaSafe = is_dma_safe_v<T>;
16
17template <DmaSafe T>
18void startDma(std::span<T> buf, DMA_HandleTypeDef* hdma) {
19    HAL_DMA_Start(hdma, (uint32_t)buf.data(), dmaDest, buf.size() * sizeof(T));
20}

Compile-time computation

TMP can compute values at compile time — the computation happens at compile time, the result is a constant:

 1// Compile-time power of two check (C++14)
 2template <size_t N>
 3constexpr bool isPowerOfTwo = N > 0 && (N & (N - 1)) == 0;
 4
 5static_assert(isPowerOfTwo<64>,  "buffer must be power of two");
 6static_assert(!isPowerOfTwo<63>, "sanity check");
 7
 8// Compile-time log2
 9constexpr size_t log2(size_t n) {
10    return n <= 1 ? 0 : 1 + log2(n / 2);
11}
12
13constexpr size_t mask = (1 << log2(64)) - 1;  // = 63

C++17 fold expressions

Fold expressions expand parameter packs without recursive templates:

 1// Sum of any number of arguments
 2template <typename... Ts>
 3auto sum(Ts... args) {
 4    return (args + ...);  // fold expression: a1 + a2 + a3 + ...
 5}
 6
 7float total = sum(1.0f, 2.5f, 3.7f);  // 7.2
 8
 9// All-of condition
10template <typename... Ts>
11bool allPositive(Ts... args) {
12    return ((args > 0) && ...);
13}
14
15bool ok = allPositive(1, 2, 3);   // true
16bool no = allPositive(1, -1, 3);  // false

Summary

  • <type_traits> + static_assert: catch type misuse at compile time — DMA-safe types, size constraints
  • SFINAE: conditional template enablement in C++11/14/17 — readable only with practice, ugly errors
  • C++20 Concepts: named constraints, readable errors, three syntax options — prefer template <MyConcept T>
  • if constexpr: compile-time branching per type — type-specific serialisation, format selection
  • Fold expressions: pack expansion without recursion — sum, all-of, any-of over argument packs
  • Custom type traits: is_dma_safe<T>, is_trivially_copyable<T> — domain-specific compile-time checks

What’s next