From reflexpr to P2996: How C++26 Finally Got Compile-Time Reflection Right
Source: isocpp
C++ has been trying to get reflection right for a long time. The feature accepted into C++26 didn’t arrive on the first attempt, or the second. Understanding what makes P2996 different from its predecessors tells you more about the design than any API tour alone.
The reflexpr Dead End
The first serious standardization push was P0194, the reflexpr proposal, which targeted C++17 and then C++20. It introduced a keyword reflexpr(T) that produced a meta-object type, not a value:
// P0194 style — never standardized
using meta_T = reflexpr(MyStruct);
using first_member = std::reflect::get_element_t<
std::reflect::get_data_members_t<meta_T>, 0>;
The approach is pure type metaprogramming: reflections are types, and you work with them using the same recursive template machinery that C++ programmers have been fighting since the late 1990s. To loop over members you write recursive template specializations. Getting from a reflected type to its members goes through std::reflect::get_data_members_t, which returns a type pack.
This wasn’t wrong exactly; it was consistent with how C++ metaprogramming had always worked. It was also barely tolerable to use, difficult to compose, and generated enormous quantities of template instantiations. More fundamentally, type-based reflections can’t be passed as function arguments or stored in variables. You can’t write a helper function that takes a reflection and does something with it; you can only write templates that take the reflected type as a template parameter.
The committee rejected reflexpr for C++17 and again for C++20. The diagnosis was that the type-based model was the wrong foundation.
The Value-Based Turn: P1240 and P2996
P1240, “Scalable Reflection” (2019), made the conceptual shift. Reflections became first-class values with a scalar type, std::meta::info. The ^ operator became the reflection operator, producing a std::meta::info at constant evaluation time:
constexpr auto r = ^MyStruct; // r is a std::meta::info
constexpr auto r2 = ^int; // reflects the type int
constexpr auto r3 = ^Color::Red; // reflects an enumerator
P2996 is P1240’s direct descendant, expanded and refined through seven revisions, and it was voted into the C++26 working draft at the February 2025 Hagenberg meeting. The core idea held: reflections are values, the entire std::meta API is consteval, and the only way to get a reflected entity back into usable C++ is through the splice operator [: :].
What the API Looks Like
The <meta> header exposes namespace std::meta with a set of consteval functions:
// Enumerate members
consteval std::vector<info> nonstatic_data_members_of(info r);
consteval std::vector<info> enumerators_of(info r);
consteval std::vector<info> bases_of(info r);
consteval std::vector<info> member_functions_of(info r);
// Query properties
consteval std::string_view name_of(info r);
consteval info type_of(info r);
consteval std::size_t offset_of(info r);
consteval bool is_public(info r);
consteval bool is_class(info r);
consteval bool is_enum(info r);
Because std::meta::info is a scalar structural type, it can serve as a non-type template argument, be stored in constexpr variables, be compared with ==, and be passed between consteval functions. This is what the type-based reflexpr approach fundamentally could not do.
The splice operator bridges compile-time reflections back to runtime-visible entities:
struct Point { float x; float y; float z; };
constexpr auto members = nonstatic_data_members_of(^Point);
// members[0] reflects Point::x
Point p{1.0f, 2.0f, 3.0f};
p.[:members[0]:] = 10.0f; // accesses p.x directly
Splicing works for types, function calls, template instantiation, and member access. using T = [:^int:] is the identity; [:^std::vector:]<int> is std::vector<int>.
What You Can Do With It
The canonical examples appear quickly once you have the API. Enum-to-string, always a macros-or-external-tooling problem in C++, becomes straightforward:
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>";
}
enum class Direction { North, South, East, West };
static_assert(enum_to_string(Direction::East) == "East");
The template for construct is a compile-time expansion statement from the companion proposal P1306. It unrolls a constexpr range at compile time, generating one branch per enumerator. The result compiles to a flat if-chain with no runtime overhead.
Struct serialization follows the same pattern:
template<typename T>
std::string to_json(const T& obj) {
std::string out = "{";
bool first = true;
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
if (!first) out += ", ";
out += '"' + std::string(std::meta::name_of(m)) + "\":";
out += serialize_value(obj.[:m:]);
first = false;
}
return out + "}";
}
What libraries like Boost.Hana or magic_get approximate through elaborate template tricks becomes direct. Member names are available, field access is direct, and the whole thing produces zero runtime overhead.
Checking for a member by name at compile time, previously requiring SFINAE gymnastics, becomes a loop:
template<typename T>
consteval bool has_member_named(std::string_view name) {
for (auto m : std::meta::members_of(^T))
if (std::meta::name_of(m) == name) return true;
return false;
}
What P2996 Deliberately Excludes
Bernard Teo’s article on isocpp.org traces the distinctions between different varieties of reflection, and understanding what P2996 omits is as important as what it includes.
P2996 is read-only, compile-time, structural reflection. It has no code injection. You cannot add members to a class from within a consteval function. You cannot create new types at runtime. Access control is respected; private members are not accessible through reflection by default.
This is a deliberate scoping decision. Code injection and metaclasses, which Herb Sutter proposed in P0707, require P2996-style reflection as a foundation but are substantially more complex as language features. The committee separated them: P2996 provides the introspective half; injection proposals like P3294 will handle the generative half, likely targeting C++29.
This also means C++26 reflection is not the same kind of thing as Java’s java.lang.reflect or Python’s inspect. Those systems operate at runtime on heap-allocated type descriptors and can traverse types that didn’t exist when the program was compiled. C++26 reflection is closed-world: the compiler has complete knowledge at compile time, and reflections are baked into the binary as generated code. You gain zero runtime overhead and full type safety; you lose the ability to do anything with types loaded from plugins or created dynamically.
Rust’s approach, via procedural macros like #[derive(Serialize)], achieves similar goals but operates on token streams rather than typed AST nodes. A std::meta::info for a data member knows its declared type, its offset, its access specifier. Rust proc macros receive syntactic tokens and have to reconstruct semantic meaning themselves from the token soup. Both approaches are powerful; P2996 favors tasks that require semantic precision over syntactic transformation.
Implementation Status
The reference implementation throughout the standardization process has been the Bloomberg Clang fork, accessible on Compiler Explorer under “Clang (experimental P2996)”. It covers the core API including nonstatic_data_members_of, enumerators_of, bases_of, splicing, and template for expansion. GCC has an in-progress implementation branch that is less complete as of early 2026. MSVC has acknowledged the proposal but has no public implementation yet.
The compile-time cost is real but manageable. Heavy reflection usage, such as iterating all members of all types across a large codebase, can increase compile times by a factor of two to five compared to hand-written equivalents. Against the actual alternative, recursive template instantiation through Boost.Hana or similar libraries, reflection is generally faster to compile because it avoids instantiation explosion. nonstatic_data_members_of(^T) is a single compiler intrinsic call rather than a recursive chain of template specializations.
The Long View
C++ spent roughly two decades without reflection, not because the committee didn’t want it but because each proposed mechanism turned out to have structural problems that only revealed themselves under real use. The reflexpr approach wasn’t abandoned arbitrarily; it was prototyped, tried, and found to be too rigid for the compositional patterns that real metaprogramming requires.
P2996’s value-based model, where reflections are first-class values rather than types, unlocks those patterns. The fact that both Clang and GCC have working implementations, with the Bloomberg fork serving as a live testbed for several years before standardization, means the feature arrives with unusual implementation maturity for something of this scope.
C++26 won’t have metaclasses, runtime reflection, or code injection. What it will have is a solid, zero-cost foundation that future proposals can build on, and enough immediate utility through enum stringification, struct serialization, generic comparison, and member-name introspection to justify the wait. The follow-on proposals, particularly around code injection, will determine how far the generative half of reflection goes. For now, the introspective half landing cleanly is the meaningful milestone.