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...:
#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).
(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.
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.#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]:
#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.
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.#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:
#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:
#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:
#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:
- The initialiser creates the hidden object.
- The object itself is converted to
bool— not the first binding. So the type needs anoperator bool(explicit is fine), or the program doesn't compile.std::pairandstd::tuplehave none. - Then the bindings are initialised. For tuple-like types that means the
get<I>calls happen after the test, whatever its result. - The bindings are in scope in both the
ifbranch and theelsebranch.
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.
#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:
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:
#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:
#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.
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]andxs...[I]index packs directly; the index is checked at compile time. Forward withstd::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 anoperator bool, and the bindings are initialised after the test and visible inelse._is a placeholder you can repeat; refer to it only when there's one.= delete("reason")and variadic friends make APIs explain themselves.