The For Loop That std::tuple Never Had: C++26 Expansion Statements and Structured Binding Packs
Source: isocpp
The core problem with std::tuple and iteration has never been a library problem; it is a type system problem. A range-based for loop requires a single type for the loop variable across all iterations. std::tuple<int, std::string, double> has three elements with three different types. There is no single type to declare, so the compiler has nothing to work with, and every technique developers have used for the past fifteen years is a workaround for that mismatch.
Bartlomiej Filipek’s November 2025 article on isocpp.org wraps up a tuple-iteration series by covering what C++26 finally provides. The two proposals at the center of the story, P1061 (structured binding packs) and P1306 (expansion statements), are worth understanding in depth because together they represent a genuine shift in where the language draws the boundary between library-level and grammar-level concerns.
Why the Workarounds Are Awkward
The canonical C++14 approach generates an index sequence and dispatches through a fold expression or recursive instantiation:
template<typename Tuple, typename F, std::size_t... I>
void for_each_impl(Tuple&& t, F&& f, std::index_sequence<I...>) {
(f(std::get<I>(std::forward<Tuple>(t))), ...);
}
template<typename Tuple, typename F>
void for_each_tuple(Tuple&& t, F&& f) {
for_each_impl(
std::forward<Tuple>(t),
std::forward<F>(f),
std::make_index_sequence<std::tuple_size_v<std::decay_t<Tuple>>>{}
);
}
C++17 improved this with std::apply:
std::apply([](auto&&... args) {
((std::cout << args << '\n'), ...);
}, my_tuple);
std::apply unpacks the tuple into a variadic function call, and a generic lambda handles each element. For simple cases this reads reasonably well. The structural problem is that fold expressions are expressions, not statements. You cannot break based on an element’s value. You cannot return from the enclosing function mid-iteration without an exception or a flag. A multi-statement body requires yet another nested lambda or comma-expression gymnastics that obscures the actual logic. The surface syntax is shorter than the C++14 version, but the underlying constraint is the same: compile-time iteration over heterogeneous types cannot be expressed at the statement level with existing language tools.
P1061: Structured Binding Packs
Structured bindings arrived in C++17 and let you write auto [x, y, z] = some_struct. P1061, by Barry Revzin, extends this so that a single binding captures all elements into a parameter pack:
auto [...elements] = my_tuple;
After this declaration, elements is a pack containing every element of the tuple. All existing pack machinery applies:
auto [...elements] = my_tuple;
// Fold expression over the pack
(std::cout << elements << '\n', ...);
// Pack expansion in a function call
some_variadic_function(elements...);
// Compile-time count
constexpr std::size_t n = sizeof...(elements);
This is genuinely cleaner than std::apply for many cases. There is no lambda wrapper required. The pack can be referenced multiple times in the same scope. You can pass it to other templates directly.
But fold expressions remain the primary iteration tool even with P1061. If you want complex per-element logic, you are still writing lambda wrappers or encoding multi-step operations as expressions. The [...elements] syntax makes it easier to get a pack out of a tuple, but it does not change what you can do with that pack once you have it. That is P1306’s problem to solve.
P1306: Expansion Statements
P1306, originally by Andrew Sutton, proposes a new statement form that iterates over a parameter pack at compile time:
template for (auto elem : elements) {
// This block is instantiated once per element
// with elem having a concrete type each time
std::cout << elem << '\n';
}
The template for syntax signals a compile-time construct. The compiler instantiates the loop body once per pack element, each time substituting the concrete type for elem. A tuple <int, std::string, double> produces three instantiated blocks, not one runtime loop. No virtual dispatch, no type erasure, no overhead beyond what the equivalent hand-written code would produce.
Because the body is a real statement block, you get the full expressive power of C++ statements:
template for (auto& elem : elements) {
if constexpr (std::is_integral_v<decltype(elem)>) {
running_total += elem;
} else if constexpr (std::is_same_v<std::decay_t<decltype(elem)>, std::string>) {
labels.push_back(elem);
}
// break, continue, return all work as expected
}
break exits the expansion early. return exits the enclosing function. This resolves the main practical limitation of fold-based iteration, which required exceptions or flag variables to replicate control flow.
The Combined Pattern
With both proposals in play, iterating a tuple becomes:
std::tuple<int, std::string, double, bool> record{42, "hello", 3.14, true};
auto [...fields] = record;
template for (auto const& field : fields) {
visit(field);
}
Compared with the C++14 index-sequence version, the signal-to-noise ratio is dramatically better. No helper function template, no std::index_sequence, no lambda. The code structure maps directly onto the intent: iterate over each field with its correct type.
The exact syntax of template for may shift before C++26 finalizes. Earlier revisions of P1306 explored alternative spellings, and the interaction with nested expansion contexts is still being refined in committee discussions. But the semantics are consistent: a statement-level construct that the compiler expands into N instantiated blocks, where N is the size of the pack.
An Instructive Parallel in Zig
It is worth noting that Zig’s inline for provides essentially the same semantics for comptime-known collections, and has done so from early in the language’s life:
const record = .{ 42, "hello", 3.14, true };
inline for (record) |field| {
std.debug.print("{any}\n", .{field});
}
Zig’s inline for iterates over a tuple-like anonymous struct at compile time, instantiating the body with the correct type for each element. The keyword inline signals the compile-time expansion, directly parallel to template in P1306’s proposed syntax. The similarity is not coincidence; both languages are converging on the same answer to the same type-theoretic question. C++ takes longer to get there because any language-level addition must coexist with forty years of existing code and a specification process designed for consensus across a much larger community.
Compiler Support and Timeline
C++26 was still in draft as of late 2025. P1061 has been tracked for inclusion and experimental implementations appear in recent Clang and GCC builds under -std=c++2c or equivalent flags. P1306 is further behind in the pipeline. Ongoing committee discussion covers the interaction between expansion statements and if constexpr inside the body, behavior when the pack is empty, and the precise handling of break and return across nested expansion contexts.
For production code, std::apply with a generic lambda remains the portable choice through the near term. Library wrappers over the index-sequence pattern continue to be viable. Neither P1061 nor P1306 should be treated as stable API until they appear in a shipping compiler’s standard mode.
The Library-vs-Language Line
For most of C++‘s history, the philosophy was to provide low-level primitives (parameter packs, fold expressions, std::index_sequence) and let the library layer compose them. That is why std::apply lives in <tuple> rather than in core grammar. P1306 concedes that some iteration patterns cannot be expressed cleanly with library tools alone. Pack expansion at the statement level, with real control flow, requires the compiler’s direct participation in generating multiple instantiated statement blocks. No combination of lambdas and fold expressions can replicate break or return without side channels.
When these features land together, tuple iteration will finally look like what the problem actually is: a for loop with a type-correct binding for each element, written in ordinary C++ syntax, requiring no helper templates and no wrapper lambdas. That it took this long is not a failure of the standard process so much as evidence of how seriously that process takes the stability of existing code.