· 6 min read ·

The Expansion Statement: Why C++26 Reflection Cannot Work Without P1306

Source: isocpp

Bernard Teo’s overview of C++26 reflection flavours, originally published in early March, surveys P2996 from the perspective of someone who has spent time with the implementation. It is worth reading for its treatment of the different contexts in which reflection is useful. What it prompted me to think about is the part of the system that gets the least coverage relative to how important it is: P1306, the companion proposal for expansion statements, and why P2996 without it would have been nearly unusable in practice.

The Problem That Creates the Need for P1306

P2996 introduces std::meta::info, an opaque scalar type representing a compile-time handle to any C++ entity. The ^ operator produces one: ^int reflects the type int into a std::meta::info value. The std::meta namespace provides consteval functions to query these values:

// at compile time, this yields a vector of member reflections
consteval auto get_members() {
    return std::meta::nonstatic_data_members_of(^Point);
    // returns std::vector<std::meta::info>
}

You now have a compile-time vector of member reflections. The problem is what you do next. You want to expand that vector into actual code, once per member. The tools C++ had before P1306 for this are not pleasant.

Pack expansion works over parameter packs, not std::vector. You cannot write foo(members...) when members is a constexpr std::vector. You can convert a vector to a pack via a helper that takes an index sequence, but that is a significant ergonomic cost and the resulting code is not readable without TMP experience. Recursive template instantiation is the other option, but it is exactly the pattern that made the old type-based reflexpr() approach so painful to work with in the first place.

What Expansion Statements Provide

P1306 introduces template for, which iterates over a compile-time range and expands its body once per element:

template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
    // this body is instantiated once for each non-static data member of T
    // m is a distinct compile-time constant in each instantiation
}

Each iteration sees m as a distinct compile-time constant, so the body can splice m into expressions, pass it to std::meta::name_of, and generate fully typed code per member. The loop body is not an ordinary loop body that runs at runtime; it is an expansion that produces a new copy of the body for each element, with m substituted in throughout.

The distinction from a runtime range-for is critical. A regular for loop over a constexpr std::vector<std::meta::info> does not work here because the body uses m in positions requiring compile-time constants: as a splice operand (obj.[:m:]), as an argument to consteval functions (std::meta::name_of(m)). template for provides compile-time semantics while looking syntactically like a loop.

Three Examples That Require Both Proposals

Enum-to-string:

template <typename E>
    requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value) {
    template for (constexpr auto e : std::meta::enumerators_of(^E)) {
        if (value == [:e:])
            return std::meta::name_of(e);
    }
    return "(unknown)";
}

Without template for, getting from a std::vector<std::meta::info> of enumerators to an expanded if-chain would require converting to a parameter pack via an index sequence, feeding it through a fold expression or recursive specialization, and keeping the whole structure readable. The result is correct but nothing a working programmer wants to maintain.

Field-by-field JSON serialization:

template <typename T>
    requires std::is_class_v<T>
nlohmann::json to_json(T const& obj) {
    nlohmann::json j;
    template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
        j[std::string(std::meta::name_of(m))] = obj.[:m:];
    }
    return j;
}

No macros inside the class definition, no code generation step, no registration call. Any struct with public non-static data members gets JSON serialization from a single function template. Today this requires either a manual to_json per type, the NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE macro, or a library like Boost.Describe that requires registering members with an invasive macro block inside or adjacent to the class definition.

Compile-time field counting (the case where template for is not needed):

consteval std::size_t count_public_fields(std::meta::info type) {
    std::size_t n = 0;
    for (auto m : std::meta::nonstatic_data_members_of(type))
        if (std::meta::is_public(m)) ++n;
    return n;  // a scalar, not expanded code
}
static_assert(count_public_fields(^Point) == 2);

This one uses a regular for loop inside a consteval function, which works fine because the result is a scalar value, not expanded code. When you need to produce aggregate information from a member list, a plain loop in a consteval function is sufficient. When you need to generate code once per member, each instantiation seeing a distinct m, you need template for. The two cases look similar on the surface but require different mechanisms.

The Proposal History Behind the Pairing

P2996 and P1306 were developed as a pair, though they are technically separate proposals with separate committee votes. Andrew Sutton, one of P1306’s authors, was also involved in early reflection work; the two proposals evolved together because the reflection authors recognized that a value-based model built around std::vector<std::meta::info> needed an ergonomic iteration primitive to complement it.

Earlier proposals for expansion statements predate P2996 and were motivated partly by limitations in pack expansion for unrelated use cases. But the value-based reflection design gave them a concrete, compelling application that made the ergonomic argument much clearer. It is a relatively unusual case of two features that are individually motivated but jointly far more useful than either alone.

The previous type-based approach, reflexpr() from P0194 and the long-running P0385 series, did not need an expansion statement companion because its iteration model was recursive template instantiation from the start. That model was painful, but at least it composed with existing TMP patterns. The shift to value-based reflection with std::vector<std::meta::info> introduced a new gap: a compile-time container that the usual pack expansion machinery could not consume. P1306 closes that gap.

The Standardization Gap

P1306 was not included in the C++26 working draft alongside P2996. The expansion statement work continues under active proposals. In the experimental Clang fork, the Bloomberg/EDG branch available on Compiler Explorer as “Clang (experimental P2996)”, template for is implemented and usable. Most published examples of P2996 in action, including examples in the proposal paper itself, rely on it.

This creates a practical tension: P2996 is in C++26, P1306 is not yet, and the most ergonomic use of P2996 depends on P1306. Library authors prototyping against the experimental compiler can use both. Code written to target the actual C++26 standard without experimental extensions will need workarounds until P1306 or an equivalent lands.

The workarounds are not catastrophic. You can convert a constexpr std::vector<std::meta::info> to a parameter pack through index sequences and achieve the same results. But the code is noticeably uglier than what you see in examples and blog posts, which is worth understanding before you build expectations from demo code.

The Third Flavour: Code Synthesis

Both P2996 and Bernard Teo’s article gesture toward a third capability beyond introspection and iteration: code injection, generating entirely new declarations at compile time. P2996 includes std::meta::define_class, which takes a description of a type and produces a fresh struct definition the compiler can use. The companion proposal P3294 extends this to token-sequence injection, enabling synthesis of arbitrary new declarations.

This is the capability that connects P2996’s lineage to Herb Sutter’s metaclass proposal from 2017, P0707. Metaclasses proposed a mechanism for a class kind to transform its instances at definition time, eliminating boilerplate for interfaces, value types, and similar patterns. P2996’s synthesis side achieves related goals through a different mechanism: a consteval function constructs a description of the desired type and calls define_class to materialize it, rather than a special class kind intercepting the class definition.

This is also the part of C++26 reflection with the least implementation coverage and the most open design questions. The introspection and iteration story is settled. The synthesis story is still being worked out in companion proposals that did not make the C++26 cut.

What to Do Now

If you want to understand C++26 reflection properly, P2996 and P1306 need to be understood as a unit. The operator and the namespace give you the data model; the expansion statement gives you the iteration primitive that makes the data model useful without regressing into template metaprogramming. The experimental Clang fork on Compiler Explorer supports both and is the most accessible way to develop intuitions before production toolchains ship.

The production timeline is realistic but not immediate. P2996 is in the C++26 working draft following the Wrocław plenary vote in November 2024. Mainline compiler support for the full feature set will likely lag standardization by 12 to 24 months based on historical precedent with major C++ features. The gap between now and shipping production code with reflection is a reasonable window to prototype against the experimental toolchain, identify which macro-based workarounds in your library code this eventually replaces, and track P1306’s progress through the committee.

Was this interesting?