C++ Module 9 – Templates and concepts
Non-type and template template parameters
Give templates values such as sizes and string literals as parameters, see why each value makes a new type, and pass std::vector as a template argument.
What you will learn
- Write a class template whose capacity is a value parameter, and check it with static_assert
- Explain why two values of a parameter make two unrelated types, and deduce a value from an argument
- Choose a type for a value parameter, including auto, floating point and class types
- Use a C++20 FixedString class parameter to carry a string literal into a type
- Pass a container template such as std::vector as a template argument
Before you start
What you’ll learn · Before you start
What you will learn
- Write a class template whose capacity is a value parameter, and check it with static_assert
- Explain why two values of a parameter make two unrelated types, and deduce a value from an argument
- Choose a type for a value parameter, including auto, floating point and class types
- Use a C++20 FixedString class parameter to carry a string literal into a type
- Pass a container template such as std::vector as a template argument
Before you start
On this page
A FixedStack<int, 8> is 40 bytes, a FixedStack<int, 16> 72, and neither allocates memory: their capacities, 8
and 16, are template arguments, values the compiler builds into each type. A
non-type template parameter: A template parameter that holds a value instead of a type, such as N in template <typename T, std::size_t N>. Its argument must be a constant expression, and each value makes a different type: std::array<int, 5> is not std::array<int, 6>. is that kind of parameter, a value such as
the 5 in std::array<int, 5> rather than a type.
You will build a fixed-capacity stack, measure what its capacity does to the object’s size, find the values in
compiled function names, turn string literals into types with a FixedString class, and pass std::vector itself
to a template that supplies its arguments. Every example is compiled with Apple clang 21 and GCC 16.
A value can be a template parameter
FixedStack<T, N> stores up to N elements of type T in a std::array<T, N> inside the object. std::size_t N
in the template line declares a value parameter, and N can be used in the class like a constant: as the array’s
size, in a static_assert, in comparisons.
// A stack whose capacity N is a template parameter: a value known at compile time, stored in the type.
#include <array>
#include <cstddef>
#include <print>
#include <vector>
template <typename T, std::size_t N>
class FixedStack {
static_assert(N > 0, "a FixedStack holds at least one element");
public:
bool push(const T& value) { // false when the stack is full
if (count_ == N) return false;
items_[count_++] = value;
return true;
}
const T& top() const { return items_[count_ - 1]; } // only for a non-empty stack
std::size_t size() const { return count_; }
static constexpr std::size_t capacity() { return N; }
private:
std::array<T, N> items_{}; // N elements inside the object: no heap allocation
std::size_t count_ = 0;
};
int main() {
FixedStack<int, 3> moves;
for (int m : {7, 4, 9, 2}) {
bool ok = moves.push(m);
std::println("push({}) -> {}", m, ok ? "stored" : "full");
}
std::println("top {}, size {} of {}", moves.top(), moves.size(), moves.capacity());
std::println("sizeof(FixedStack<int, 8>) = {}", sizeof(FixedStack<int, 8>));
std::println("sizeof(FixedStack<int, 16>) = {}", sizeof(FixedStack<int, 16>));
std::println("sizeof(FixedStack<double, 16>) = {}", sizeof(FixedStack<double, 16>));
std::println("sizeof(std::vector<int>) = {}", sizeof(std::vector<int>)); // its elements live elsewhere
} Output
push(7) -> stored push(4) -> stored push(9) -> stored push(2) -> full top 9, size 3 of 3 sizeof(FixedStack<int, 8>) = 40 sizeof(FixedStack<int, 16>) = 72 sizeof(FixedStack<double, 16>) = 136 sizeof(std::vector<int>) = 24
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined fixed_stack.cpp -o fixed_stack && ./fixed_stack
Step through the loop of the program and watch the four pushes into a stack whose N is 3:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- An empty stack. FixedStack<int, 3> moves; holds its three slots inside the object, a std::array<int, 3>, and a count that starts at 0. N is 3 in this type, fixed while compiling.
- push(7). count_ is 0, not N, so 7 goes into items_[0] and count_ becomes 1. push returns true, and the program printed push(7) -> stored.
- push(4). 4 goes into items_[1], and count_ becomes 2.
- push(9). 9 fills items_[2], the last slot, and count_ becomes 3, which is N.
- push(2) is refused. Now count_ == N, so push returns false and changes nothing: the program printed push(2) -> full. No memory is allocated to make room, because the capacity is part of the type.
- After the loop. top() reads items_[count_ - 1], 9, and the program printed top 9, size 3 of 3. capacity() returns N itself, a constant of the type.
The fourth push is refused, because count_ == N and N is 3 for this type. The sizes show where the elements live:
| Type | sizeof |
Where the elements are |
|---|---|---|
FixedStack<int, 8> |
40 bytes | Eight 4-byte ints and an 8-byte count, inside the object |
FixedStack<int, 16> |
72 bytes | Sixteen ints and the count, inside the object |
FixedStack<double, 16> |
136 bytes | Sixteen 8-byte doubles and the count |
std::vector<int> |
24 bytes | On the heap, however many there are |
A fixed capacity suits small buffers that are created often or that must not allocate, such as code running inside
an interrupt handler or a game’s inner loop, while a vector suits sizes known only at run time. The static_assert
rejects FixedStack<int, 0> at compile time, which is possible only because N is a
constant expression: An expression the compiler can evaluate while it compiles, such as 6 * 7 or a call of a constexpr function with constant arguments. Array sizes, template arguments, case labels and static_assert need one, and one may never contain undefined behaviour. More in “C++ const, constexpr, consteval and constinit”, known while compiling.
Checkpoint
Check: Values as template parameters
Each value makes a different type
The value is part of the type’s name. A function template can therefore deduce it from an argument, as
capacity_of does below, and length_of deduces the length of a plain array from a reference to it. The compiled
function names show the values, and assigning one size to the other is rejected:
capacity_of.cpp
// A function template can deduce a value parameter, here N, from the type of its argument.
#include <array>
#include <cstddef>
#include <print>
template <typename T, std::size_t N>
struct Buffer {
std::array<T, N> data{};
};
template <typename T, std::size_t N>
std::size_t capacity_of(const Buffer<T, N>&) {
return N;
}
template <typename T, std::size_t N>
constexpr std::size_t length_of(const T (&)[N]) { // a reference to an array keeps its size
return N;
}
int main() {
Buffer<int, 8> small;
Buffer<int, 16> large;
int marks[5] = {72, 91, 64, 85, 58};
std::println("{} {} {}", capacity_of(small), capacity_of(large), length_of(marks));
} assign.cpp
// Buffer<int, 8> and Buffer<int, 16> are two unrelated types: one cannot be assigned to the other.
#include <array>
#include <cstddef>
template <typename T, std::size_t N>
struct Buffer {
std::array<T, N> data{};
};
int main() {
Buffer<int, 8> small;
Buffer<int, 16> large;
small = large;
} from session import run
# Run capacity_of.cpp, list the instantiations in its object file, then try the assignment in assign.cpp.
run("""
clang++ -std=c++23 -Wall -Wextra capacity_of.cpp -o capacity_of && ./capacity_of
clang++ -std=c++23 -c capacity_of.cpp -o capacity_of.o && nm -C -j capacity_of.o | grep -E 'capacity_of|length_of'
g++-16 -std=c++23 -c capacity_of.cpp -o capacity_of-gcc.o && nm -C -j capacity_of-gcc.o | grep -E 'capacity_of|length_of'
clang++ -std=c++23 -c assign.cpp
g++-16 -std=c++23 -c assign.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra capacity_of.cpp -o capacity_of && ./capacity_of
8 16 5
$ clang++ -std=c++23 -c capacity_of.cpp -o capacity_of.o && nm -C -j capacity_of.o | grep -E 'capacity_of|length_of'
unsigned long capacity_of<int, 16ul>(Buffer<int, 16ul> const&)
unsigned long capacity_of<int, 8ul>(Buffer<int, 8ul> const&)
unsigned long length_of<int, 5ul>(int const (&) [5ul])
$ g++-16 -std=c++23 -c capacity_of.cpp -o capacity_of-gcc.o && nm -C -j capacity_of-gcc.o | grep -E 'capacity_of|length_of'
unsigned long capacity_of<int, 16ul>(Buffer<int, 16ul> const&)
unsigned long capacity_of<int, 8ul>(Buffer<int, 8ul> const&)
unsigned long length_of<int, 5ul>(int const (&) [5ul])
$ clang++ -std=c++23 -c assign.cpp
assign.cpp:13:11: error: no viable overloaded '='
13 | small = large;
| ~~~~~ ^ ~~~~~
assign.cpp:6:8: note: candidate function (the implicit copy assignment operator) not viable: no known conversion from 'Buffer<[...], 16>' to 'const Buffer<[...], 8>' for 1st argument
6 | struct Buffer {
| ^~~~~~
assign.cpp:6:8: note: candidate function (the implicit move assignment operator) not viable: no known conversion from 'Buffer<[...], 16>' to 'Buffer<[...], 8>' for 1st argument
6 | struct Buffer {
| ^~~~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c assign.cpp
assign.cpp: In function 'int main()':
assign.cpp:13:13: error: no match for 'operator=' (operand types are 'Buffer<int, 8>' and 'Buffer<int, 16>')
13 | small = large;
| ^~~~~
• there are 2 candidates
• candidate 1: 'constexpr Buffer<int, 8>& Buffer<int, 8>::operator=(const Buffer<int, 8>&)'
assign.cpp:6:8:
6 | struct Buffer {
| ^~~~~~
• no known conversion for argument 1 from 'Buffer<int, 16>' to 'const Buffer<int, 8>&'
• candidate 2: 'constexpr Buffer<int, 8>& Buffer<int, 8>::operator=(Buffer<int, 8>&&)'
• no known conversion for argument 1 from 'Buffer<int, 16>' to 'Buffer<int, 8>&&'
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 capacity_session.py
capacity_of<int, 8ul> and capacity_of<int, 16ul> are two functions in the object file, just as larger<int>
and larger<double> were in the function templates lesson. length_of<int, 5ul> worked out the array’s length
from its type, int[5], which a by-value parameter would have lost when the array decayed to a pointer. The
assignment fails with “no viable overloaded ‘=’” on Clang and “no match for ‘operator=’” on GCC, since
Buffer<int, 8> has no assignment from Buffer<int, 16>. Code that should accept every size has to be a template
over N too, or take the elements through a view whose length is a run-time value, such as
std::span: A view of a contiguous sequence of elements that it does not own (C++20, header <span>): a pointer to the first element and their number. It is made from a C array, a std::array or a std::vector, and sliced without copying. More in “Pointer arithmetic, arrays and std::span”.
Key idea
A value parameter is part of the type: Buffer<int, 8> and Buffer<int, 16> are as unrelated as two classes
with different names, and code for every size is a template over N.
Checkpoint
Check: One type per value
A value parameter has one of a few kinds of type
A non-type template parameter can have one of these types:
- an integer type,
bool, a character type or an enumeration; - a pointer, a pointer to member or a reference, whose argument must refer to an object or function with
static storage duration: The lifetime of a global, a variable at namespace scope or a static variable: it exists for the whole run of the program. Variables with thread storage duration, declared thread_local, exist once per thread instead. More in “C++ const, constexpr, consteval and constinit” (a pointer may also be null), or
std::nullptr_t; - since C++20, a floating-point type, and a structural type: A type that may be the type of a non-type template parameter: a scalar type such as int, double or a pointer, an lvalue reference, or (C++20) a literal class whose bases and non-static data members are all public, not mutable and structural themselves. class type: a literal class
whose bases and data members are all public, not
mutable, and themselves structural.
template <auto V> (C++17, paper P0127R2) leaves the type open: the parameter takes the type of its argument, just
as auto x = …; would.
// template <auto V> (C++17) takes a value of any type allowed for a template parameter, and deduces that type.
#include <print>
#include <type_traits>
#include "type_name.h"
template <auto V>
struct Constant {
static constexpr auto value = V;
};
enum class Level { low, high };
// A floating-point value as a template argument: allowed since C++20.
template <double Rate>
double with_rate(double amount) {
return amount * (1.0 + Rate);
}
int main() {
std::println("Constant<5> value type {}", type_name<decltype(Constant<5>::value)>());
std::println("Constant<'x'> value type {}", type_name<decltype(Constant<'x'>::value)>());
std::println("Constant<2.5> value type {}", type_name<decltype(Constant<2.5>::value)>());
std::println("Constant<Level::high> value type {}", type_name<decltype(Constant<Level::high>::value)>());
std::println("with_rate<0.18>(250.0) = {:.2f}", with_rate<0.18>(250.0));
// Two floating-point arguments give the same type only if their values are identical, bit for bit.
std::println("Constant<0.1 + 0.2> is Constant<0.3>? {}", std::is_same_v<Constant<0.1 + 0.2>, Constant<0.3>>);
std::println("Constant<0.0> is Constant<-0.0>? {}", std::is_same_v<Constant<0.0>, Constant<-0.0>>);
} Output
Constant<5> value type const int Constant<'x'> value type const char Constant<2.5> value type const double Constant<Level::high> value type const Level with_rate<0.18>(250.0) = 295.00 Constant<0.1 + 0.2> is Constant<0.3>? false Constant<0.0> is Constant<-0.0>? false
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined auto_param.cpp -o auto_param && ./auto_param
Constant<5>, Constant<'x'>, Constant<2.5> and Constant<Level::high> deduced int, char, double and
Level. Floating-point arguments come with a rule that surprises people: two of them make the same type only when
their values are identical, bit for bit. 0.1 + 0.2 is not exactly 0.3 in binary floating point, so
Constant<0.1 + 0.2> and Constant<0.3> are different types, and so are Constant<0.0> and Constant<-0.0>,
although 0.0 == -0.0 is true.
The kinds of value parameter grew in two editions. Walk through what each change added, and which example of this lesson uses it:
- C++17: auto
auto for a value parameter new syntax
template <auto V> takes its type from the argument, as Constant<5>, Constant<'x'> and Constant<Level::high> did (paper P0127R2; GCC 7, Clang 4).
- C++17: matching
Defaults count when a template is matched
template <typename> class Container accepts std::vector, whose second parameter has a default, as Inbox does (paper P0522R0; GCC 7, and Clang 19 by default).
- C++20: classes
Class types as value parameters new syntax
A structural class such as FixedString can be a template argument, which is how a string literal becomes part of Field's type (paper P0732R2; GCC 9, Clang 12).
- C++20: floats
Floating-point values
with_rate<0.18> takes a double; two arguments make one type only when identical bit for bit (paper P1907R1; complete in GCC 11; Clang 18, except one corner case with references).
The timeline has 4 events, from C++17: auto to C++20: floats.
Expecting equal values to make one type
Constant<0.1 + 0.2> and Constant<0.3> printed false: the two doubles differ in their last bit. For a
parameter such as a rate, take an integer number of basis points instead, 1,800 for the 0.18 of with_rate, so
that one value always means one type.
Checkpoint
Check: The types a value parameter may have
A class type carries a string literal into a type
A pointer parameter cannot take a string literal. The standard forbids a template argument that points to a string
literal object, so Tag<"price"> fails for template <const char* Name>, while a named array with static storage,
such as static constexpr char price_name[] = "price";, is accepted. C++20 offers a neater route: a structural class
that copies the characters of the literal into a public array, so that the literal becomes a constructor argument of
a class-type parameter:
literal_param.cpp
// A pointer parameter cannot take a string literal: the standard forbids pointing a template argument at one.
template <const char* Name>
struct Tag {};
int main() {
Tag<"price"> price_tag;
(void)price_tag;
} fixed_string.cpp
// C++20: a class-type value as a template argument, so a string literal can become part of a type.
#include <algorithm>
#include <cstddef>
#include <print>
#include <string_view>
#include <type_traits>
template <std::size_t N>
struct FixedString {
char text[N]{}; // a public array of char: FixedString is a structural type, so it can be a template argument
constexpr FixedString(const char (&s)[N]) { std::copy_n(s, N, text); }
constexpr std::string_view view() const { return {text, N - 1}; }
};
template <FixedString Name, typename T>
struct Field {
T value{};
static constexpr std::string_view name() { return Name.view(); }
};
template <typename F>
void print_field(const F& field) {
std::println("{} = {}", F::name(), field.value);
}
int main() {
Field<"price", double> price{249.5};
Field<"quantity", int> quantity{3};
print_field(price);
print_field(quantity);
static_assert(!std::is_same_v<Field<"price", double>, Field<"Price", double>>); // another name, another type
std::println("sizeof(price) = {}", sizeof(price)); // the name lives in the type, not in the object
} from session import run
run("""
clang++ -std=c++23 -c literal_param.cpp
g++-16 -std=c++23 -c literal_param.cpp
clang++ -std=c++23 -Wall -Wextra fixed_string.cpp -o fixed_string && ./fixed_string
g++-16 -std=c++23 -Wall -Wextra fixed_string.cpp -o fixed_string-gcc && ./fixed_string-gcc
clang++ -std=c++23 -c fixed_string.cpp -o fixed_string.o && nm -C -j fixed_string.o | grep print_field
""") Output
$ clang++ -std=c++23 -c literal_param.cpp
literal_param.cpp:6:9: error: pointer to subobject of string literal is not allowed in a template argument
6 | Tag<"price"> price_tag;
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c literal_param.cpp
literal_param.cpp: In function 'int main()':
literal_param.cpp:6:16: error: '"price"' is not a valid template argument for type 'const char*' because string literals can never be used in this context
6 | Tag<"price"> price_tag;
| ^
[exit status 1]
$ clang++ -std=c++23 -Wall -Wextra fixed_string.cpp -o fixed_string && ./fixed_string
price = 249.5
quantity = 3
sizeof(price) = 8
$ g++-16 -std=c++23 -Wall -Wextra fixed_string.cpp -o fixed_string-gcc && ./fixed_string-gcc
price = 249.5
quantity = 3
sizeof(price) = 8
$ clang++ -std=c++23 -c fixed_string.cpp -o fixed_string.o && nm -C -j fixed_string.o | grep print_field
void print_field<Field<FixedString<6ul>{"price"}, double>>(Field<FixedString<6ul>{"price"}, double> const&)
void print_field<Field<FixedString<9ul>{"quantity"}, int>>(Field<FixedString<9ul>{"quantity"}, int> const&)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strings_session.py
In template <FixedString Name, typename T>, the parameter’s type is the class template FixedString with no
arguments, so class template argument deduction (CTAD): The C++17 rule that lets a declaration such as std::pair p{1, 2.0} leave out a class template's arguments: the compiler deduces them from the initialiser, treating each constructor and deduction guide as a function template. More in “Class templates and argument deduction (CTAD)” works out N
from the literal. Step through what happens to "price":
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A literal, a class parameter. The parameter is template <FixedString Name, …>: its type is the class template FixedString, written without arguments. The argument is the literal "price", 5 letters and the final zero.
- CTAD finds N. Class template argument deduction runs on the constructor FixedString(const char (&s)[N]). A reference keeps the array whole, so N is 6.
- The value is made. The constructor copies the six characters into the public array text. That object, FixedString<6>{"price"}, is the template argument: a value the compiler keeps.
- A type with a name inside. Field<"price", double> is now a type of its own. nm -C showed the text in its symbol, Field<FixedString<6ul>{"price"}, double>, and "Price" would make another type.
- Nothing in the object. An object of the type holds only its double, so sizeof(price) printed 8. F::name() reads the name from the type, and print_field printed price = 249.5.
N is 6 for "price", with its terminating zero. Each name makes its own type, so Field<"price", double> and
Field<"Price", double> differ, as the static_assert proves, and the symbol names show the text,
FixedString<6ul>{"price"}, inside the type. The object holds only the double: sizeof(price) is 8. The name
takes no room in any object, code can select or compare fields by name while compiling, and print_field printed
it from the type alone.
Checkpoint
Check: String literals through a class parameter
A template template parameter takes a template
Sometimes a template should let its user choose a container, not an element type. A
template template parameter: A template parameter that takes a template, such as template <typename...> class Container, so that a call can pass std::vector itself and the template writes Container<int> with arguments of its own choosing. declares a parameter that is itself a
template: template <typename...> class Container accepts any class template whose parameters are all types, and the
code then writes Container<int> with arguments of its own choosing.
// A template template parameter takes a template, such as std::vector, and supplies its arguments itself.
#include <deque>
#include <list>
#include <print>
#include <utility>
#include <vector>
#include "type_name.h"
// Container is a template with any number of type parameters; squares() fills a Container<int>.
template <template <typename...> class Container>
Container<int> squares(int n) {
Container<int> out;
for (int i = 1; i <= n; ++i) out.push_back(i * i);
return out;
}
// One type parameter is enough to accept std::vector, whose allocator parameter has a default (C++17).
template <typename T, template <typename> class Container = std::vector>
class Inbox {
public:
void add(T item) { items_.push_back(std::move(item)); }
std::size_t size() const { return items_.size(); }
private:
Container<T> items_;
};
int main() {
auto v = squares<std::vector>(4);
auto d = squares<std::deque>(4);
auto l = squares<std::list>(4);
std::println("{} {}", type_name<decltype(v)>(), v);
std::println("{} {}", type_name<decltype(d)>(), d);
std::println("{} {}", type_name<decltype(l)>(), l);
Inbox<int> plain; // a std::vector<int> inside
Inbox<int, std::deque> queue; // a std::deque<int> inside
plain.add(1);
queue.add(2);
queue.add(3);
std::println("{} {}", plain.size(), queue.size());
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra container_param.cpp -o container_param && ./container_param
g++-16 -std=c++23 -Wall -Wextra container_param.cpp -o container_param-gcc && ./container_param-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra container_param.cpp -o container_param && ./container_param std::vector<int> [1, 4, 9, 16] std::deque<int> [1, 4, 9, 16] std::list<int> [1, 4, 9, 16] 1 2 $ g++-16 -std=c++23 -Wall -Wextra container_param.cpp -o container_param-gcc && ./container_param-gcc std::vector<int> [1, 4, 9, 16] std::deque<int> [1, 4, 9, 16] std::__cxx11::list<int> [1, 4, 9, 16] 1 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 container_param_session.py
squares<std::vector>(4), squares<std::deque>(4) and squares<std::list>(4) passed the templates themselves,
without arguments, and got three different container types holding the same four squares; C++23’s std::println
printed each one as a range. Step through the calls and the two Inbox objects:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A template as an argument. squares is declared template <template <typename...> class Container>: its parameter takes a class template whose parameters are all types. std::vector is passed as it is, with no arguments.
- The template supplies int. The body writes Container<int> out;, so for std::vector it builds a std::vector<int> and pushes 1, 4, 9 and 16 into it.
- The same body, a deque. squares<std::deque>(4) runs the same body with another template: out is a std::deque<int> holding the same four squares.
- And a list. squares<std::list>(4) gives a std::list<int>. Each call is a separate instantiation of squares, one per template passed in.
- A default, and one parameter. Inbox declares template <typename> class Container = std::vector, with one type parameter, and still accepts std::vector and std::deque, whose second parameter has a default: C++17 counts defaults when it matches. Inbox<int> held 1 item, the deque inbox 2.
Inbox declares its parameter as template <typename> class Container, with one type parameter, yet accepts
std::vector and std::deque, which have two (the second is the allocator, with a default). That matching, where
default arguments count, is a C++17 rule (paper P0522R0), implemented since GCC 7 and since Clang 19. With an older
Clang, write template <typename...> or the full parameter list. Since C++17 the keyword in that position may also
be typename instead of class.
Checkpoint
Check: Template template parameters
Practise: a RingBuffer with a compile-time capacity
ring_buffer.h declares RingBuffer<T, N>, a first-in, first-out queue that holds at most N elements in a
std::array: a ring buffer: A fixed-size array used as a queue whose write position wraps from the last slot back to the first, so that once the array is full each new item replaces the oldest one. More in “C++ memory-bug hunt: ten broken programs”. push adds at the back and refuses when the buffer is
full; pop takes the oldest element from the front. The member variables head_ (where the oldest element is) and
count_ (how many there are) are ready for you; write push and pop.
C header · ring_buffer.h
// Starter code for "Your turn": a first-in, first-out queue with room for exactly N elements.
#pragma once
#include <array>
#include <cstddef>
#include <optional>
template <typename T, std::size_t N>
class RingBuffer {
static_assert(N > 0, "a RingBuffer holds at least one element");
public:
// Adds value at the back. Returns false, and changes nothing, when the buffer is full.
bool push(const T& value) {
// TODO
(void)value;
return false;
}
// Removes the element at the front and returns it; std::nullopt when the buffer is empty.
std::optional<T> pop() {
// TODO
return std::nullopt;
}
bool empty() const { return count_ == 0; }
bool full() const { return count_ == N; }
std::size_t size() const { return count_; }
static constexpr std::size_t capacity() { return N; }
private:
std::array<T, N> slots_{};
std::size_t head_ = 0; // index of the front element
std::size_t count_ = 0; // how many elements are stored
}; C++ · ring_buffer_tests.cpp
// Sample tests for ring_buffer.h. Build: clang++ -std=c++23 -Wall -Wextra ring_buffer_tests.cpp -o ring_buffer_tests
#include <print>
#include <string>
#include "ring_buffer.h"
int checks = 0;
int failures = 0;
void check(bool ok, const char* what) {
++checks;
if (!ok) {
++failures;
std::println("FAIL {}", what);
}
}
int main() {
RingBuffer<int, 3> q;
check(q.empty() && !q.full() && q.capacity() == 3, "a new buffer is empty and holds three");
check(q.push(1) && q.push(2) && q.push(3), "three pushes fit");
check(q.full() && !q.push(4), "a fourth push is refused");
check(q.pop() == 1 && q.pop() == 2, "pop() returns the oldest elements first");
check(q.push(4) && q.push(5), "the freed slots are used again");
check(q.pop() == 3 && q.pop() == 4 && q.pop() == 5, "the order survives the wrap-around");
check(q.empty() && q.pop() == std::nullopt, "an empty buffer pops std::nullopt");
RingBuffer<std::string, 1> one;
check(one.push("only") && !one.push("more"), "a capacity of one holds one element");
check(one.pop() == "only" && one.push("again") && one.size() == 1, "and can be refilled");
static_assert(sizeof(RingBuffer<char, 64>) > sizeof(RingBuffer<char, 8>), "the capacity is part of the object");
std::println("{} of {} checks passed", checks - failures, checks);
return failures == 0 ? 0 : 1;
} from session import run
# Sample tests built against the starter: push() refuses everything and pop() finds nothing yet.
run("""
clang++ -std=c++23 -Wall -Wextra ring_buffer_tests.cpp -o ring_buffer_tests && ./ring_buffer_tests
g++-16 -std=c++23 -Wall -Wextra ring_buffer_tests.cpp -o ring_buffer_tests-gcc && ./ring_buffer_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra ring_buffer_tests.cpp -o ring_buffer_tests && ./ring_buffer_tests FAIL three pushes fit FAIL a fourth push is refused FAIL pop() returns the oldest elements first FAIL the freed slots are used again FAIL the order survives the wrap-around FAIL a capacity of one holds one element FAIL and can be refilled 2 of 9 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra ring_buffer_tests.cpp -o ring_buffer_tests-gcc && ./ring_buffer_tests-gcc FAIL three pushes fit FAIL a fourth push is refused FAIL pop() returns the oldest elements first FAIL the freed slots are used again FAIL the order survives the wrap-around FAIL a capacity of one holds one element FAIL and can be refilled 2 of 9 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ring_buffer_session.py
The two checks that pass already only look at a new, empty buffer. Indexes wrap around the end of the array: after
pushes and pops, the newest element can sit in a slot before the oldest one. The diagram shows the tests’
RingBuffer<int, 3> in that state, after pushing 1, 2 and 3, popping twice and pushing 4:
How a ring buffer's indexes wrap around
Text description of the diagram
The diagram shows the RingBuffer<int, 3> of the sample tests, a ring buffer whose three slots are a std::array inside the object, after these steps: push 1, 2 and 3, pop twice (1 and 2 come out), then push 4.
- The slots hold 4 in slot 0, nothing in slot 1 and 3 in slot 2. The newest element, 4, sits before the oldest one, 3, because the write position wrapped around past the last slot.
- The buffer keeps two numbers: head, the index of the oldest element, which is 2, and count, the number of stored elements, which is 2.
- pop() returns the element at head, 3, and moves head on by one, modulo the capacity: (2 + 1) % 3 = 0.
- push(x) writes to the slot (head + count) % 3, which is (2 + 2) % 3 = 1, the only free slot.
The same sequence of the tests runs below, every push and pop in order, with front for head_ and rear for the
slot the next push writes, (head_ + count_) % N. Watch the fourth push being refused and the write position wrap
from slot 2 to slot 0:
- being moved or written
Pseudo-code
-
enqueue(x): if count = capacity: error "full" -
a[rear] ← x; rear ← (rear + 1) mod capacity; count ← count + 1 -
dequeue(): if count = 0: error "empty" -
x ← a[front]; front ← (front + 1) mod capacity; count ← count − 1; return x
A circular queue with 3 slots. front is where the next item leaves, rear where the next one goes; both wrap around to slot 0 after slot 2.
When it finishes: 0 items queued.
Try your own input
Compute the write position from head_ and count_ with % N, and move head_ forward the same way in pop. The
tests include a buffer with a capacity of one, the case where an off-by-one error shows first. Then try a
variation: when N is a power of two, i % N equals i & (N - 1), a mask: An integer with 1 bits exactly where an operation should act, such as 1u << 3 for bit 3. flags | mask sets those bits, flags & ~mask clears them, flags ^ mask flips them and flags & mask keeps only them. More in “C++ bitwise operators and the <bit> header” that is cheaper
than a division. Because N is known at compile time, static_assert((N & (N - 1)) == 0) can insist on it.
The questions below ask for capacities, sizes, deduced values and the templates a parameter accepts. The one you know least comes first, and each choice explains itself when you pick it:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Are RingBuffer<int, 8> and RingBuffer<int, 16> the same type? What follows from your answer?
No. A non-type template argument is part of the type, so each capacity makes a separate class, compiled separately,
with its own member functions. Three things follow. An object of one cannot be assigned to or passed where the other
is expected: a function taking RingBuffer<int, 8>& rejects the 16-slot buffer, so code that should work for any
capacity has to be a template over N, or take a view such as std::span. The capacity costs nothing at run time:
the object holds only the elements and the indexes, and the compiler can check the size with a static_assert. And
every capacity the program uses adds its own copy of the code, so dozens of capacities make a larger binary than one
run-time-sized container.
A follow-up usually checks that the answer carries over to new code. Try these two:
Checkpoint
Interview round: two follow-up questions
Two harder questions test the design trade-offs: a capacity that must come from a file at start-up, and field names carried in types:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
The capacity of a message queue declared RingBuffer<Message, N> must now come from a configuration file read at start-up. Why can't the value from the file be N, and how would you redesign the buffer so that both compile-time and run-time capacities work without writing the logic twice?
-
Mid to senior
You want Field<"price", double> so that each field's name is part of its type. Why does template <const char* Name> refuse "price", how does a C++20 FixedString parameter make it work, and what does putting names into types cost?
Key takeaways
- A non-type template parameter is a value known at compile time, such as
Ninstd::array<T, N>; it can size arrays inside the object and be checked withstatic_assert. - Each value makes a different type:
FixedStack<int, 8>is 40 bytes andFixedStack<int, 16>72, they do not convert to each other, and function names carry the values (capacity_of<int, 8ul>). - Function templates can deduce a value from an argument’s type, including the length of an array passed by reference.
template <auto V>(C++17) deduces the value’s type; C++20 adds floating-point values, which match only when identical bit for bit, and structural class types.- A string literal cannot be a pointer argument, but a C++20
FixedStringclass parameter can hold it, making names part of types without adding a byte to the objects. template <typename...> class Containeraccepts templates such asstd::vector; since C++17 default arguments lettemplate <typename> classmatch them too.
Terms in this lesson
- non-type template parameter
- A template parameter that holds a value instead of a type, such as N in template <typename T, std::size_t N>. Its argument must be a constant expression, and each value makes a different type: std::array<int, 5> is not std::array<int, 6>.
- structural type
- A type that may be the type of a non-type template parameter: a scalar type such as int, double or a pointer, an lvalue reference, or (C++20) a literal class whose bases and non-static data members are all public, not mutable and structural themselves.
- template template parameter
- A template parameter that takes a template, such as template <typename...> class Container, so that a call can pass std::vector itself and the template writes Container<int> with arguments of its own choosing.
References
- Working Draft, Programming Languages: C++ (N4950), 13.2 Template parameters, 13.4.3 Template non-type arguments, 13.4.4 Template template arguments (ISO/IEC JTC1/SC22/WG21)
- P0127R2: Declaring non-type template parameters with auto (ISO/IEC JTC1/SC22/WG21)
- P0732R2: Class Types in Non-Type Template Parameters (ISO/IEC JTC1/SC22/WG21)
- P1907R1: Inconsistencies with non-type template parameters (ISO/IEC JTC1/SC22/WG21)
- P0522R0: Matching of template template-arguments excludes compatible templates (ISO/IEC JTC1/SC22/WG21)
- C++ Standards Support in GCC (GNU Project)
- C++ Support in Clang (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress