Master Variadic Templates to Write Truly Generic Code
Expand parameter packs, recurse over type lists, and build generic utilities like type-safe loggers and forwarding wrappers using C++11 variadic templates.
By the end of this page, you will understand what a parameter pack is and why it exists, how to expand packs recursively and with C++17 fold expressions, and how to apply these tools to real-world patterns: perfect forwarding, aggregation, and per-element transformation.
What and Why
Before C++11, writing a function that accepted an arbitrary number of arguments of different types required either C-style variadic arguments (va_args) β type-unsafe and fragile β or a combinatorial explosion of overloads: func(T1), func(T1, T2), func(T1, T2, T3), and so on.
Variadic templates eliminate both problems. A parameter pack is a compile-time placeholder for zero or more template arguments. Pack expansion lets you apply an operation to every element of that pack, all resolved at compile time with full type safety and zero runtime overhead.
The mental model that helps most: a parameter pack is not a container. It is a syntactic list of types or values that you explode into concrete code at marked expansion points. The compiler generates a distinct version of your template for each unique set of argument types β the pack is a shorthand for that process.
Step by Step
Declaring and Inspecting a Pack
The ... to the left of a name in a declaration means "this is a pack." The ... to the right of a name means "expand this pack here."
#include <cstddef>
#include <iostream>
template <typename... Ts> // Ts is a type parameter pack
void show(Ts... args) { // args is a function parameter pack matching Ts
std::cout << sizeof...(args) << " argument(s)\n";
}
int main() {
show(); // 0 argument(s)
show(1, 2.0, "three"); // 3 argument(s)
}sizeof...(pack) is a compile-time constant expression. It works on both type packs and value packs.
Recursive Expansion β The C++11 Idiom
The original way to process each element is recursive template instantiation: peel off the first argument, do something with it, then recurse on the rest.
#include <iostream>
// Base case: nothing left to print
void print() {}
// Recursive case: handle one element, then recurse
template <typename T, typename... Rest>
void print(T first, Rest... rest) {
std::cout << first << '\n';
print(rest...); // expand the remaining pack
}
int main() {
print(42, 3.14, "hello");
// prints: 42 / 3.14 / hello
}The call chain is entirely determined at compile time. When the pack empties, the zero-argument base case is selected. No runtime recursion, no overhead.
Fold Expressions β The C++17 Upgrade
C++17 introduced fold expressions, which collapse the recursive pattern into a single expression. There are four forms; the most common two:
| Form | Evaluation |
|---|---|
(... op pack) | Unary left: ((p0 op p1) op p2) ... |
(pack op ...) | Unary right: (p0 op (p1 op p2)) ... |
#include <iostream>
template <typename... Ts>
auto sum(Ts... args) {
return (... + args); // unary left fold over +
}
template <typename... Ts>
void print_all(Ts... args) {
// Binary left fold: feed each arg into cout
((std::cout << args << ' '), ...);
std::cout << '\n';
}
int main() {
std::cout << sum(1, 2, 3, 4) << '\n'; // 10
print_all(42, 3.14, "hello"); // 42 3.14 hello
}The comma-fold (expr, ...) guarantees left-to-right evaluation order in C++17 and later.
Common Patterns
Perfect Forwarding a Pack
This is the single most important variadic pattern in production code. It lets you relay any number of arguments to another function while preserving their value categories:
#include <memory>
template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
struct Point { int x, y; };
int main() {
auto p = make<Point>(3, 4);
}std::forward<Args>(args)... expands both packs β Args and args β in lockstep. This is the internal mechanism behind std::make_unique, std::make_shared, and every emplace method in the standard library.
Aggregating Over a Pack
Fold expressions make "reduce" operations trivial:
#include <iostream>
template <typename... Ts>
bool all_positive(Ts... args) {
return (... && (args > 0));
}
template <typename... Ts>
auto product(Ts... args) {
return (... * args);
}
int main() {
std::cout << all_positive(1, 2, 3) << '\n'; // 1
std::cout << all_positive(1, -2, 3) << '\n'; // 0
std::cout << product(2, 3, 4) << '\n'; // 24
}Applying a Callable to Each Element
The comma-fold pattern is the idiomatic "for-each over a pack":
#include <iostream>
template <typename F, typename... Ts>
void for_each_arg(F&& f, Ts&&... args) {
(f(std::forward<Ts>(args)), ...);
}
int main() {
for_each_arg(
[](auto x) { std::cout << x << '\n'; },
10, 3.14, "hello"
);
}Indexed Pack Expansion with std::index_sequence (C++14)
Sometimes you need a compile-time index alongside each pack element β for example, to access std::tuple elements by position. std::index_sequence generates a compile-time integer pack that you expand in lockstep with the type pack.
#include <iostream>
#include <tuple>
#include <utility>
template <typename Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& t, std::index_sequence<Is...>) {
((std::cout << '[' << Is << "] = " << std::get<Is>(t) << '\n'), ...);
}
template <typename... Ts>
void print_tuple(const std::tuple<Ts...>& t) {
print_tuple_impl(t, std::index_sequence_for<Ts...>{});
}
int main() {
auto t = std::make_tuple(42, 3.14, "hello");
print_tuple(t);
// [0] = 42
// [1] = 3.14
// [2] = hello
}Pass std::index_sequence<Is...> as a dummy parameter so the compiler deduces the integer pack Is..., then expand Is and the tuple in lockstep with std::get<Is>(t).
Key helpers in <utility> (C++14):
std::make_index_sequence<N>β producesindex_sequence<0, 1, ..., N-1>std::index_sequence_for<Ts...>β shorthand formake_index_sequence<sizeof...(Ts)>std::integer_sequence<T, Ints...>β generalised form for arbitrary integer types
Type List Operations
A parameter pack carries its element types at compile time, making it a natural "type list." Two operations that come up often are extracting the type at a given index and testing whether a pack contains a particular type.
Type at index N β use recursive partial specialization to peel one element per step:
#include <type_traits>
// Primary template: peel one type per recursion step
template <std::size_t N, typename T, typename... Ts>
struct type_at : type_at<N - 1, Ts...> {};
// Base case: N == 0, the answer is T
template <typename T, typename... Ts>
struct type_at<0, T, Ts...> {
using type = T;
};
template <std::size_t N, typename... Ts>
using type_at_t = typename type_at<N, Ts...>::type;
// Usage
static_assert(std::is_same_v<type_at_t<1, int, double, char>, double>);
static_assert(std::is_same_v<type_at_t<2, int, double, char>, char>);Type membership check β a single fold expression over || replaces the entire recursive idiom:
#include <type_traits>
template <typename Needle, typename... Haystack>
inline constexpr bool contains_type_v =
(std::is_same_v<Needle, Haystack> || ...);
static_assert( contains_type_v<double, int, double, char>);
static_assert(!contains_type_v<float, int, double, char>);The unary right fold (expr || ...) short-circuits: as soon as one is_same_v is true the compiler knows the result (all of this is evaluated at compile time by constant folding). When the pack is empty, the fold of || over zero elements yields false β which is the correct semantics for "not found."
Non-Type Parameter Packs (C++11)
Template parameter packs are not limited to types. You can also pack non-type values such as integers. Non-type packs are declared with a concrete type followed by ...:
template <int... Ns>
constexpr int sum_ints() {
return (... + Ns); // unary left fold over +
}
static_assert(sum_ints<1, 2, 3, 4>() == 10);Any operator that works in a fold expression works over a non-type pack:
template <int... Ns>
constexpr bool all_positive() {
return ((Ns > 0) && ...);
}
template <std::size_t... Ns>
constexpr std::size_t product() {
return (Ns * ...);
}
static_assert( all_positive<1, 2, 3>());
static_assert(!all_positive<1, -1, 3>());
static_assert(product<2, 3, 4>() == 24);Non-type packs are particularly useful when you need a fixed sequence of compile-time constants β for example, bit-flag masks, dimension sizes, or port numbers β passed as template arguments rather than runtime values.
Capturing Packs in Lambdas (C++20)
Before C++20, capturing a parameter pack in a lambda required expanding it into a copy-capture list with [args...], which copies every element. C++20 introduced lambda init-capture packs: you write [...name = expr] to give each captured element an explicit initializer, enabling moves and other transformations.
The canonical use-case is deferring a perfectly-forwarded call without copying temporaries:
#include <functional>
#include <iostream>
#include <string>
#include <utility>
// Capture forwarded args by move; invoke later
template <typename F, typename... Args>
auto defer(F f, Args&&... args) {
// [...a = std::forward<Args>(args)] captures each arg
// by move-constructing an individual closure variable
return [f, ...a = std::forward<Args>(args)]() mutable {
return f(std::move(a)...);
};
}
int main() {
auto call_later = defer(
[](std::string s, int n) { std::cout << s << ' ' << n << '\n'; },
std::string{"hello"}, 42
);
call_later(); // hello 42
}Key points sourced from cppreference and the Stanford C++20 parameter pack idioms article:
[...a = std::forward<Args>(args)]is syntactically a lambda init-capture pack (C++20). The ellipsis before the name tells the compiler to create one closure variable per pack element.- Each initializer is a pack-expansion pattern; when expanded,
std::forward<Args>(args)preserves rvalue-ness, so temporaries are moved rather than copied into the closure. mutableis required when the body needs to move out of the captured variables (closure variables areconstby default in a non-mutable lambda).- Inside the body,
std::move(a)...expands the captured pack back into a call argument list.
Without this C++20 feature you had to wrap the pack in a std::tuple, then unpack it with std::apply at call time β noticeably more boilerplate.
Variadic Bases: Mixin Aggregation
Expanding a pack in a base-class list is legal since C++11 and lets you compose behaviour at compile time by inheriting from an arbitrary set of mixin types:
#include <iostream>
// Two independent capability mixins
struct Printable {
void print() const { std::cout << "printable\n"; }
};
struct Serializable {
void serialize() const { std::cout << "serialized\n"; }
};
// Widget inherits from every mixin in the pack
template <typename... Mixins>
class Widget : public Mixins... {
public:
// Forward each mixin's constructor
Widget(const Mixins&... m) : Mixins(m)... {}
};
int main() {
Widget<Printable, Serializable> w{Printable{}, Serializable{}};
w.print(); // from Printable
w.serialize(); // from Serializable
}The pattern can be combined with CRTP (Curiously Recurring Template Pattern) so that each mixin can call back into the derived type. According to Fluent C++, this "variadic CRTP" requires passing a template template parameter rather than a plain typename, because the mixin needs to be instantiated with the concrete derived type:
// Each skill is a template taking one typename (the derived class)
template <typename Derived>
struct Stringify {
std::string to_string() const {
// Can call Derived's public interface here
return "Widget";
}
};
// template<typename> typename... Skills is a pack of template-templates
template <template <typename> typename... Skills>
class NTWidget : public Skills<NTWidget<Skills...>>... {
public:
void demo() const {
// All skill methods are in scope
(Skills<NTWidget<Skills...>>::to_string(), ...);
}
};
using RichWidget = NTWidget<Stringify>;
RichWidget rw;
// rw.to_string() available from the Stringify mixinThe template<typename> typename... Skills syntax (C++17; use template<typename> class... in C++14) ensures each entry in the pack is itself a one-argument template, not a concrete type. The expansion Skills<NTWidget<Skills...>>... in the base list instantiates each mixin with the fully-parameterised derived class.
What Can Go Wrong
Missing the Base Case in Recursive Templates
// WRONG: when rest is empty, print(rest...) matches no overload
template <typename T, typename... Rest>
void bad_print(T first, Rest... rest) {
std::cout << first;
bad_print(rest...); // compile error when rest is empty
}The fix in C++11 is a zero-argument overload. In C++17, if constexpr is cleaner and eliminates the separate overload entirely:
template <typename T, typename... Rest>
void good_print(T first, Rest... rest) {
std::cout << first << '\n';
if constexpr (sizeof...(rest) > 0) {
good_print(rest...); // only instantiated when rest is non-empty
}
}Forgetting std::forward on a Forwarding Pack
template <typename... Args>
void wrong(Args&&... args) {
target(args...); // always passes lvalues
}
template <typename... Args>
void right(Args&&... args) {
target(std::forward<Args>(args)...); // preserves value category
}Without std::forward, every argument is passed as an lvalue reference regardless of what the caller provided. Move-only types will fail to compile; moveable types will be copied unnecessarily.
Expanding Outside an Expansion Context
A pack can only be expanded in specific syntactic positions: function argument lists, initializer lists, base class specifiers, and a handful of others. Assigning a pack directly to a scalar variable is a compile error. When you need to "do something" to each element for its side effects in C++11, the initializer-list trick works:
template <typename... Ts>
void process(Ts&&... args) {
// C++11: force expansion via brace-init
int unused[] = { 0, (do_something(std::forward<Ts>(args)), 0)... };
(void)unused;
}In C++17, replace this with a comma fold.
Quick Reference
| Syntax | Meaning |
|---|---|
typename... Ts | Declare a type parameter pack |
Ts... args | Declare a function parameter pack |
sizeof...(Ts) | Count of types in pack (compile-time size_t) |
f(args...) | Expand pack as function arguments |
(... op pack) | Unary left fold (C++17) |
(pack op ...) | Unary right fold (C++17) |
(init op ... op pack) | Binary left fold (C++17) |
std::forward<Ts>(args)... | Expand both packs in lockstep |
if constexpr (sizeof...(rest) > 0) | Guard recursive call against empty pack (C++17) |
std::make_index_sequence<N> | Compile-time index_sequence<0..N-1> (C++14) |
std::index_sequence_for<Ts...> | Shorthand for make_index_sequence<sizeof...(Ts)> |
Standard requirements: parameter packs and recursive expansion β C++11. std::index_sequence β C++14. Fold expressions and if constexpr β C++17.
What's Next
- Advanced Templates β SFINAE, explicit specialization, and argument deduction rules that interact directly with parameter packs.
- Concepts (Advanced) β Constrain variadic packs with
requiresclauses to catch misuse at the call site. constexpr if(Advanced) β Eliminate recursive base-case overloads with compile-time branching.- Lambda (Advanced) β Capture parameter packs in lambdas for deferred or parallel expansion.
- Expression Templates β A canonical idiom that pushes template metaprogramming β and variadic techniques β to their limits.