Programming · 55 pages
- C language — 15 lessonstutorial
- Lesson 1 — Hello, C
- Lesson 2 — Types & literals
- Lesson 3 — Expressions & operators
- Lesson 4 — Control flow
- Lesson 5 — Functions
- Lesson 6 — Arrays
- Lesson 7 — Pointers
- Lesson 8 — Strings
- Lesson 9 — Structs & unions
- Lesson 10 — Dynamic memory
- Lesson 11 — Preprocessor
- Lesson 12 — Multi-file projects
- Lesson 13 — Standard library
- Lesson 14 — Undefined behavior
- Lesson 15 — Modern C (C11/17/23)
- C++ programming — 15 lessonstutorial
- Lesson 1 — Hello, C++
- Lesson 2 — References & namespaces
- Lesson 3 — Classes & RAII
- Lesson 4 — Inheritance & polymorphism
- Lesson 5 — Templates
- Lesson 6 — STL containers
- Lesson 7 — Iterators & algorithms
- Lesson 8 — Smart pointers
- Lesson 9 — Move semantics
- Lesson 10 — Lambdas
- Lesson 11 — Error handling
- Lesson 12 — Concurrency
- Lesson 13 — Modules (C++20)
- Lesson 14 — Modern C++23
- Lesson 15 — C++ on Cortex-M
- Bare-metal C tutorial — 10 lessonsembedded
- Lesson 1 — Toolchain setup
- Lesson 2 — Bare-metal blink
- Lesson 3 — Registers in C
- Lesson 4 — Memory map
- Lesson 5 — Linker script
- Lesson 6 — Interrupts & ISRs
- Lesson 7 — Bit patterns
- Lesson 8 — Fixed-point math
- Lesson 9 — State machines
- Lesson 10 — Ring buffers + DMA
- Embedded C patterns
- volatile & memory-mapped IO
- CMSIS-Core
- CMSIS-DSP
- Linker scripts
- Startup & vector table
- C++ on Cortex-M
- Rust on Cortex-Mmodern
- Inline assembly
- Bare-metal libc & newlib-nano
- Bootloaders & DFU
- OTA update flow
Lesson 5 — Templates
Function and class templates, type deduction, template specialisation, variadic packs, if constexpr, concepts (C++20), and a RingBuffer with N as a template parameter — the zero-overhead polymorphism mechanism.
- What you'll learn
- Function and class templates, template parameter deduction, full and partial specialisation, variadic templates and parameter packs, if constexpr, constexpr functions, the SFINAE-to-concepts story, and a RingBuffer
example. - Code provided
- About 200 lines covering function templates, class templates, specialisation, variadic templates, and the RingBuffer
- Time
- 150-180 minutes
- Pre-reqs
- Lessons 1-4
Templates are the other half of polymorphism in C++. Virtual functions give you runtime polymorphism — one piece of code that handles many types, with a vtable lookup at each call site. Templates give you compile-time polymorphism — one piece of code that generates a separate, fully specialised version for each type used, with zero runtime cost. The STL is built on templates. constexpr is built on templates. Modern zero-overhead C++ is built on templates. This lesson teaches them properly.
Function templates
The simplest template: a function that works for any type.
#include <iostream>
template <typename T>
T max_of(T a, T b) {
return (a > b) ? a : b;
}
int main() {
std::cout << max_of(3, 7) << "\n"; // T = int
std::cout << max_of(3.14, 2.71) << "\n"; // T = double
std::cout << max_of<long>(3, 7L) << "\n"; // explicit T = long
}The template <typename T> line declares T as a type parameter. Inside the function, T stands for "whatever type the compiler picks". The compiler examines each call site, deduces T from the argument types, and generates a separate copy of the function for each distinct T it sees. max_of(3, 7) instantiates max_of<int>; max_of(3.14, 2.71) instantiates max_of<double>. These are two distinct functions in the final binary.
Two notes:
typename Tandclass Tmean the same thing in template parameter lists. Most modern code usestypenamefor type parameters and reservesclassfor actual class declarations.- The compiler must see the full definition of the template at each call site, which is why templates almost always live in header files. (C++20 modules change this; we cover modules in lesson 13.)
If you call max_of(3, 3.14), you get a compile error: the compiler cannot deduce T because the two arguments have different types. You can fix it either by explicit instantiation (max_of<double>(3, 3.14)) or by writing two parameters with separate types:
template <typename A, typename B>
auto max_of(A a, B b) {
return (a > b) ? a : b;
}Now A and B are independent, and the return type uses auto to deduce whatever the conditional expression yields.
Class templates
A class can be templated too:
template <typename T>
class Box {
public:
explicit Box(T value) : value_(std::move(value)) {}
const T& value() const { return value_; }
void set_value(T v) { value_ = std::move(v); }
private:
T value_;
};
int main() {
Box<int> bi{42};
Box<std::string> bs{"hello"};
std::cout << bi.value() << " " << bs.value() << "\n";
}Box<int> and Box<std::string> are two distinct types generated from one template. They share no code at runtime — each is fully specialised. That is why we say templates are zero-overhead: the generated code for Box<int> is identical to what you would have hand-written for a Box that stores an int.
C++17 added class template argument deduction (CTAD), so you can sometimes write the template without the angle brackets:
Box b{42}; // CTAD deduces Box<int>
Box s{std::string{"hi"}}; // Box<std::string>This works only when the compiler can deduce all parameters from the constructor arguments.
Non-type template parameters
Templates can take non-type parameters as well as type parameters. The classic use is sizes known at compile time:
template <typename T, std::size_t N>
class Array {
public:
T& operator[](std::size_t i) { return data_[i]; }
const T& operator[](std::size_t i) const { return data_[i]; }
constexpr std::size_t size() const { return N; }
private:
T data_[N];
};
Array<int, 16> a;
Array<double, 100> b;N is a value, not a type. Array<int, 16> and Array<int, 32> are distinct types — the compiler can do array bounds analysis on each separately. This is how std::array<T, N> works internally.
Non-type template parameters must be of integer, enum, pointer, reference, or (C++20) literal class types. The classic restriction is that they must be compile-time constants — values the compiler knows during compilation.
Template specialisation
You can provide a custom implementation for specific template arguments:
template <typename T>
struct TypeName {
static const char* get() { return "unknown"; }
};
// Full specialisation for int
template <>
struct TypeName<int> {
static const char* get() { return "int"; }
};
// Full specialisation for std::string
template <>
struct TypeName<std::string> {
static const char* get() { return "std::string"; }
};
std::cout << TypeName<int>::get() << "\n"; // int
std::cout << TypeName<double>::get() << "\n"; // unknown
std::cout << TypeName<std::string>::get() << "\n"; // std::stringThis is full specialisation. The template <> says "this template takes no parameters here — I am specifying everything." TypeName<int>::get returns "int"; for any type not specialised, you get the primary template's "unknown".
Partial specialisation lets you specialise on a pattern rather than a single type. It works only for class templates:
template <typename T>
struct IsPointer {
static constexpr bool value = false;
};
template <typename T>
struct IsPointer<T*> { // partial: "anything that is a pointer"
static constexpr bool value = true;
};
static_assert(IsPointer<int>::value == false);
static_assert(IsPointer<int*>::value == true);
static_assert(IsPointer<std::string*>::value == true);Partial specialisation is how std::vector<bool>'s notorious bit-packing trick is implemented, how iterator traits choose the right algorithm for each iterator category, and how most of the template metaprogramming machinery in the standard library works.
Variadic templates
A template can take any number of parameters:
template <typename... Args>
void print_all(const Args&... args) {
((std::cout << args << " "), ...); // C++17 fold expression
std::cout << "\n";
}
print_all(1, "hello", 3.14, 'A'); // 1 hello 3.14 AArgs... is a parameter pack: zero or more types. args... is the corresponding pack of values. The C++17 fold expression ((std::cout << args << " "), ...) expands to (std::cout << arg1 << " "), (std::cout << arg2 << " "), ... with the comma operator joining them.
Before C++17 you wrote this with recursive templates: a base case and a recursive case. The fold expression is the modern, shorter form.
Variadic templates power std::tuple, std::variant, std::make_unique, std::format, and most modern factory functions.
if constexpr
Sometimes a template needs to do different things for different types. if constexpr (C++17) lets you branch at compile time:
#include <type_traits>
template <typename T>
void describe(const T& value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "integer: " << value << "\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "float: " << value << "\n";
} else {
std::cout << "other type\n";
}
}
describe(42); // integer: 42
describe(3.14); // float: 3.14
describe("hello"); // other typeThe branch not taken is not compiled for each instantiation. That is a big deal: code that would fail to compile in the not-taken branch is fine as long as it never gets selected. Compare with regular if, where both branches must compile for every type — making certain template patterns impossible without specialisation.
std::is_integral_v, std::is_floating_point_v, and friends are type traits from <type_traits>. They are compile-time predicates over types, returning bool. The _v suffix is the C++17 shortcut for std::is_integral<T>::value.
constexpr functions
A constexpr function can be evaluated at compile time when its arguments are compile-time constants:
constexpr int factorial(int n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
}
constexpr int five_factorial = factorial(5); // computed at compile time
int x = read_input();
int y = factorial(x); // computed at runtimeThe same function works in both contexts. Mark anything you can as constexpr; it costs you nothing and unlocks compile-time computation. C++20 added consteval for functions that must run at compile time (refuse to compile if used at runtime) and constinit for variables that must be initialised at compile time.
constexpr plus templates is how modern C++ encodes computation in the type system. Lookup tables generated at compile time. Register addresses computed from board configs. Whole DSLs implemented in templates and constexpr. This is the corner of C++ that surprises C programmers most.
SFINAE and concepts
"SFINAE" stands for Substitution Failure Is Not An Error. Historically, if you wanted to constrain a template to only work for certain types, you wrote something like:
#include <type_traits>
// Only enabled for integer types
template <typename T,
typename = std::enable_if_t<std::is_integral_v<T>>>
T double_it(T x) {
return x * 2;
}
double_it(5); // OK
// double_it(3.14); // compile error: no matching functionThe trick: if T is not integral, the enable_if_t substitution fails, and the compiler treats the template as not-a-match rather than as an error. The function disappears from the overload set. SFINAE is powerful and ugly.
C++20 introduced concepts, which make the same intent readable:
#include <concepts>
template <std::integral T>
T double_it(T x) {
return x * 2;
}
double_it(5); // OK
// double_it(3.14); // compile error: 'double' does not satisfy 'std::integral'std::integral is a concept — a named compile-time predicate over types. Concepts can be combined (std::integral && std::signed_integral), used with requires clauses, and produce error messages a human can actually read.
You can write your own concepts:
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
template <Addable T>
T sum(T a, T b) { return a + b; }The requires(T a, T b) { ... } block lists expressions that must be valid for T to satisfy the concept. If a + b works and returns T, you are Addable.
Modern code prefers concepts to SFINAE. Older code is full of std::enable_if; you should be able to read it but should not write new code in the old style.
A complete example: RingBuffer<T, N>
A ring buffer is a fixed-capacity circular FIFO. Templating it on the element type and capacity lets the compiler size the storage at compile time, optimise the modulo arithmetic into bit-masks when N is a power of two, and produce zero-overhead code.
#include <array>
#include <cstddef>
#include <optional>
template <typename T, std::size_t N>
class RingBuffer {
static_assert(N > 0, "RingBuffer capacity must be positive");
public:
bool push(T value) {
if (full()) return false;
data_[write_] = std::move(value);
write_ = (write_ + 1) % N;
++count_;
return true;
}
std::optional<T> pop() {
if (empty()) return std::nullopt;
T value = std::move(data_[read_]);
read_ = (read_ + 1) % N;
--count_;
return value;
}
constexpr std::size_t capacity() const { return N; }
std::size_t size() const { return count_; }
bool empty() const { return count_ == 0; }
bool full() const { return count_ == N; }
private:
std::array<T, N> data_{};
std::size_t read_ = 0;
std::size_t write_ = 0;
std::size_t count_ = 0;
};
int main() {
RingBuffer<int, 4> rb;
rb.push(1); rb.push(2); rb.push(3);
while (auto v = rb.pop()) {
std::cout << *v << " ";
}
std::cout << "\n"; // 1 2 3
}Things to note:
static_assert(N > 0)runs at compile time. If you writeRingBuffer<int, 0>, the compile fails with the message. Compile-time sanity checks are free.std::array<T, N>stores N elements inline, no heap. The wholeRingBuffer<int, 4>is 36 bytes (16 bytes for the array, plus threestd::size_ts) and lives entirely on the stack or in static memory.% Nis constant-folded. IfN == 4, GCC will recognise(x + 1) % 4as(x + 1) & 3(a single AND instruction) because 4 is a power of two. For non-power-of-twoN, the modulo is a real division — slightly slower. For an embedded ring buffer, pick a power of two.std::optional<T>for thepopreturn signals "may be empty" without needing exceptions or sentinel values. We will see optional properly in lesson 11.
This pattern — a templated container with the capacity as a non-type template parameter — is exactly the right tool for embedded contexts where the standard std::vector is forbidden (it allocates). The ETL library provides production versions of this pattern for every common container.
When templates go wrong
Two failure modes to know about.
Code bloat. Each template instantiation is a separate copy of the code. If you instantiate RingBuffer<int, 16>, RingBuffer<int, 32>, and RingBuffer<int, 64>, you get three full copies. On a Cortex-M with 64 KB of flash this matters. Mitigation: avoid unnecessary parameterisation (don't template on capacity unless you need to), share a non-templated base class for common code.
Cryptic error messages. Template errors used to be famous for their length and incomprehensibility. Concepts (C++20) and improved diagnostics in clang and gcc have made this much better, but a misuse of a heavily templated library can still produce 50-line errors. Read them top-down; the first line usually points to your actual mistake.
Exercises
Exercise 5.1. Write a function template min_of(a, b) that works for any type with operator<. Test it with int, double, and std::string.
Exercise 5.2. Modify the RingBuffer to use a power-of-two N only, enforced by static_assert. Replace % N with & (N - 1). Compile both versions and look at the generated assembly with -O2 -S to see the difference.
Exercise 5.3. Write a variadic function template sum(args...) that accepts any number of arithmetic values and returns their sum. Use a C++17 fold expression. Constrain it with a C++20 concept to require arithmetic types only.
What you learned
- Function templates and class templates instantiate separate code for each type used. Zero runtime overhead.
- Non-type template parameters (like
N) let the compiler size data at compile time. Foundation ofstd::arrayand the RingBuffer. - Full specialisation (
template <>) provides per-type customisation. Partial specialisation works for class templates. - Variadic templates with parameter packs accept any number of arguments; C++17 fold expressions are the modern way to operate on them.
if constexprbranches at compile time; the untaken branch is not compiled.constexprfunctions can run at compile time when their arguments are constants. Mark everything you can.- SFINAE was the old constraint mechanism; C++20 concepts replace it with readable named predicates.
- Templates are the C++ tool for zero-overhead polymorphism. On embedded targets, they often replace virtual dispatch entirely.
In lesson 6 we tour the STL containers — vector, array, string, map, unordered_map, set, span — when to use each, and which to avoid on embedded.
Next
| Previous | Next |
|---|---|
| Lesson 4 — Inheritance and polymorphism | Lesson 6 — STL containers |