D Has Traits, Rust Has Macros, Java Has Runtime: The Design Space C++26 Reflection Occupies
Source: isocpp
A retrospective look at Bernard Teo’s “Flavours of Reflection”, originally published March 3, 2026.
With C++26 reflection (P2996) landing in the working draft and experimental implementations available in both Clang and GCC, it is worth stepping back to ask what kind of reflection this actually is. Every mainstream language already has some answer to introspection, and they differ radically in design. Understanding where C++26 sits in that space clarifies both what the feature does well and what it does not try to do.
The Design Axes
Reflection mechanisms differ along a few important dimensions: when the introspection happens (compile time vs. runtime), what material it operates on (syntax vs. semantics), what it produces (types, values, or code), and how it composes with the rest of the language. C++26, D, Rust, and Java each occupy distinct positions on these axes, and those positions are not accidental.
D: String Names and mixin
D’s compile-time reflection is built directly into the language via __traits. You can iterate over a type’s members at compile time and generate code using mixin:
struct Point { int x; int y; }
static foreach (name; __traits(allMembers, Point)) {
pragma(msg, name); // prints "x" then "y" at compile time
}
// Access the actual member from its name
static foreach (name; __traits(allMembers, Point)) {
alias member = __traits(getMember, Point, name);
// member is now an alias to Point.x or Point.y
}
The model is name-centric. __traits(allMembers, T) returns an array of strings. You then use those strings to retrieve actual members through a second __traits(getMember, ...) call. Code generation uses mixin, which pastes a string into the program as source:
mixin("int " ~ name ~ "_copy;"); // generates a new variable declaration
String-based code injection is surprisingly powerful in practice. D uses it extensively in its standard library (std.traits, std.conv). But it has real limits: there is no typed representation of “a member of type T at offset N with attribute A.” The reflection is shallow in a type-theoretic sense. You get names, and from names you can get members, but the representation of a member is not a first-class value you can store, sort, filter, or pass to a function in a principled way.
Rust: Syntax Without Semantics
Rust has no built-in compile-time reflection. What it has instead is a macro system with two layers. Declarative macros (macro_rules!) match on syntax patterns. Procedural macros (proc_macro) receive a TokenStream representing the annotated item and return a TokenStream to inject into the compilation:
#[derive(Serialize)]
struct Point { x: f64, y: f64 }
The Serialize derive macro, from the serde crate, works by parsing the token stream with syn, inspecting field names and types textually, then generating code with quote. This is powerful enough that serde has become one of Rust’s most-used libraries. But the mechanism has a structural limitation: proc macros run before type checking. They see token streams, not resolved types. A proc macro cannot ask whether a field type implements Display, whether it is a reference or owned, or what its size is. All of that information is available only in the compiler’s type-checked IR, which proc macros do not touch.
Pro macros also live in separate crates compiled before the main crate. They are not inline computations; they are separate programs that happen to run at build time. The ergonomic overhead is real: a typical serialization derive requires depending on syn, quote, and proc-macro2, adding several seconds to clean build times and hundreds of milliseconds to incremental builds.
Java: Runtime All the Way Down
Java’s java.lang.reflect is a runtime system. Type information survives compilation and is queryable at runtime:
Class<?> c = obj.getClass();
for (Field f : c.getDeclaredFields()) {
f.setAccessible(true);
System.out.println(f.getName() + ": " + f.get(obj));
}
This is genuinely useful for frameworks like Spring and Jackson, where the full type information of a class is not known until a configuration file is loaded or a network request arrives. But it comes at cost: every field lookup involves string comparison and table traversal at runtime, reflection can throw checked exceptions (IllegalAccessException, NoSuchFieldException), and tools like GraalVM’s native-image compilation require explicit configuration listing which classes will be reflectively accessed, because the AOT compiler cannot statically determine call targets through reflection.
More fundamentally, Java runtime reflection cannot generate new types. It reads program structure; it does not write it.
C++26: Reflections as Values
C++26’s approach, standardized through P2996, is different from all three of the above. The core is a new operator ^ that produces a value of type std::meta::info:
#include <meta>
constexpr std::meta::info r = ^int; // reflects the type int
constexpr std::meta::info e = ^Color; // reflects an enum
constexpr std::meta::info m = ^Point::x; // reflects a data member
std::meta::info is an opaque scalar type, similar in spirit to a pointer. It is a compile-time constant, storable in constexpr variables, passable to and from consteval functions, and usable as a non-type template argument. The std::meta namespace provides a set of consteval query functions that operate on these values:
constexpr auto members = std::meta::nonstatic_data_members_of(^Point);
// members is a std::vector<std::meta::info> at compile time
std::meta::name_of(members[0]); // returns "x" as a string_view
std::meta::type_of(members[0]); // returns a std::meta::info reflecting int
To convert a reflection back into a program entity, the splice operator [: ... :] does the reverse:
using T = [:^int:]; // T is int
Point p;
p.[:^Point::x:] = 5; // accesses p.x
The practical payoff comes from combining these with the companion expansion statement (template for, from P1306), which iterates a compile-time range and unrolls the loop body once per element:
template <typename E>
constexpr std::string_view enum_to_string(E value) {
template for (constexpr auto e : std::meta::enumerators_of(^E)) {
if ([:e:] == value) return std::meta::name_of(e);
}
return "<unknown>";
}
enum Color { red, green, blue };
static_assert(enum_to_string(Color::green) == "green");
Or struct member iteration for serialization:
template <typename T>
void print_members(const T& obj) {
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
std::println("{}: {}", std::meta::name_of(m), obj.[:m:]);
}
}
This compiles to exactly the same code as writing out each field access manually. The reflection happens entirely at compile time; the output binary contains no std::meta::info values.
Why the Previous C++ Attempts Failed
C++ has been working on reflection since around 2014, through SG7 (Study Group 7). The earlier approach, embodied in proposals like P0194 and the Reflection TS, used a type-based model: reflexpr(T) produced a unique type per reflected entity, and queries were type traits:
// Old Reflection TS style (never standardized)
using ReflT = reflexpr(MyStruct);
using Members = std::reflect::get_data_members_t<ReflT>;
using FirstMember = std::tuple_element_t<0, Members>;
constexpr auto name = std::reflect::get_name_v<FirstMember>;
The fundamental problem was iteration. Every reflexpr expression produced a new distinct type. Iterating over members required recursive template instantiation over a type-level tuple, with O(N²) compile-time cost for N members. You could not pass a “list of member reflections” to a function because such a list was a type, not a value, and types cannot be returned from functions or stored in arrays. The API was verbose, slow to compile, and impossible to use with standard algorithms.
P2996’s insight was to collapse this: reflections are values, not types. A std::vector<std::meta::info> containing ten member reflections costs no more to pass around than a vector of ten integers. It can be filtered with std::ranges::filter, sorted, transformed, and returned from a consteval function. This is what makes the API feel like ordinary code rather than template metaprogramming.
What Is Still Missing
P2996 is introspection only; it reads program structure but does not write it, beyond the define_aggregate function for creating new struct types. The write side is the subject of P3294 (“Code Injection with Token Sequences”), which would allow generating new declarations from compile-time computations. P3294 did not make C++26 but is being actively developed for a future standard.
The experimental implementations are in the Bloomberg Clang fork, enabled with -freflection -std=c++2c. Compiler Explorer hosts a “Clang (experimental P2996)” option that lets you test examples directly in the browser without installing anything. A GCC branch exists as well, though it is less complete.
The Position C++26 Occupies
Looking at the four approaches together: D operates on names at compile time, Rust operates on syntax at build time, Java operates on types at runtime, and C++26 operates on the semantic model at compile time. That last combination, semantic plus compile time, is what neither D nor Rust achieves. D sees names and must look up semantics through a second call; Rust sees tokens and cannot access semantics at all during macro expansion. C++26 reflections carry the fully resolved type information that the compiler computed during semantic analysis, available as first-class values in constant expression evaluation.
That is the position Bernard Teo’s comparative analysis points toward: not that C++26 reflection is more powerful in every dimension, but that it occupies a specific and previously unfilled position in the design space. Thirty years of proposals finally converged on a model that is composable with ordinary C++ code, zero-cost at runtime, and expressive enough to replace entire categories of code generation tools, from enum-to-string utilities to serialization frameworks to binding generators, without leaving the language.