· 7 min read ·

C++26 Reflection and the Value That Changed Everything

Source: isocpp

Compile-time reflection is coming to C++26 through P2996, and the experimental implementation in a Bloomberg-maintained Clang fork already works well enough to prototype real use cases on Compiler Explorer today. Bernard Teo’s retrospective on isocpp.org from early March 2026 covers the taxonomy of what reflection can do. The more interesting story, though, is why the final design works when over a decade of earlier proposals did not, and what that means for the library ecosystem that grew up in the absence of language support.

The reflexpr Dead End

The first serious attempt at C++ reflection was reflexpr, developed through a series of papers (P0194, P0385) with work ongoing from roughly 2014 to 2020. The design used a special keyword returning a type that encoded the reflected entity:

// reflexpr style -- never standardized
using meta_T = reflexpr(MyStruct);
using members = std::reflect::get_data_members_t<meta_T>;

The core problem with this approach is that types are not composable in the way values are. To iterate over the members of a struct, you needed index sequences, recursive template instantiation, and the full machinery of pre-C++17 template metaprogramming. You could not store a “reflected member” in a constexpr variable and pass it to a function. You could not put reflected entities into an array and call std::sort on them. Every operation required writing more TMP infrastructure, and the resulting code looked nothing like ordinary C++. The ergonomics were poor enough that reflexpr never made it into a standard.

The fundamental limitation was not a syntax problem. It was a model problem: encoding program entities as types forces you into the type system as your only means of computation, and C++ template metaprogramming is a famously hostile environment for anything beyond trivial operations.

The Design Shift: Types to Values

The conceptual breakthrough came with P1240 (“Scalable Reflection”, Andrew Sutton and Faisal Vali, 2019), which reframed the problem entirely. Instead of returning a type, the reflect operator should return a value of a scalar type. P2996 formalized this as std::meta::info, an opaque, consteval-only scalar that encodes a handle to a program entity.

Because std::meta::info is a value, it works anywhere a constant expression is valid. You can store it in a constexpr variable, pass it to a consteval function, put it in a constexpr std::vector, filter it with std::ranges::filter_view, count it with .size(). The entire C++ constexpr computation model becomes available for working with reflected entities. This is not a minor ergonomic improvement; it is a different programming model.

Two Operators

P2996 introduces two core syntactic constructs. The reflect operator ^ takes a program entity and produces a std::meta::info value representing it:

constexpr std::meta::info r_int    = ^int;
constexpr std::meta::info r_class  = ^MyClass;
constexpr std::meta::info r_member = ^MyClass::foo;
constexpr std::meta::info r_ns     = ^::;   // global namespace

The splice operator [: :] is the inverse: it takes a std::meta::info and injects the entity back into source code at the point of use. The context determines what kind of entity is expected:

constexpr auto r = ^int;
[:r:] x = 42;            // type context: equivalent to int x = 42
using T = [:r:];         // T is int
std::vector<[:r:]> v;   // template argument: vector<int>
obj.[:member_info:] = 5; // member access

Splicers resolve entirely at compile time. The std::meta::info value must be a constant expression, so the compiler knows the actual entity before code generation. There is no runtime representation of these operations; the overhead is entirely at compile time.

The Metafunction Library

The <meta> header provides a library of consteval functions for querying reflected entities. The most useful ones for everyday code are:

  • std::meta::data_members_of(^T) — all non-static data members
  • std::meta::enumerators_of(^E) — all enumerators of an enum type
  • std::meta::name_of(r) — compile-time string_view of the entity’s name
  • std::meta::type_of(r)meta::info for the type of a member
  • std::meta::offset_of(r) — byte offset of a data member
  • std::meta::bases_of(^T) — base classes
  • std::meta::is_public(r), std::meta::is_static(r) — predicate queries

All of these return either std::meta::info, a bool, an integer, or a std::string_view. All are consteval and have zero runtime cost.

The companion template for construct, also part of the C++26 package, expands a compile-time range into separate instantiations:

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");

Before P2996, reliable enum-to-string required either hand-written switch statements or libraries like magic_enum, which use compiler-specific __PRETTY_FUNCTION__ and __FUNCSIG__ tricks to recover names through string manipulation at compile time. The reflection approach is cleaner, portable, and does not depend on implementation-defined compiler behavior.

Serialisation is the other headline use case. Today, libraries like Boost.Hana require annotating structs with special macros, and Boost.PFR relies on aggregate-initialization tricks that break on non-aggregate types and have edge cases with bitfields. With P2996:

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

No macros, no preprocessor, no aggregate hacks. The struct fields are directly enumerable, and the splice operator accesses them by name without any intermediate layer.

The D Comparison

The language that solved this problem first is D. __traits and std.traits in D have provided compile-time field enumeration and member access for well over a decade:

import std.traits;
alias fields = FieldNameTuple!MyStruct;  // compile-time tuple of field names

D also has string mixin for code injection, which is roughly analogous to the companion P3294 proposal for C++. The difference is that D’s reflection is an ad-hoc collection of __traits builtins with inconsistent semantics accumulated over time, while P2996 is a principled value-based API organized around a single type. D solved the problem pragmatically; C++ is solving it systematically. The resulting P2996 API is noticeably more composable and easier to reason about, at the cost of a much longer standardization timeline.

Rust has no built-in compile-time reflection. Its derive macros (#[derive(Debug, Serialize)]) operate on token trees via proc macros, which requires writing against the syn and quote crates, parsing a typed AST manually, and generating token streams. The ergonomic gap between writing a Rust proc macro and writing a P2996 consteval function is significant. Java and Python both offer runtime reflection, which is a different trade-off: maximum flexibility, meaningful performance cost, and no compile-time guarantees.

Implications for Existing Tooling

Several significant C++ tools exist specifically because the language lacked reflection.

Qt’s meta-object compiler (moc) is a separate build step that parses Qt class declarations and generates moc_*.cpp files providing runtime type information, signal/slot wiring, and property systems. It exists because C++ had no native way to enumerate members or attach metadata to types at compile time. P2996 provides the foundation for a moc-free property and signal system in new code, though Qt’s full runtime type system carries more scope than just field enumeration.

The magic_enum library, which works by extracting enum names from compiler-specific function signature strings, is a workaround for the absence of enumerators_of. It will be unnecessary once P2996 is widely available.

Boost.PFR, which enables structured bindings and field iteration for aggregates through initialization tricks, is similarly a workaround. The Boost.PFR documentation explicitly notes the limitations: it only works for aggregates, breaks with certain field types, and has no access to field names at runtime without additional preprocessor support. P2996 handles all of these cases cleanly.

Compile-Time Cost

P2996 is not free at compile time. data_members_of produces a constexpr vector<meta::info> with one entry per member, and template for generates one instantiation per element in the expanded range. A struct with 100 members iterated through template for produces 100 instantiation steps.

Compared to TMP-based alternatives, this is substantially better. Boost.Hana and similar libraries can produce exponential template instantiation graphs in the worst case. P2996’s approach is linear in the number of members for most use cases. Early measurements from the Bloomberg Clang fork indicate roughly 2 to 5x compile time increase for structs with 50 to 100 fields and full serialization codegen, compared to 10 to 50x for equivalent Hana-based code. That is a real cost, but it is predictable and bounded.

Code Injection Is Still Coming

P2996 is the read side of reflection: inspecting program entities. The write side, P3294, introduces code injection: declaring new members inside a class at compile time using consteval { } blocks and injection syntax. This is what enables metaclasses, automatic operator== generation, and the kind of boilerplate elimination that today requires CRTP or code generation scripts. P3294 was still undergoing design revision as of mid-2025 and may arrive alongside P2996 in C++26 or slip to a later standard.

Even without P3294, P2996 alone represents a substantial capability increase. The experimental Clang branch is accessible on Compiler Explorer today, the feature has real implementation work behind it rather than just a proposal document, and Bernard Teo’s overview lays out the full taxonomy of what the landing feature looks like. After a decade of false starts, compile-time reflection in C++ is finally in a form that is actually pleasant to use.

Was this interesting?