C++ Variadic Templates and Fold Expressions Explained
Variadic templates take any number of arguments as a parameter pack, and C++17 fold expressions apply one operator across it. Plus non-type template parameters.
- Course: C++ study plan
- Module: Templates and generic programming
- Kind: Lesson
- Reading time: 14 min
- Runtime: Clang 18 · C++20
What is a variadic template in C++?
A variadic template is a template that accepts any number of arguments through a parameter pack: template <typename... Ts> void f(const Ts&... args). args... expands the pack into a comma-separated list, sizeof...(args) counts it, and a pack may be empty. Before C++17 each element was processed by recursion; fold expressions such as (args + ...) now do it in one line.
Lesson
A template parameter does not have to be a type. std::array<int, 4> takes a number, std::bitset<64> takes a number, and std::tuple<int, std::string, double> takes as many types as you give it. Both ideas — a non-type parameter that is a compile-time value, and a parameter pack that stands for any number of arguments — are what let a container know its size without storing it and a print function accept three arguments of three types. This lesson covers non-type parameters, packs and sizeof..., recursion over a pack, and the C++17 fold expressions that replace most of that recursion with one line.
Non-type template parameters
#include <array>
#include <cstddef>
template <typename T, std::size_t N>
class Ring { // a fixed-capacity buffer that overwrites its oldest entry
public:
static constexpr std::size_t capacity = N;
void push(const T& value) {
data_[(start_ + size_) % N] = value;
if (size_ < N) ++size_;
else start_ = (start_ + 1) % N; // full: the slot we just wrote was the oldest
}
std::size_t size() const { return size_; }
const T& at(std::size_t i) const { return data_[(start_ + i) % N]; } // 0 = oldest
private:
std::array<T, N> data_{};
std::size_t start_ = 0;
std::size_t size_ = 0;
};
Ring<int, 4> recent; // N = 4, baked into the type
std::size_t N declares a parameter that is a value, and every use of N inside the class is a compile-time constant: it sizes the std::array, it is the modulus, and it can initialise a static constexpr member. There is no size field for the capacity because the capacity is part of the type — sizeof(std::array<int, 4>) is 16 bytes, exactly four ints, which is the reason std::array exists (Module 6). Ring<int, 4> and Ring<int, 8> are different types, as unrelated as Stack<int> and Stack<long> were in lesson 2.
The argument must be a constant expression. int n; std::cin >> n; Ring<int, n> r; is refused with the value of 'n' is not usable in a constant expression; a const int n = 4; is fine because its initialiser is a constant, while const int n = read(); is not. When the size genuinely comes from input, either dispatch at run time to a few fixed instantiations — if (n == 4) run<4>(); else run<8>(); — or use a std::vector.
Allowed kinds: integers and enumerations, pointers and references to objects with static storage, std::nullptr_t, and since C++20 floating-point values and class types whose members are all public. template <auto V> lets the parameter's type be deduced from the argument. Non-type parameters can also be deduced from a call: template <typename T, std::size_t N> std::size_t length(const T (&)[N]) { return N; } learns the length of a built-in array from the argument's type, which is how std::size(arr) works.
Parameter packs
#include <iostream>
template <typename... Ts> // Ts is a pack of types
void count_args(const Ts&... args) { // args is a pack of parameters
std::cout << sizeof...(args) << " arguments\n";
}
count_args(1, 2.5, "three"); // Ts = {int, double, char[6]}: 3 arguments
count_args(); // an empty pack is legal: 0 arguments
typename... Ts declares a pack; Ts... or args... expands it — the compiler replaces the expansion with a comma-separated list, one entry per element. sizeof... gives the count without expanding. Expansion can carry a pattern: f(args)... becomes f(a1), f(a2), f(a3), and std::forward<Ts>(args)... is the pattern std::make_unique uses to pass every constructor argument through unchanged. A pack can be empty, which matters when you write the base case below.
Recursion over a pack
Before C++17 the only way to do something with each element was to peel one off and recurse:
void print() { std::cout << '\n'; } // base case: the empty pack
template <typename First, typename... Rest>
void print(const First& first, const Rest&... rest) {
std::cout << first;
if (sizeof...(rest) > 0) std::cout << ' ';
print(rest...); // one shorter each time
}
print("total", 3, 2.5); // total 3 2.5
print("total", 3, 2.5) instantiates print<char[6], int, double>, which calls print<int, double>, then print<double>, then the non-template print(). Four functions for one call, and the base case is not optional — without it the last call print() has no candidate and the error names it. if constexpr (sizeof...(rest) > 0) print(rest...); (lesson 6) removes the need for the base case by not compiling the recursive call when the pack is empty. The peel-one-off shape is still the right one when the first element is special: a separator argument, a format string, a stream.
Fold expressions (C++17)
A fold applies one binary operator across the whole pack in a single expression:
template <typename... Ts>
auto sum(Ts... xs) { return (xs + ...); } // unary right fold: x1 + (x2 + (x3))
template <typename... Ts>
auto sum_from_zero(Ts... xs) { return (0 + ... + xs); } // binary left fold: ((0 + x1) + x2) + x3
template <typename... Ts>
bool all(Ts... flags) { return (... && flags); } // true for an empty pack
template <typename... Ts>
void print_all(const Ts&... xs) {
((std::cout << xs << ' '), ...); // fold over the comma operator: one statement per element
std::cout << '\n';
}
| Form | Spelling | Expands to |
|---|---|---|
| unary right fold | (pack op ...) | x1 op (x2 op (x3)) |
| unary left fold | (... op pack) | ((x1 op x2) op x3) |
| binary right fold | (pack op ... op init) | x1 op (x2 op init) |
| binary left fold | (init op ... op pack) | (init op x1) op x2 |
The parentheses are part of the syntax. Left and right differ only when the operator is not associative — (xs - ...) and (... - xs) give different answers — and the binary forms supply a starting value, which is what makes them legal on an empty pack: (xs + ...) with no arguments is a compile error, (0 + ... + xs) is 0. Only &&, || and , may fold an empty pack unaided, giving true, false and nothing. The comma fold is the everyday one: it runs an expression once per element, in order, and replaces the whole recursive print. (std::cout << ... << xs) is a binary left fold over << that prints every element with no separator.
Variadic class templates
std::tuple<Ts...> stores one member per pack element; std::variant<Ts...> stores one of them at a time (Module 15). The one variadic class you will write yourself is the visitor helper:
template <typename... Fs>
struct Overloaded : Fs... { // inherit from every lambda
using Fs::operator()...; // and pull in every call operator
};
Passed to std::visit, it lets a handful of lambdas act as one overload set; Module 15 shows the whole idiom.
Pitfalls
| Mistake | What happens |
|---|---|
Ring<int, n> with a run-time n | Not a constant expression: compile error. |
Passing Ring<int, 4> to a function taking Ring<int, 8>& | Different types: no match. |
(xs + ...) on an empty pack | Compile error; use (0 + ... + xs). |
| Recursion without a base case | no matching function for call to 'print()'. |
f(args...) when you meant f(args)... | Passes all arguments to one call instead of calling once per argument. |
sizeof(Ts)... for sizeof...(Ts) | The former is a list of sizes, not a count. |
Reading (xs - ...) as left to right | It is a right fold: x1 - (x2 - x3). |
Key takeaways
- A non-type parameter is a compile-time value baked into the type;
std::array<T, N>stores no size becauseNis the type. - A template argument must be a constant expression; dispatch run-time values to a few fixed instantiations.
typename... Tsdeclares a pack,Ts...expands it,sizeof...(Ts)counts it, and a pack may be empty.- Recursion peels one element per call and needs a base case;
if constexprcan replace it. - Fold expressions apply one operator across a pack; the binary forms are safe on an empty pack, and the comma fold runs code per element.
Common questions
What is a fold expression in C++17?
A fold expression applies one binary operator across a whole parameter pack: (xs + ...) is a unary right fold, (... + xs) a unary left fold, and (0 + ... + xs) a binary fold with a starting value. The parentheses are part of the syntax. The comma fold ((std::cout << xs << ' '), ...) runs an expression once per element, in order.
What happens when you fold an empty parameter pack?
A unary fold over an empty pack is a compile error for most operators, so (xs + ...) with no arguments fails. Only &&, || and the comma operator have defaults: true, false and nothing. A binary fold supplies its own initial value, so (0 + ... + xs) is simply 0 for an empty pack.
What is a non-type template parameter?
A template parameter that is a compile-time value rather than a type, as in template <typename T, std::size_t N> class Ring. The value is part of the type, so Ring<int, 4> and Ring<int, 8> are different types and the size needs no data member. The argument must be a constant expression; a value read at run time cannot be used.
How does std::array know its size without storing it?
The size is a non-type template parameter: std::array<int, 4> has N = 4 baked into its type, so the object is exactly the size of four ints, with no size field. A function template can deduce N from a built-in array's type too, which is how std::size(arr) works.
How do you recurse over a parameter pack?
Peel off the first argument and call the function again with the rest: template <typename First, typename... Rest> void print(const First& first, const Rest&... rest) prints first and calls print(rest...). A non-template print() is the base case for the empty pack; if constexpr (sizeof...(rest) > 0) can replace it.
Exercises
Ring<T, N> with the capacity chosen at run time
Write template <typename T, std::size_t N> class Ring — a fixed-capacity buffer over a std::array<T, N> that overwrites its oldest entry when full — with push, size, at(i) (0 is the oldest) and a static constexpr std::size_t capacity = N. Then a function template run<N>() that prints capacity=<N> from Ring<int, N>::capacity and processes commands.
The first line is the capacity, one of 2, 4 or 8. It is read at run time, so it cannot be a template argument directly: dispatch to run<2>(), run<4>() or run<8>(). Commands: push <v>; size (print size=<k>); oldest (print oldest=<v> or empty); print (print ring: <oldest> … <newest> or ring: (empty)).
Input: the capacity, then commands. Output: capacity=<N> first, then one line per size, oldest and print.
4
push 1
push 2
push 3
push 4
push 5
print
size
oldest
prints
capacity=4
ring: 2 3 4 5
size=4
oldest=2A variadic report built from fold expressions
Write template <typename... Ts> void report(Ts... xs) that prints one line: args=<count> [<x1> <x2> …] sum=<s> all_positive=<yes|no>. The count is sizeof...(xs); the values are printed by a fold over the comma operator with a single space between them (and [] for no values); sum is a binary fold (0LL + ... + xs) so that an empty pack sums to 0 and large values do not overflow int; all_positive is the fold (... && (xs > 0)), which is yes for an empty pack. No loops over the pack, no recursion — folds only.
Read n, then n lines of <k> <v1> … <vk> with k from 0 to 4 and integer values. A pack's size is fixed at compile time, so read the values into an array and dispatch on k: report(), report(v[0]), report(v[0], v[1]) and so on — five instantiations of one template.
Input: n, then n lines. Output: one line per input line.
4
3 1 2 3
0
2 -1 5
1 7
prints
args=3 [1 2 3] sum=6 all_positive=yes
args=0 [] sum=0 all_positive=yes
args=2 [-1 5] sum=4 all_positive=no
args=1 [7] sum=7 all_positive=yesIn this module: Templates and generic programming
- Function templates — one definition, every type
- Class templates — Stack<T>, Pair<A, B> and deduction
- Specialisation — when one type needs its own version
- Non-type and variadic templates — numbers and packs as parameters (this lesson)
- Concepts and constraints — saying what T must be
- Type traits and metaprogramming — computing with types
- Checkpoint — Templates and generic programming
← Specialisation — when one type needs its own version · Concepts and constraints — saying what T must be →