· 6 min read ·

From Index Sequences to Expansion Statements: What C++26 Finally Fixes About Tuple Iteration

Source: isocpp

Iterating over a std::tuple has been one of those things in C++ where the standard library gives you a type and then refuses to give you the most basic tool to use it. You can construct a tuple, store values of different types in it, pass it around, decompose it with structured bindings since C++17, but you cannot write a plain loop over its elements without reaching for some combination of template machinery that would make a newcomer assume the code was broken.

Bartlomiej Filipek’s recent walkthrough covers C++26’s answer to this in the final part of a mini-series on tuple iteration. This is a good moment to look at not just what C++26 adds, but why the problem persisted this long and what its resolution tells us about the trajectory of the language.

Why a Plain Loop Never Worked

The reason you cannot write for (auto x : my_tuple) is not a library limitation. It is a type system constraint. A range-based for loop expects that every iteration yields a value of a single type. The compiler needs to know the type of x at the point it emits code for the loop body, and that type must be consistent across iterations. A std::tuple<int, double, std::string> has elements of three different types, so there is no single type for x to be.

This is the heterogeneity problem. Tuples exist precisely to hold values of different types in a fixed compile-time sequence, and loops exist to process sequences. The two concepts are fundamentally at odds unless the iteration itself happens at compile time, with a different instantiation of the loop body for each element type.

Every technique before C++26 is a way to spell “compile-time iteration” without language syntax for it.

The Pre-C++17 Approach: Recursive Templates

Before C++17, the standard pattern was recursive template specialization. You write a struct or function template that processes element I, then recurses on element I+1, with a base case at std::tuple_size<Tuple>::value.

template <std::size_t I = 0, typename... Args>
void print_tuple(const std::tuple<Args...>& t) {
    if constexpr (I < sizeof...(Args)) {
        std::cout << std::get<I>(t) << '\n';
        print_tuple<I + 1>(t);
    }
}

The if constexpr here is a C++17 feature that cleans this up considerably. In C++11/14, you needed separate template specializations to terminate the recursion. Either way, you are writing a loop as a function that calls itself at compile time, which means the loop body must be a function (or must be expressible as one), the recursion depth is proportional to the tuple size, and the diagnostic output when something goes wrong is a call stack of template instantiations.

C++17: Fold Expressions and std::apply

C++17 brought two significant tools. Fold expressions let you apply a binary operator across a parameter pack, and std::apply unpacks a tuple into a function call’s argument list.

Combined, they get you surprisingly far:

auto t = std::make_tuple(1, 2.5, std::string("hello"));

std::apply([](auto&&... args) {
    (..., (std::cout << args << '\n'));
}, t);

The fold expression (..., expr) evaluates expr for each pack element in order. This is left-fold over the comma operator, which sequences side effects. It works. It handles the common cases. But it has real limits.

The comma operator in a fold expression gives you sequencing, but not a statement block. You cannot declare local variables per element. You cannot use break or continue semantics. If you want to do something more complex than a single expression per element, you push that complexity into a helper lambda or function, which just moves the problem rather than solving it.

std::apply also requires all logic to be inside the lambda, which means the tuple you are iterating and the code processing it are syntactically separated in ways that complicate reading and writing more involved logic.

C++20 and C++23: Better, Not Different

C++20 added concepts, which improved error messages and made generic code more expressive, but did not change the fundamental mechanics of tuple iteration. std::apply with fold expressions remained the idiomatic approach. C++23 added std::ranges::zip and improvements to views, but those apply to homogeneous ranges; heterogeneous iteration remained unchanged.

The language acknowledged the problem but deferred the solution.

C++26: Two Proposals That Compose

C++26 addresses this through two separate proposals that each solve part of the problem and compose well together.

P1061: Structured Binding Packs extends structured bindings to introduce a parameter pack. Where auto [a, b, c] = t requires knowing there are exactly three elements, auto [...elems] = t captures all elements as a pack named elems:

auto t = std::make_tuple(1, 2.5, std::string("hello"));
auto [...elems] = t;  // elems is a pack of {1, 2.5, "hello"}

This is genuinely new. You now have a named pack bound to the tuple’s contents, without spelling out every element’s name individually and without going through std::apply.

P1306: Expansion Statements adds an template for construct that iterates over a parameter pack with actual statement-block semantics:

template for (auto& elem : elems) {
    std::cout << elem << '\n';
    // Full statement block: local variables, early exit logic, etc.
}

Together, these two proposals make heterogeneous iteration look like ordinary iteration:

template <typename Tuple>
void print_all(const Tuple& t) {
    auto [...elems] = t;
    template for (const auto& elem : elems) {
        std::cout << elem << '\n';
    }
}

This is not just syntactic sugar. The statement block changes what you can express. You can declare local variables inside the loop body. You can call functions that return values and act on them per element. The loop body is a proper scope, not a fold expression over a single expression.

What This Enables Beyond Simple Printing

The practical utility becomes clearer with slightly more complex cases. Consider serializing a tuple to JSON, where each element requires type-dependent formatting:

// C++17 approach: nested lambda, messy control flow
std::apply([&out, first = true](auto&&... args) mutable {
    (..., ([&] {
        if (!first) out << ", ";
        first = false;
        serialize(out, args);
    }()));
}, t);

// C++26 approach
auto [...elems] = t;
bool first = true;
template for (const auto& elem : elems) {
    if (!first) out << ", ";
    first = false;
    serialize(out, elem);
}

The C++17 version requires an immediately-invoked lambda per element to introduce statement-level logic inside the fold. The mutable on the outer lambda, the first variable’s placement, the nested lambdas: these are all artifacts of not having a proper loop construct. The C++26 version reads the way you would write it in any other language.

The same improvement applies to error handling, index tracking with std::size_t i = 0; ++i; inside the loop, and any logic that benefits from full statement semantics.

The Broader Pattern

This development fits a recognizable arc in modern C++. A capability exists in the type system (heterogeneous compile-time sequences), it gets widely used, the lack of proper syntax forces idioms that work but obscure intent, and eventually the standard adds syntax that matches the semantics developers were already expressing.

Variadic templates (C++11), fold expressions (C++17), and now expansion statements (C++26) are three steps in closing this gap. Each step made the underlying compile-time power more accessible without fundamentally changing what was possible.

Bartlomiej’s post notes this is the final part of a series that covers C++20 and C++23 approaches first. Reading the progression from index_sequence tricks through to template for makes the design intent of P1306 clearer than reading the proposal in isolation.

Compiler support for P1306 and P1061 is still catching up; as of late 2025 these are accepted for the C++26 standard but production-grade support across GCC, Clang, and MSVC varies. The P1306 proposal text and P1061 proposal text are worth reading if you want the full design rationale, including how expansion statements interact with break, continue, and coroutines.

The short version is that C++ is finally treating tuple iteration as a language problem rather than a library problem, and the solution, once you see it, looks like it should have been there from the beginning.

Was this interesting?