Skip to content
C++
Language
since C++11
Intermediate

Pack Expansion and Parameter Packs

"C++ variadic templates: parameter packs, fold expressions, index_sequence, pack indexing, and compile-time iteration patterns."

Pack Expansionsince C++11

A 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

cpp
// 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 pack

Examples

Expansion Contexts

cpp
// 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:

cpp
// 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 → false

std::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.

cpp
#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

cpp
// 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:

cpp
// 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:

cpp
// 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.

cpp
// 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 overload

Recursive 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.

cpp
// 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.

cpp
// 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>);   // false

Finding 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.

cpp
// 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 found

Use (... + 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."

cpp
// 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.

cpp
// 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.

cpp
// 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::integral

A requires clause allows more expressive compound constraints over the whole pack.

cpp
// 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.

cpp
// 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.

cpp
// 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();   // 3

The ... 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.

cpp
// 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 x

Pack 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.

cpp
// 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.

cpp
// 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.

cpp
// 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