Pack Expansion and Parameter Packs
"C++ variadic templates: parameter packs, fold expressions, index_sequence, pack indexing, and compile-time iteration patterns."
Pack Expansionsince C++11A parameter pack is a template parameter that binds zero or more arguments; pack expansion instantiates the surrounding pattern once per element by appending ... to the pattern.
Overview
Parameter packs eliminate the need for overload sets or hand-coded tuple workarounds when a template must accept an arbitrary number of arguments. A pack is declared with ... before the name (typename... Ts) and expanded with ... after the pattern (Ts..., f(args)...). The expansion can appear anywhere a comma-separated list is syntactically valid: function argument lists, template argument lists, base-class lists, initializer lists, and—since C++20—lambda capture lists.
C++17 introduced fold expressions, which reduce a pack over a binary operator without recursion. C++14 brought std::index_sequence for positional iteration over tuples and arrays. C++26 adds direct pack indexing (pack...[N]), making element access as simple as a subscript.
Syntax
// Template parameter packs
template<typename... Ts> // type pack
template<int... Ns> // non-type pack (C++11)
template<auto... Vs> // auto non-type pack (C++17)
template<template<typename>... Cs> // template template pack (C++11)
// Function parameter pack — must follow all non-pack parameters
void f(Ts... args); // value copies
void g(Ts&&... args); // forwarding references (C++11)
// Expansion: append ... after the pattern
f(transform(args)...); // expands to: f(transform(a0), transform(a1), ...)
std::tuple<const Ts*...> // type-level expansion in template arguments
// sizeof...: constexpr count of pack elements
sizeof...(Ts) // type pack
sizeof...(args) // function parameter packExamples
Expansion Contexts
// Base-class list — the "overload" pattern (C++17 using-declaration pack)
template<typename... Visitors>
struct Overload : Visitors... {
using Visitors::operator()...; // C++17: unpack using-declarations
};
// Deduction guide (C++17)
template<typename... Ts> Overload(Ts...) -> Overload<Ts...>;
// Usage:
std::visit(Overload{
[](int x) { std::println("int: {}", x); },
[](std::string s) { std::println("str: {}", s); },
}, some_variant);
// Template argument list
template<typename... Ts>
using tuple_of_vectors = std::tuple<std::vector<Ts>...>;
// C++20: pack expansion in lambda capture with move
template<typename... Args>
auto defer_all(Args&&... args) {
return [...args = std::forward<Args>(args)]() mutable {
(process(std::move(args)), ...);
};
}Fold Expressions (C++17)
Four forms exist, distinguished by fold direction and presence of an initializer:
// Unary right fold: (pack op ...) → E0 op (E1 op (E2 op E3))
// Unary left fold: (... op pack) → ((E0 op E1) op E2) op E3
// Binary right fold: (pack op ... op init) — init is the rightmost operand
// Binary left fold: (init op ... op pack) — init is the leftmost operand
template<typename... Ts>
auto sum(Ts... xs) { return (xs + ... + 0); } // binary right, safe for empty pack
template<typename... Ts>
auto product(Ts... xs) { return (xs * ... + 1); } // identity: 1
// Left-fold chains operator<< correctly (left-associative)
template<typename... Ts>
void print(Ts&&... args) {
(std::cout << ... << args) << '\n'; // C++17 unary left fold
}
// Fold over comma: sequences side effects left-to-right
// (comma operator guarantees sequencing; each sub-expression evaluated in order)
template<typename F, typename... Ts>
void for_each_arg(F&& f, Ts&&... args) {
(f(std::forward<Ts>(args)), ...); // unary right fold over comma
}
// Logical folds short-circuit correctly
template<typename... Ts>
bool all_positive(Ts... xs) { return ((xs > 0) && ...); } // empty → true
template<typename... Ts>
bool any_positive(Ts... xs) { return ((xs > 0) || ...); } // empty → falsestd::index_sequence (C++14)
std::make_index_sequence<N> generates index_sequence<0, 1, ..., N-1> and is the standard mechanism for positional access into heterogeneous containers.
#include <utility>
// Unpack a tuple as individual function arguments
// (std::apply in C++17 does exactly this)
template<typename F, typename Tuple, std::size_t... Is>
decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence<Is...>) {
return std::forward<F>(f)(std::get<Is>(std::forward<Tuple>(t))...);
}
template<typename F, typename Tuple>
decltype(auto) my_apply(F&& f, Tuple&& t) {
using T = std::remove_cvref_t<Tuple>; // C++20
return apply_impl(std::forward<F>(f), std::forward<Tuple>(t),
std::make_index_sequence<std::tuple_size_v<T>>{});
}
// Transform each tuple element with a function, return new tuple
template<typename F, typename Tuple, std::size_t... Is>
auto tuple_transform_impl(F&& f, Tuple&& t, std::index_sequence<Is...>) {
return std::tuple{f(std::get<Is>(std::forward<Tuple>(t)))...};
}
template<typename F, typename... Ts>
auto tuple_transform(F&& f, std::tuple<Ts...>&& t) {
return tuple_transform_impl(std::forward<F>(f), std::move(t),
std::index_sequence_for<Ts...>{});
}
auto t = std::make_tuple(1, 2.0, 3u);
auto doubled = tuple_transform([](auto x) { return x * 2; }, std::move(t));
// tuple<int, double, unsigned>: (2, 4.0, 6)Type-List Manipulation
// Repeat a type N times
template<typename T, std::size_t N,
typename Seq = std::make_index_sequence<N>>
struct repeat_type;
template<typename T, std::size_t N, std::size_t... Is>
struct repeat_type<T, N, std::index_sequence<Is...>> {
template<std::size_t> using Elem = T; // Is drives count, not value
using type = std::tuple<Elem<Is>...>;
};
using four_ints = repeat_type<int, 4>::type; // tuple<int, int, int, int>
// Filter: keep only types satisfying a predicate (via conditional inclusion)
template<template<typename> class Pred, typename... Ts>
using filter_t = decltype(std::tuple_cat(
std::declval<std::conditional_t<Pred<Ts>::value,
std::tuple<Ts>,
std::tuple<>>>()...));
filter_t<std::is_integral, int, double, long, float> // tuple<int, long>C++26: Pack Indexing
Direct subscript syntax for pack elements replaces std::tuple_element gymnastics:
// C++26
template<typename... Ts>
using first_t = Ts...[0];
template<std::size_t I, typename... Ts>
using nth_type = Ts...[I];
template<std::size_t I, typename... Ts>
constexpr decltype(auto) nth(Ts&&... xs) noexcept {
return std::forward<Ts...[I]>(xs...[I]);
}
first_t<int, double, char> // int
nth_type<2, int, double, char> // char
nth<1>(10, 20, 30) // 20
// Combining with index_sequence for slicing (C++26)
template<std::size_t Start, typename... Ts, std::size_t... Is>
auto slice_from_impl(std::index_sequence<Is...>, Ts&&... xs) {
return std::tuple<Ts...[Start + Is]...>{xs...[Start + Is]...};
}
template<std::size_t Start, typename... Ts>
auto slice_from(Ts&&... xs) {
return slice_from_impl<Start>(
std::make_index_sequence<sizeof...(Ts) - Start>{},
std::forward<Ts>(xs)...);
}Best Practices
- Prefer fold expressions over recursive templates (C++17). They compile faster and avoid instantiation depth limits imposed by
-ftemplate-depth. - Use binary fold with identity for arithmetic operators.
(xs + ...)is ill-formed over an empty pack;(xs + ... + 0)is always well-formed. - Use
std::apply(C++17) rather than hand-rolling index_sequence indirection when calling a function with a tuple's elements. - Constrain packs with concepts (C++20):
template<std::integral... Ts>produces clearer diagnostics than unconstrained packs and prevents silent misuse. - Move-capture packs in long-lived lambdas (C++20):
[...args = std::move(args)]()avoids dangling references when the pack escapes the enclosing function.
Common Pitfalls
Unary fold over empty pack. Unary folds over most operators—+, *, |, &, <<—are ill-formed when the pack is empty. Only && (identity true), || (identity false), and , (yields void) are safe. Use binary fold with an explicit identity element to handle the empty case.
Unspecified evaluation order in function argument expansion. Expanding a pack in a function's argument list does not guarantee evaluation order:
// Evaluation order of side_effect(a0), side_effect(a1), ... is unspecified
f(side_effect(args)...);
// Guaranteed left-to-right: fold over comma
(f(args), ...);
// Also guaranteed (initializer-list): left-to-right with discarded result
std::initializer_list<int>{(f(args), 0)...};Dependent pack in unevaluated context. A bare pack name cannot appear in sizeof, typeid, or decltype without being expanded. Use sizeof...(pack) for the count, or wrap in a tuple for type-level inspection.
Mixing pack and non-pack in the same pattern. When multiple packs appear in one expansion (f(As..., Bs...) is ill-formed unless both packs have the same length—the compiler expands them in lockstep, like std::zip).
Recursive Variadic Templates (Pre-C++17 Style)
Before fold expressions, processing a parameter pack required splitting it into a head element and a tail pack, recurse on the tail, and provide a zero-argument base case to terminate the recursion.
// C++11 — base case: empty pack, nothing to print
void print_all() {}
// C++11 — recursive case: print the first element, recurse on the rest
template<typename T, typename... Ts>
void print_all(T first, Ts... rest) {
std::cout << first << '\n';
print_all(rest...); // one fewer element each call
}
print_all(1, 2.5, "hello");
// generates three instantiations:
// print_all(int, double, const char*)
// print_all(double, const char*)
// print_all(const char*)
// plus the base-case overloadRecursive expansion produces O(N) template instantiations; fold expressions produce O(1). Each recursive call above is a distinct template instantiation, so a 50-element pack generates 50 distinct function templates. Deep packs can hit the compiler's default instantiation limit (-ftemplate-depth=900 on GCC and Clang) and significantly slow compilation times.
// C++17 — one instantiation, no recursion needed
template<typename... Ts>
void print_all(Ts&&... args) {
((std::cout << args << '\n'), ...); // unary right fold over comma
}Historical context: C++11 variadic templates unlocked std::tuple, std::variant, and std::function without macro hacks—all relied on recursive pack expansion internally. C++17 fold expressions then eliminated the need for most recursive patterns, and C++26 pack indexing removes the last common holdout (positional element access).
Counting and Filtering at Compile Time with Folds
Fold expressions compose naturally with type-traits to answer compile-time questions about the pack itself—how many elements satisfy a condition, whether any or all do, and at which index a given type first appears.
// C++17 — count how many types in the pack are integral
template<typename... Ts>
constexpr std::size_t count_integral = ((std::is_integral_v<Ts> ? 1 : 0) + ... + 0);
static_assert(count_integral<int, double, long, float> == 2); // int, long
// C++17 — check if ANY element is the same as T
template<typename T, typename... Ts>
constexpr bool contains = ((std::is_same_v<T, Ts> || ...));
static_assert(contains<int, float, int, double>); // true
static_assert(!contains<char, float, int, double>); // true
// C++17 — check if ALL elements satisfy a predicate
template<typename... Ts>
constexpr bool all_trivial = ((std::is_trivial_v<Ts> && ...));
static_assert(all_trivial<int, double, char>); // true
static_assert(!all_trivial<int, std::string, char>); // falseFinding the index of the first occurrence of a type in a pack requires std::index_sequence to generate positions, then a fold to locate the match.
// C++17 — index of the first T in the pack Ts...
// Returns the index of the first matching type, or sizeof...(Ts) if not found.
template<typename T, typename... Ts>
struct type_index {
private:
template<std::size_t... Is>
static constexpr std::size_t find(std::index_sequence<Is...>) {
std::size_t result = sizeof...(Ts);
// fold: first Is where Ts...[Is] == T wins (lowest index)
((std::is_same_v<T, Ts> && result == sizeof...(Ts)
? (result = Is, true) : false) || ...);
return result;
}
public:
static constexpr std::size_t value =
find(std::index_sequence_for<Ts...>{});
};
static_assert(type_index<int, float, int, double>::value == 1);
static_assert(type_index<char, float, int, double>::value == 3); // not foundUse (... + 0) (binary fold) over (...) (unary fold) when the pack might be empty. The binary form is always well-formed; the unary form over + is ill-formed for an empty pack.
std::make_from_tuple and std::apply (C++17)
C++17 standardised two utilities that wrap the index_sequence idiom in readable names. Prefer them over hand-rolled implementations.
std::apply(f, tuple) calls f with each tuple element forwarded as a separate argument—the canonical solution to "unpack a tuple into a function call."
// C++17
#include <tuple>
#include <functional>
auto args = std::make_tuple(1, 2.5, std::string{"hi"});
// Calls print_all(1, 2.5, "hi")
std::apply([](auto&&... xs) { print_all(std::forward<decltype(xs)>(xs)...); }, args);
// Useful for variadic dispatch:
template<typename F, typename... Args>
auto invoke_later(F&& f, Args&&... args) {
auto stored = std::make_tuple(std::forward<Args>(args)...);
return [f = std::forward<F>(f), stored = std::move(stored)]() mutable {
return std::apply(f, std::move(stored));
};
}std::make_from_tuple<T>(tuple) constructs a T from the tuple elements, enabling lazy or deferred construction.
// C++17 — lazy factory: stores constructor args, builds the object on demand
template<typename T, typename... Args>
class LazyConstruct {
std::tuple<Args...> args_;
public:
explicit LazyConstruct(Args&&... args)
: args_(std::forward<Args>(args)...) {}
T build() {
return std::make_from_tuple<T>(std::move(args_));
}
};
LazyConstruct<std::string, std::size_t, char> factory(5, 'x');
std::string s = factory.build(); // "xxxxx"Both utilities are implemented inside the standard library using pack expansion over std::index_sequence—reading <tuple> source is an excellent way to see the idiom in production. Now that C++17 ships std::apply, never write the index_sequence indirection manually for tuple-to-function dispatch.
Constrained Parameter Packs with Concepts (C++20)
Concepts provide first-class syntax for constraining every element of a pack, and their error messages are dramatically shorter than equivalent SFINAE.
// C++20 — every type in the pack must satisfy std::integral
template<std::integral... Ts>
auto sum_ints(Ts... xs) { return (xs + ... + 0); }
sum_ints(1, 2L, 3u); // OK — int, long, unsigned are all integral
// sum_ints(1, 2.5, 3); // Error: double does not satisfy std::integralA requires clause allows more expressive compound constraints over the whole pack.
// C++20 — all types must be trivially copyable
template<typename... Ts>
requires (std::is_trivially_copyable_v<Ts> && ...)
void memcopy_all(Ts... xs) { /* ... */ }
// C++20 — at least one type must be int
template<typename... Ts>
requires (std::same_as<Ts, int> || ...)
void needs_an_int(Ts... xs) { /* ... */ }Custom pack-level concepts compose naturally with standard library concepts.
// C++20 — named concept: every type must be formattable with std::format
template<typename... Ts>
concept AllFormattable = (std::formattable<Ts, char> && ...);
template<AllFormattable... Ts>
std::string format_all(std::string_view sep, Ts&&... xs) {
std::string out;
bool first = true;
((out += (first ? (first = false, "") : std::string(sep)),
out += std::format("{}", xs)), ...);
return out;
}Concepts give one-line diagnostics; unconstrained packs give 20-line template instantiation backtraces. Without std::integral... Ts, passing a double to sum_ints yields a wall of substitution failures inside the function body. With the concept constraint, the compiler reports: error: 'double' does not satisfy 'integral' at the call site.
Lambda Capture of Parameter Packs (C++20)
C++14 introduced generalized lambda captures ([x = std::move(x)]) but offered no syntax for capturing an entire parameter pack by value or move. C++20 fills this gap.
// C++14 — captures a single value; packs are NOT capturable this way
auto f = [val = std::move(val)]() mutable { return val; }; // C++14, single value only
// C++20 — capture an entire pack by move into the lambda
template<typename F, typename... Args>
auto make_deferred(F&& f, Args&&... args) {
return [...args = std::forward<Args>(args)]() mutable {
return f(std::move(args)...);
};
}
auto task = make_deferred(std::plus<>{}, 1, 2);
int result = task(); // 3The ... before the capture name is what distinguishes a pack capture from a single-value capture. Each pack element becomes a separate captured member of the lambda's closure type.
// C++20 — capture by reference wrapper for deferred read access
template<typename F, typename... Args>
auto make_view(F&& f, Args&... args) {
return [...args = std::ref(args)]() {
return f(args.get()...); // unwrap reference_wrapper with .get()
};
}
int x = 10, y = 20;
auto view = make_view(std::plus<>{}, x, y);
x = 99;
int r = view(); // 119 — sees updated xPack lambda captures are the idiomatic C++20 replacement for std::bind with multiple arguments. They preserve the type of each element independently, support move-only types, and compose with std::packaged_task or coroutine awaitables for async dispatch.
Initializer-List Trick for Guaranteed Evaluation Order
Function argument expansion (f(args)...) does not sequence the evaluations of each element—the order is unspecified. Before C++17 fold expressions, C++11 code that needed left-to-right side effects used an initializer-list to force sequencing.
// C++11/14 — initializer-list guarantees left-to-right evaluation order
template<typename F, typename... Ts>
void for_each_arg(F&& f, Ts&&... args) {
int dummy[] = { (f(std::forward<Ts>(args)), 0)... };
(void)dummy; // suppress unused-variable warning
}
// C++11/14 — print_all without recursion or fold
template<typename... Ts>
void print_all(Ts&&... args) {
int dummy[] = { (print_one(std::forward<Ts>(args)), 0)... };
(void)dummy;
}The 0 pads the comma expression to produce an int value; the ... then expands the whole pattern into the brace-enclosed initializer list, which the standard guarantees evaluates left-to-right.
// C++11 — std::initializer_list form: works in constexpr context
template<typename F, typename... Ts>
constexpr void constexpr_for_each(F f, Ts&&... args) {
std::initializer_list<int>{ (f(std::forward<Ts>(args)), 0)... };
}C++17 fold expressions render the trick largely unnecessary for new code, but the pattern still appears in C++11/14 codebases and in some constexpr contexts where fold syntax is unavailable.
// C++17 — prefer the fold over comma; same semantics, cleaner syntax
template<typename F, typename... Ts>
void for_each_arg(F&& f, Ts&&... args) {
(f(std::forward<Ts>(args)), ...); // unary right fold over comma
}
// C++17 — for stream output specifically, left-fold over << is clearest
template<typename... Ts>
void print_all(Ts&&... args) {
(std::cout << ... << args); // left fold: cout << a0 << a1 << a2 ...
}The initializer-list trick is the C++11/14 workaround; the comma fold is the C++17 idiom; both guarantee left-to-right evaluation order. The fold over , remains valid in C++17 and later—choose whichever reads most clearly in context.
See Also
- Fold Expressions — full operator table and associativity rules
- Variadic Templates — template parameter packs vs. function parameter packs
- Concepts — constraining packs with requires-clauses
- std::tuple — canonical heterogeneous container for pack storage