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>andstd::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
- CRTP — static polymorphism and mixins — zero-cost compile-time polymorphism
- Type erasure without virtual — runtime flexibility with concepts guarding the interface
- std::variant and std::optional — type-safe discriminated unions