· 5 min read ·

The Value-Based Design That Makes C++26 Reflection Worth the Wait

Source: isocpp

Reflection is finally coming to C++26, and the most interesting thing about it is not the feature itself but the design decision that made it work after twenty years of failed proposals. Bernard Teo wrote a solid overview of the different flavours of reflection in C++26 earlier this month. What that piece opens up is worth going deeper on, specifically the central architectural choice in P2996 that separates it from everything that came before.

The short version: every serious prior attempt treated reflection as a type-level operation. P2996 treats it as a value-level operation. That distinction sounds academic until you write code with both approaches.

What Failed Before and Why

The 2016 proposal, P0194, used reflexpr() to produce a type. Every query on that type produced another type:

// P0194 style
using meta_Point = reflexpr(Point);
using members = std::reflect::get_data_members_t<meta_Point>;
using first = std::reflect::get_element_t<0, members>;
constexpr auto name = std::reflect::get_name_v<first>;

To get the second member, you write get_element_t<1, members>. To filter members by a predicate, you need type list algorithms. To iterate over them, you need parameter pack expansions on type lists. Every step spins up the template instantiation machinery, with all the overhead and error message chaos that implies. The mental model is template metaprogramming, which has its uses but does not scale gracefully to the kinds of structural queries that reflection is meant to simplify.

P2996 uses a different primitive. The ^^ operator produces a value of type std::meta::info, an opaque scalar. The entire API is a library of consteval functions that take and return these values:

// P2996 style
constexpr auto members = std::meta::nonstatic_data_members_of(^^Point);
constexpr auto name = std::meta::name_of(members[0]);

members is a std::vector<std::meta::info>. Indexing it is indexing a vector. Filtering it uses std::ranges::filter. Sorting it uses std::ranges::sort. This all works during constant evaluation, and none of it requires new template instantiations per operation. The Bloomberg Clang fork that serves as the reference implementation showed compile times for equivalent operations running five to ten times faster than comparable magic_enum approaches, which themselves relied on parsing __PRETTY_FUNCTION__ strings in ways that technically invoke undefined behavior.

The Syntax in Practice

P2996 introduces two syntactic constructs. The ^^ prefix operator reflects an entity, producing std::meta::info. The [: :] splice operator goes the other direction, materializing a reflection back into C++ syntax in the appropriate context:

using T = [: ^^int :];          // T is int
struct Foo { int value; };
constexpr auto mem = ^^Foo::value;
Foo f{42};
int v = f.[:mem:];              // equivalent to f.value

The canonical example that shows both together is enum-to-string. Before P2996, the practical options were X-macros, external code generators, or magic_enum. With P2996:

template <typename 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>";
}

enum Color { Red, Green, Blue };
static_assert(enum_to_string(Green) == "Green");

The template for construct is an expansion statement that iterates over a constexpr range at compile time, instantiating the body for each element. Each iteration can produce a distinct type, so it is fundamentally different from a runtime loop, but it reads like one.

Generic serialization, which previously required handwritten serialize() methods or macro-heavy registration systems like Boost.Fusion, follows the same pattern:

template <typename T>
std::string to_json(T const& obj) {
    std::string result = "{";
    bool first = true;
    template for (constexpr auto m : std::meta::nonstatic_data_members_of(^^T)) {
        if (!first) result += ",";
        result += '"';
        result += std::meta::name_of(m);
        result += "\":";
        result += std::to_string(obj.[:m:]);
        first = false;
    }
    return result + "}";
}

The runtime code produced is identical to what a developer would write by hand. All std::meta functions are consteval; they have no runtime presence.

Where C++ Sits in the Reflection Landscape

Zig’s comptime system is the closest philosophical match to P2996. @typeInfo(T) returns a tagged union you switch over, and inline for iterates over struct fields at compile time:

fn printFields(comptime T: type) void {
    const info = @typeInfo(T);
    switch (info) {
        .Struct => |s| {
            inline for (s.fields) |field| {
                std.debug.print("{s}\n", .{field.name});
            }
        },
        else => @compileError("expected struct"),
    }
}

Both Zig and P2996 are value-based, zero-overhead, and operate on semantically resolved types, after type-checking rather than on raw syntax. Zig’s version is simpler and more constrained; P2996’s API is larger and integrates with a more complex type system. The philosophical alignment is clear enough that Zig was frequently cited as an existence proof during P2996’s development.

D has had compile-time reflection via __traits and static foreach for years, with direct code injection via mixin. The gap is that D’s injection is string-based, which loses type safety during generation. P2996’s splice notation is typed: [:r:] in a type context produces a type, in an expression context produces a value, and the compiler verifies the distinction.

Rust procedural macros operate on pre-type-checked syntax trees, so a proc macro can see that a struct has a field named x but cannot query sizeof(x.type) or whether a trait is implemented without additional mechanisms. Rust macros are powerful for syntactic transformation but weaker than P2996 for semantic queries, and they require a separate crate with a different compilation model.

Java’s reflection is entirely runtime. The overhead is real, it can throw exceptions, and it defeats JIT optimization. P2996 has no runtime reflection mechanism at all; if you want runtime introspection in C++26, you still build it yourself from the compile-time information.

What P2996 Still Cannot Do

The proposal covers member enumeration, type and template queries, enumerator introspection, offset and alignment queries, and type generation via std::meta::define_aggregate. What it does not provide is arbitrary inline code generation: you cannot write a function that, given a struct definition, automatically generates member functions as first-class members of that struct.

That capability falls to P3294, a companion proposal covering token injection. Token injection was split from P2996 because the questions around hygiene, name capture, and scoping were complex enough that the committee wanted more time before committing to wording. P3294 would enable patterns like automatically generating getters and setters from a struct definition, or building property systems of the kind common in game engines. P2996 alone can generate free functions and new aggregate types, which covers the majority of practical cases, but the gap is visible for certain framework patterns.

Implementation Status

The reference implementation is the Bloomberg Clang fork, available on Compiler Explorer under the experimental P2996 compiler option. This is the most accessible path for experimentation today. Upstream Clang is receiving P2996 support incrementally, with full coverage expected around Clang 19 or 20. GCC has an independent experimental implementation at an earlier stage. MSVC has not announced an experimental build, consistent with Microsoft’s pattern of waiting for final standardization.

For developers who want to start experimenting now, the Bloomberg fork on Compiler Explorer requires no local setup. The full paper is at wg21.link/p2996 and is readable in about an hour; the example sections are particularly concrete.

The twenty years of failed proposals were not wasted. P0194 demonstrated that type-based reflection at C++ scale was unworkable in practice. P1240 established the value-based model. P2996 refined it. The current design is as clean as it is because the committee spent years learning what the wrong approach felt like.

Was this interesting?