· 7 min read ·

C++26 Reflection and the Value-Based Design That Changes Everything

Source: isocpp

Bernard Teo published a retrospective look at C++26 reflection earlier this month, covering the different “flavours” the feature encompasses. It’s worth using that as a springboard to dig into the part that I think matters most: why this particular proposal succeeded where a decade of predecessors did not, and what that design choice means for how you actually use the feature.

A Brief History of Getting This Wrong

C++ has been trying to get reflection right since at least 2016, when Matúš Chochlík and collaborators introduced P0385. The proposal used a reflexpr() keyword that produced a type encoding information about a C++ entity. Iterating over a struct’s members meant manipulating a type-list, which meant recursive template instantiation, which meant the familiar wall of unreadable error messages and exponential compile times.

The Reflection TS (finalized around N4856) refined this approach without changing the fundamental model. reflexpr(Point) gave you a meta-object type; std::reflect::get_data_members_t<reflexpr(Point)> gave you another type representing the member list. You could query it, but composing queries required chaining type aliases. There was no way to store a reflection in a variable, filter a list of members with std::ranges::filter, or pass a reflection to a helper function and get one back. Everything was locked inside the type system.

P1240 moved to the ^ operator (pronouncing it “hat” or “caret”) but kept reflections as types. It was directionally right but architecturally the same.

The shift that actually worked came with P2996, authored by Wyatt Childers, Peter Dimov, Dan Katz, Barry Revzin, Andrew Sutton, Faisal Vali, and Daveed Vandevoorde. P2996 was voted into the C++26 working draft at the February 2025 WG21 meeting in Sofia. The ^ operator stayed. What changed is what it produces.

The std::meta::info Type and Why It Matters

In P2996, ^expr reflects a C++ entity into a value of type std::meta::info. Not a type. A value.

constexpr std::meta::info r1 = ^int;        // reflects the type int
constexpr std::meta::info r2 = ^MyStruct;   // reflects a class
constexpr std::meta::info r3 = ^my_func;    // reflects a function
constexpr std::meta::info r4 = ^::;         // reflects the global namespace

std::meta::info is a scalar, equality-comparable, trivially copyable type. It is essentially an opaque handle into the compiler’s internal entity table. Two info values compare equal if they reflect the same entity. You can store it in a constexpr variable, put it in a constexpr std::vector, pass it to and return it from consteval functions, and use ordinary if and for to work with it.

That last point is where the design opens up. In the old type-based approach, “iterating over members” meant writing recursive template specializations. In P2996, it looks like this:

constexpr auto members = std::meta::nonstatic_data_members_of(^MyStruct);
// members is a constexpr std::vector<std::meta::info>
for (auto m : members) {
    // m is just a value
}

You can filter that vector with std::ranges::filter. You can sort it. You can find the first member satisfying a predicate. All at compile time, using the same algorithms you use at runtime.

The full std::meta namespace exposes everything you would want: nonstatic_data_members_of, members_of, bases_of, enumerators_of, template_arguments_of. Predicate functions cover access (is_public, is_private), storage class (is_static_member), and entity kind (is_type, is_function, is_namespace, and so on). Layout queries like offset_of, size_of, and alignment_of round out the introspective surface.

Splicers: Closing the Loop

Introspection alone does not get you anywhere useful. You need to go back from a reflection to actual C++ syntax. That is what splicers handle.

The syntax [: r :] takes a std::meta::info value and splices it back into the program. The context determines the interpretation:

using T = [:^int:];              // T is int
std::vector<[:^std::string:]> v; // vector<string>

MyStruct obj;
obj.[:member_reflection:] = 42;  // access a member by reflection

Combined with P1306’s expansion statements (template for), this is where code generation becomes straightforward:

template <typename T>
void print_members(T const& obj) {
    constexpr auto members = std::meta::nonstatic_data_members_of(^T);
    template for (constexpr auto m : members) {
        std::println("{} = {}",
            std::meta::identifier_of(m),
            obj.[:m:]);
    }
}

template for stamps out a copy of its body for each element in the compile-time range, substituting the current reflection. The splicer obj.[:m:] becomes an actual member access. std::meta::identifier_of(m) returns a std::string_view of the member’s name.

Enum-to-String Without the Hacks

Every C++ project eventually needs to convert an enum to a string. The current art is embarrassing: X macros, switch statements maintained by hand, or libraries like magic_enum that abuse __PRETTY_FUNCTION__ and compiler-specific string parsing to extract enum names at runtime.

With P2996, the solution is a library function:

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::identifier_of(e);
        }
    }
    return "<unknown>";
}

enum class Direction { North, South, East, West };
static_assert(enum_to_string(Direction::East) == "East");

This is zero overhead. The whole thing resolves at compile time. The same pattern handles string-to-enum by comparing identifier_of against the input.

Serialization Without Macros or Code Generators

JSON serialization libraries currently require you to annotate your structs with macros:

// nlohmann/json today
NLOHMANN_DEFINE_TYPE_INTRUSIVE(Person, name, age, salary)

You have to list the fields explicitly, keep the macro in sync with the struct, and accept that the macro expands to something the debugger cannot easily show you. With P2996:

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

Any struct. No annotation. No macro. No separate build step. The compiler derives everything from the type definition at the call site.

How This Compares to Other Languages

Zig has had something spiritually similar for years. @typeInfo(T) returns a std.builtin.TypeInfo tagged union, and inline for over Struct.fields stamps out per-field code at compile time:

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

Zig deserves credit for demonstrating that value-based compile-time type inspection is ergonomic and practical. P2996 is richer, covering access specifiers, template arguments, layout information, and a more uniform entity model, but the conceptual debt to Zig’s comptime approach is real.

Rust handles this territory with procedural macros. A #[derive(Serialize)] attribute triggers a compiler plugin that receives the struct’s token stream and emits serialization code. The syn and quote crates make this tractable, but proc macros operate on syntax, not semantics. You are parsing token trees, not querying a typed model. Writing a proc macro correctly requires understanding the Rust macro expansion pipeline, handling hygiene, and testing against a separate compilation step. P2996 requires none of that infrastructure: introspection and splicing are part of the language, available anywhere a consteval function can run.

Java and C# reflection are runtime systems. They enable powerful frameworks, but the cost is real: type erasure, heap allocation, security manager checks, and errors that surface at runtime rather than compile time. C++26 reflection produces zero runtime overhead because all queries happen during compilation. There is no runtime std::meta::members_of() call; the compiler evaluates it and the results are baked into the generated code.

Implementation Status

The Bloomberg team maintains clang-p2996, a fork of Clang that implements the proposal. It is available on Compiler Explorer under the “clang (experimental P2996)” compiler option. The examples in this post work on that toolchain. GCC work is ongoing but not publicly available as of early 2026. MSVC has not shipped an experimental implementation yet.

P2996 does not travel alone. It depends on P1306 (expansion statements) for the template for construct, and code injection, the ability to programmatically add members to a class, is a separate companion paper (P3294) still under development. Introspection alone is already substantial, but the injection story will determine how far this feature reaches into territory currently owned by code generators and build-time tools.

What Changes in Practice

The libraries that will be affected most are the ones currently relying on macros or external tooling to bridge the gap between type information and runtime behavior. JSON serialization, ORM mapping, RPC stub generation, CLI argument parsing from structs, mock generation for testing: all of these have implementations today that require either user annotation or a separate build step. P2996 makes all of them solvable as ordinary header-only library code.

The more interesting shift is in what becomes possible for smaller, domain-specific use cases. A protocol buffer alternative that you write yourself in an afternoon. A configuration file parser that maps TOML keys to struct fields with compile-time validation. A debug pretty-printer that regenerates itself whenever the struct changes. These are things that currently require either accepting a heavyweight dependency or writing brittle macro glue.

Reflection in C++26 is compile-time, type-safe, composable with standard algorithms, and implemented with tooling that already exists in a usable form. The flavors Bernard Teo describes, static versus dynamic, introspective versus generative, are real distinctions worth understanding. The unifying thread across all of them is that std::meta::info is a value, and that single decision is what makes the rest of it work.

Was this interesting?