Compile-Time, Semantic, Universal: The Design Bets Behind C++26 Reflection
Source: isocpp
The debate over C++ reflection ran for more than a decade and produced at least half a dozen proposals that didn’t make it before P2996 was voted into the C++26 working draft. Bernard Teo’s recent piece on isocpp.org organizes the topic around the idea of “flavours,” which is the right framing. Not all reflection systems work the same way, and understanding the axes of the design space explains both what C++26 chose and why each earlier attempt fell short.
Mapping the Design Space
Any reflection system occupies a position along a few independent axes.
Compile-time vs runtime. Runtime reflection, as Java and Python programmers know it, preserves type metadata in the binary so programs can query it while running. obj.getClass().getDeclaredFields() in Java, inspect.getmembers(obj) in Python. This enables plugin systems, debuggers, and serialization frameworks that don’t require code generation steps. It also costs memory, execution time, and, in Java’s case, type safety, since setAccessible(true) can bypass private at runtime.
Compile-time reflection operates entirely during compilation. The queries run at build time, the results shape the code generated, and by the time the binary runs, the reflection calls are gone. C++26 chose this direction exclusively.
Introspective vs generative. Introspective reflection reads the structure of a program: member names, types, base classes, enumerators. Generative reflection writes new code based on that structure. Java’s java.lang.reflect is primarily introspective. Rust’s procedural macros are primarily generative. The most useful systems do both, and C++26 is designed for both: the ^^ operator and std::meta functions handle the reading side, while splice operators [: :] and expansion statements handle code generation.
Syntactic vs semantic. Rust’s procedural macros receive a token stream, not a resolved type. They can examine the tokens in struct Foo { x: i32 } and generate code from them, but they cannot ask “what are the trait bounds on this type” without explicit plumbing. C++26 reflection operates on the fully resolved semantic model. Name lookup is complete, overload resolution is done, template parameters are substituted. You see what the compiler sees.
Opt-in vs universal. Many systems require the type author to do something before the type is reflectable. Java has marker interfaces, Rust has #[derive(Reflect)], and C++ has had decades of BOOST_FUSION_ADAPT_STRUCT macros. C++26 P2996 is universal: any type, including those in third-party headers you cannot modify, exposes its members through std::meta::nonstatic_data_members_of.
C++26’s position is compile-time, generative and introspective, semantic, and universal. That combination is what makes the feature unusual relative to what came before it.
Why Earlier Proposals Failed
The committee reviewed Matúš Chochlík’s P0194 series through 2018 and tabled it without adoption. The proposals were technically detailed and implementable, but they required macros for registration, placing them firmly in the opt-in quadrant. EWG wasn’t satisfied with that. If library types and third-party types couldn’t be reflected without modification, the feature couldn’t serve as the foundation for generic serialization, property systems, or ORM-style frameworks.
The conceptual shift came in 2019 with Vandevoorde’s P1717, which argued that reflection should produce values, not a parallel type hierarchy. Earlier proposals had you writing reflect::member_t<0, Foo> as a type, working within the C++ type system to carry reflected information around. P1717 proposed that ^^T should return an opaque literal value of type std::meta::info, passable in constexpr contexts, storable, and processable by ordinary consteval functions. This reframing unlocked everything that followed. Template metaprogramming with normal function-call syntax, rather than deeply nested type machinery, turned out to be far more reviewable and implementable.
The Operator and the Model
The ^^ operator takes any C++ construct and returns a std::meta::info representing it:
constexpr auto t = ^^int;
constexpr auto members = std::meta::nonstatic_data_members_of(^^MyStruct);
constexpr auto enums = std::meta::enumerators_of(^^Color);
constexpr std::string_view name = std::meta::name_of(members[0]);
Splice operators reconstruct the entity at the point of use:
using T = [:^^int:]; // T is int
obj.[: some_member_info :] = 5; // access a member by reflected info
These two halves, combined with expansion statements for iterating compile-time ranges, cover most practical use cases. Enum-to-string has been a persistent pain in C++, requiring either the magic_enum library’s __PRETTY_FUNCTION__ trick (which has a ~256 value limit and relies on non-standard compiler behavior) or X_MACRO patterns that make the enum definition awkward. With P2996:
template <typename E>
constexpr std::string enum_to_string(E value) {
template for (constexpr auto e : std::meta::enumerators_of(^^E)) {
if (value == [:e:]) return std::string(std::meta::name_of(e));
}
return "<unknown>";
}
No macros, no value range limits, no reliance on compiler-specific name mangling.
Struct serialization to JSON, currently requiring BOOST_DESCRIBE_STRUCT or BOOST_FUSION_ADAPT_STRUCT opt-ins, becomes a plain loop:
template <typename T>
std::string to_json(const T& obj) {
std::string result = "{";
bool first = true;
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^^T)) {
if (!first) result += ",";
std::string key = std::meta::name_of(m);
result += serialize_field(key, obj.[: m :]);
first = false;
}
return result + "}";
}
No registration macros. No specializations to maintain. Any struct works.
The Language Comparison
Rust is the most instructive comparison because it is also a systems language with zero-overhead goals. Its answer to the reflection problem is procedural macros: compile-time token-level code transformation. #[derive(Serialize)] reads your struct’s tokens and generates a Serialize implementation. The ecosystem around this, particularly serde, is mature and fast. But proc macros require the type author to opt in, operate on syntax rather than semantics, and require writing a separate Rust program in a separate crate with its own compilation pass. The barrier is high enough that most developers consume proc macros rather than write them.
C++26 reflection is available to any template author without additional tooling, operates on the fully resolved semantic model, and requires no opt-in from the type being reflected.
C# source generators, added in .NET 5 and used by System.Text.Json for zero-overhead serialization, occupy a similar space to Rust proc macros: they see both syntax and semantics and generate code at compile time. But they run out-of-process and require annotating a JsonSerializerContext subclass before source generation kicks in. That registration step is precisely the opt-in ceremony C++26 is designed to avoid.
Java and C# both have runtime reflection that enables things C++26 cannot, such as loading plugins at runtime and inspecting types whose names were not known during compilation. C++26 makes no attempt to cover that ground. Programs carry no reflection overhead unless they use reflection, which is consistent with the language’s foundational guarantee.
The Annotations Gap
The area where C++26’s current design is most noticeably weaker than C# and Java is user-defined annotations. C# attributes like [JsonPropertyName("my_key")] are first-class and queryable via reflection, and they are central to how .NET serialization customization works. P3394, “Annotations for Reflection,” proposes adding this capability to C++:
struct MyStruct {
[[json_name("x_coord")]] int x;
[[no_serialize]] int cache;
};
with annotations readable through the reflection API at compile time. This proposal was under active review through 2025; its presence or absence in the final C++26 standard will determine whether annotation-driven frameworks built on P2996 need workarounds or can adopt a first-class mechanism.
Implementation State
The Bloomberg clang-p2996 fork has had a working implementation available for some time and is accessible via Compiler Explorer. Patches from that fork have been moving into mainline LLVM. GCC has ongoing work but was less mature as of mid-2025. MSVC has not announced a timeline.
P2996 is in the C++26 working draft. Companion proposals covering annotations, function parameter reflection, and attribute reflection are continuing through the committee process in parallel.
The decade-long wait produced a cleaner design than what was available in 2016. Universality, semantic depth, and value-based representation are the properties that make P2996 usable for the problems C++ programmers actually have. The specific flavour C++ chose, compile-time and semantic and universal and generative, was shaped by constraints that don’t apply to languages with garbage collectors or runtime type systems. Understanding those constraints separately from the syntax is worth doing before the tutorials start treating ^^ as self-evident.