CodeRigor

FREE LESSON

Modern pack tricks: indexing, binding packs and friends

Index packs directly with Ts...[I], destructure tuples and aggregates into packs with auto [...xs], and use structured bindings as conditions and _ placeholders — the C++26 features that retire most index_sequence boilerplate.

Variadic templates have been in C++ since 2011, but getting at the elements of a pack — "the last argument", "every field of this struct", "this tuple, reversed" — meant recursion, std::tuple_element, std::index_sequence and std::apply. C++26 adds three small features that remove most of that machinery:

Feature Syntax Replaces
Pack indexing Ts...[I], xs...[I] recursive templates, std::get<I>(std::forward_as_tuple(xs...))
Structured binding packs auto [...xs] = t; std::apply, std::index_sequence + std::get
Bindings as conditions if (auto [ok, v] = f()) a named temporary plus a separate test

Plus two conveniences: the _ placeholder for names you don't need, and = delete("reason"). Everything in this lesson compiles on both GCC 16 and Clang 23.

1. Packs recap

A pack is a list of types (class... Ts) or values (Ts... xs). You can't name its elements; you can only expand it with a pattern followed by ..., and ask its size with sizeof...:

C++
#include <print>
#include <string>

template <class... Ts>
void show(const Ts&... xs) {
    std::println("{} argument(s)", sizeof...(xs));
    (std::println("  {}", xs), ...);           // unary right fold over the comma operator
    std::println("sum of sizes: {}", (sizeof(Ts) + ... + 0));   // binary fold with an init value
}

int main() {
    show(1, 2.5, std::string("three"));
    show();
}

Folds come in four shapes. The parentheses are part of the syntax, and the position of ... decides the grouping:

Fold Means
(xs op ...) x0 op (x1 op (x2 op x3)) — unary right
(... op xs) ((x0 op x1) op x2) op x3 — unary left
(xs op ... op init) x0 op (x1 op (x2 op init)) — binary right
(init op ... op xs) ((init op x0) op x1) op x2 — binary left

Only &&, || and , have a value for an empty pack (true, false, void()). Any other unary fold over an empty pack is a compile error, which is why sums are usually written with an init value: (0 + ... + xs).

Where the ... sits decides the grouping
(xs - ...) nests to the right; (... - xs) nests to the left. With - and xs = 8, 4, 2, 1 they give 5 and 1 — only associative operators don't care.
Where the ... sits decides the grouping
(xs - ...) nests to the right; (... - xs) nests to the left. With - and xs = 8, 4, 2, 1 they give 5 and 1 — only associative operators don't care.

2. Pack indexing: Ts...[I]

C++26 lets you pick one element of a pack directly. The index must be a constant expression and in range — out of range is a compile error, not UB.

Ts...[I] and xs...[I] pick one element of a pack
For last(1, 2.5, 'c'): sizeof...(xs) is 3, so xs...[sizeof...(xs) - 1] is 'c' and Ts...[0] is int. An index of 3 would not compile.
Ts...[I] and xs...[I] pick one element of a pack
For last(1, 2.5, 'c'): sizeof...(xs) is 3, so xs...[sizeof...(xs) - 1] is 'c' and Ts...[0] is int. An index of 3 would not compile.
C++
#include <cstddef>
#include <print>
#include <type_traits>

template <class... Ts>
using first_t = Ts...[0];

template <class... Ts>
constexpr auto last(Ts... xs) {
    return xs...[sizeof...(xs) - 1];
}

int main() {
    static_assert(std::is_same_v<first_t<int, double, char>, int>);
    std::println("{}", last(1, 2.5, 'z'));      // z
}

xs...[I] names the element as an lvalue (it's an id-expression, like naming a parameter). To pass it on with its original value category, forward it with the matching type, Ts...[I]:

C++
#include <cstddef>
#include <print>
#include <string>
#include <type_traits>
#include <utility>

template <std::size_t I, class... Ts>
    requires(I < sizeof...(Ts))
constexpr decltype(auto) nth(Ts&&... xs) {
    return std::forward<Ts...[I]>(xs...[I]);
}

int main() {
    int a = 1;
    double b = 2.0;
    nth<1>(a, b) = 9.5;                          // a reference into the caller's b
    std::println("{}", b);                       // 9.5
    static_assert(std::is_same_v<decltype(nth<0>(a, b)), int&>);
    static_assert(std::is_same_v<decltype(nth<0>(1, b)), int&&>);
}

Before C++26 the same thing took a recursive template or std::get<I>(std::forward_as_tuple(xs...)). Indexing is also cheaper to compile: no tuple instantiation, no recursion depth.

3. Structured binding packs: auto [...xs] = t;

A structured binding can now introduce a pack — exactly one, at any position, alongside ordinary names. It works on everything structured bindings already worked on: aggregates, tuple-like types (std::tuple, std::pair, std::array, your own get<I> types) and built-in arrays.

One binding pack, alongside ordinary names
auto [first, ...rest, last] = std::tuple{1, 2.5, 'c', "hi"}: the pack takes whatever the other names leave — here the middle two elements. Inside a template, rest... expands like any pack.
One binding pack, alongside ordinary names
auto [first, ...rest, last] = std::tuple{1, 2.5, 'c', "hi"}: the pack takes whatever the other names leave — here the middle two elements. Inside a template, rest... expands like any pack.
C++
#include <array>
#include <print>
#include <tuple>

struct Point3 { int x, y, z; };

template <class T>
constexpr auto sum_all(const T& t) {
    const auto& [...xs] = t;              // a pack of references to t's elements
    return (0 + ... + xs);
}

template <class T>
void ends(const T& t) {
    const auto& [first, ...middle, last] = t;
    std::println("first {} last {} ({} in between)", first, last, sizeof...(middle));
}

int main() {
    std::println("{}", sum_all(std::tuple{1, 2.5, 3}));      // 6.5
    std::println("{}", sum_all(Point3{1, 2, 3}));            // 6
    std::println("{}", sum_all(std::array<int, 0>{}));       // 0 — an empty pack
    ends(std::array{4, 5, 6, 7});
}

The catch: only inside templates. A binding pack's size has to be a template-dependent fact, so a binding pack in a plain function is an error. Generic lambdas ([](auto t)) are templates, so they're fine:

C++
#include <tuple>

int sum(std::tuple<int, int> t) {
    auto [...xs] = t;                     // error: pack declaration outside of template
    return (xs + ...);
}

Before and after

Here is "apply a function to every element of a tuple" in C++17 and in C++26:

C++
#include <print>
#include <tuple>
#include <utility>

// C++17: std::apply forwards the elements as arguments to a lambda that
// takes a parameter pack.
template <class Tuple, class F>
void for_each_17(const Tuple& t, F f) {
    std::apply([&](const auto&... xs) { (f(xs), ...); }, t);
}

// C++26: destructure into a pack directly.
template <class Tuple, class F>
void for_each_26(const Tuple& t, F f) {
    const auto& [...xs] = t;
    (f(xs), ...);
}

struct Rgb { int r, g, b; };

int main() {
    auto print = [](const auto& x) { std::print("{} ", x); };
    for_each_17(std::tuple{1, "two", 3.0}, print);
    std::println();
    for_each_26(Rgb{255, 128, 0}, print);    // aggregates too — std::apply can't do that
    std::println();
}

The second version also works on plain aggregates, which std::apply never did: iterating the fields of a struct without reflection (Level 9's reflection skill goes further: names, annotations, member access by name).

Order of evaluation. When you build a new tuple from a pack, use a braced initialiser — std::tuple<Rs...>{f(xs)...} — because elements of a braced list are evaluated left to right. Function arguments, as in std::make_tuple(f(xs)...), are evaluated in an unspecified order, and GCC on x86-64 really does evaluate them right to left.

4. Structured bindings as conditions

C++26 lets the declaration itself be the condition:

C++
#include <charconv>
#include <print>
#include <string_view>

struct Parsed {
    int value = 0;
    bool ok = false;
    explicit operator bool() const { return ok; }   // explicit is fine: conditions convert contextually
};

Parsed parse_int(std::string_view s) {
    Parsed p;
    auto [end, ec] = std::from_chars(s.data(), s.data() + s.size(), p.value);
    p.ok = ec == std::errc{} && end == s.data() + s.size();
    return p;
}

int main() {
    for (std::string_view s : {"42", "4x2"}) {
        if (auto [value, _] = parse_int(s))
            std::println("{} -> {}", s, value);
        else
            std::println("{} is not a number (value stays {})", s, value);
    }
}

The rules, in order:

  1. The initialiser creates the hidden object.
  2. The object itself is converted to bool — not the first binding. So the type needs an operator bool (explicit is fine), or the program doesn't compile. std::pair and std::tuple have none.
  3. Then the bindings are initialised. For tuple-like types that means the get<I> calls happen after the test, whatever its result.
  4. The bindings are in scope in both the if branch and the else branch.

If you want to test something other than the object, use the C++17 form with an init-statement: if (auto [a, b] = f(); a < b).

5. The _ placeholder

A variable named _ declared in a block or as a structured binding is a placeholder: you can declare as many as you like, they never trigger unused-variable warnings, and they still live until the end of the scope.

C++
#include <mutex>
#include <print>
#include <tuple>

std::mutex m;

int main() {
    auto [_, _, z] = std::tuple{1, 2.5, 'z'};   // only want the third
    std::println("{}", z);

    auto _ = std::lock_guard{m};                 // a third _: the guard holds the lock until the end of main
    std::println("critical section");
}

Once there are two _ in a scope you can't refer to _ any more (it's ambiguous). With exactly one, it's an ordinary variable name.

6. Two small friends

= delete("reason") puts your explanation into the compiler's error message:

C++
struct Handle {
    Handle() = default;
    Handle(const Handle&) = delete("handles are unique; use clone() or std::move");
};

int main() {
    Handle a;
    Handle b = a;     // error: ... handles are unique; use clone() or std::move
}

Variadic friends make every type in a pack a friend — handy for "passkey" patterns where a fixed set of classes may call a private API:

C++
#include <print>

template <class... Friends>
class Token {
    friend Friends...;
    Token() = default;                // only the listed classes can make one
};

class Registry;

class Plugin {
public:
    void attach(Token<Registry>) { std::println("attached"); }
};

class Registry {
public:
    void add(Plugin& p) { p.attach(Token<Registry>{}); }
};

int main() {
    Plugin p;
    Registry{}.add(p);
}

7. Worked example: debug-printing any aggregate

Combine a binding pack, a fold over the comma operator and an index counter to print every field of a struct:

C++
#include <format>
#include <print>
#include <string>

struct Employee {
    std::string name;
    int id;
    double salary;
};

template <class T>
std::string fields(const T& obj) {
    const auto& [...xs] = obj;
    std::string out = "{";
    std::size_t i = 0;
    ((out += (i++ ? ", " : "") + std::format("{}", xs)), ...);
    return out + "}";
}

int main() {
    std::println("{}", fields(Employee{"Ada", 7, 1234.5}));   // {Ada, 7, 1234.5}
}

Step by step: const auto& [...xs] = obj makes a pack of references to the three fields. The comma fold expands (out += …) once per field, left to right (the comma operator sequences its operands). i++ ? ", " : "" puts a separator before every field except the first.

Faded example: all fields equal?

Fill in the fold so all_equal(a, b) is true when every pair of corresponding fields compares equal. Two objects, so two binding packs — they expand together, element by element, in the same pattern.

C++
template <class T>
bool all_equal(const T& a, const T& b) {
    const auto& [...xs] = a;
    const auto& [...ys] = b;
    return /* ??? */;
}
Answer

(true && ... && (xs == ys)) — or simply ((xs == ys) && ...), since && folds are allowed to be empty (an empty && fold is true). Two packs in one pattern must have the same length, which they do here because both come from the same type T.

Summary

  • Ts...[I] and xs...[I] index packs directly; the index is checked at compile time. Forward with std::forward<Ts...[I]>(xs...[I]).
  • auto [...xs] = t; turns any aggregate, tuple-like type or array into a pack — but only inside a template (including generic lambdas).
  • One pack per binding, at any position: auto [first, ...rest] = t;.
  • Build tuples from packs with braced initialisers for left-to-right order.
  • if (auto [a, b] = f()) tests the object; it needs an operator bool, and the bindings are initialised after the test and visible in else.
  • _ is a placeholder you can repeat; refer to it only when there's one.
  • = delete("reason") and variadic friends make APIs explain themselves.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

C++26 Ts...[I] pack indexing.

C++26 structured binding packs: auto [...xs] = t.

Variadic templates refresher — the pack mechanics these features build on.

Tour of C++26 features including pack indexing and structured binding improvements.

Fold expressions introduced: reducing a parameter pack with an operator — the basis for later pack tricks.