· 6 min read ·

Twenty Years of Workarounds: What C++26 Reflection Provides

Source: isocpp

Bernard Teo’s piece on the flavors of C++ reflection, published earlier this month, covers P2996’s design space thoroughly. The most interesting thread running through it is the historical one: this feature has been anticipated for so long that the C++ community built an entire ecosystem of workarounds, each one implicitly describing what reflection should eventually provide.

P2996 landed in the C++26 working draft at the Wrocław plenary in November 2024, after a design process that stretched back to at least 2013. The feature it introduces, compile-time static reflection via std::meta::info, is both less and more than what developers might expect from the word “reflection.” Understanding that distinction is the most useful thing to take away from this.

What P2996 Is

P2996 introduces three things: a new operator ^, a splice syntax [: :], and a standard library namespace std::meta populated with consteval query functions.

The ^ operator takes a C++ entity, whether a type, function, variable, data member, enumerator, namespace, or template, and returns a std::meta::info value representing it. That value is an opaque scalar, usable in constexpr contexts, passable to consteval functions, storable in compile-time arrays. The splice operator [: :] goes the other direction: it takes a std::meta::info and substitutes it back into the program as the entity it represents.

constexpr std::meta::info r = ^int;   // reflects the type int
using T = [:r:];                      // T is now int

This is simple on the surface, but the implications compound quickly once you realize that std::meta::info values can be collected, filtered, and iterated at compile time. Consider enum-to-string, the canonical motivating example:

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

The template for here is from a companion proposal, P1306, which introduces expansion statements for iterating over compile-time sequences. The two proposals are effectively designed as a pair.

Generic serialization follows the same structure:

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

No macros, no registration, no separate code generator. The member names and types come directly from the compiler’s semantic model.

What the Workarounds Were Asking For

The C++ ecosystem’s reflection-shaped hole produced some genuinely impressive engineering.

magic_enum extracts enum names at compile time by abusing __PRETTY_FUNCTION__ and __FUNCSIG__: the compiler embeds the enumerator name in a function’s pretty-printed signature during template instantiation, and magic_enum parses it out as a string. This works, but it carries a default enumerator count limit of around 256, breaks for enumerators with values outside a configurable range, and depends entirely on compiler implementation details that the standard never guaranteed.

Boost.PFR exploits structured binding rules and aggregate initialization to infer how many members a struct has, then bind to them. The approach requires the type to be an aggregate, provides no access to member names, and cannot handle base classes or non-public members. For the struct-to-tuple use case on simple aggregates it works well; for anything beyond that, it falls apart.

Boost.Hana pushed template metaprogramming to its theoretical limit, treating types as values and implementing functional-style operations over them. Hana still required manual registration via BOOST_HANA_DEFINE_STRUCT, and its compile times were a persistent complaint. The library is a masterpiece of what templates can accomplish; it is also a sustained demonstration of why templates should not have to accomplish that.

All three libraries were documenting the same requirement: developers needed a way to enumerate a type’s members, get their names and types, and operate over them generically. The convergence on the same use cases, serialization, ORM mapping, debug printing, RPC stubs, test data generation, happened because those use cases were genuinely important. P2996 addresses all of them without a single macro.

The Compile-Time-Only Design

P2996 is not runtime reflection. This is the most important thing to understand about its design, and it is a deliberate choice rather than an oversight.

Every function in std::meta is consteval, meaning it can only be called during compilation. A std::meta::info value does not exist at runtime; it cannot be stored in a runtime std::vector; you cannot enumerate a type’s members given only a runtime type identifier.

This means P2996 cannot directly implement patterns like Java’s Spring framework, which scans classes at startup and wires dependencies dynamically, or Python’s getattr(obj, "method_name") with a runtime string. You can use P2996 to generate a runtime dispatch table at compile time, but the reflection machinery itself is not available after compilation finishes.

The reason for this is C++‘s zero-overhead principle. Java reflection has well-documented performance problems because every reflective call passes through the JVM’s reflection machinery, involving heap allocation and dynamic dispatch. C++26 reflection generates the same machine code as a hand-written equivalent; the compiler resolves every [: :] splice before producing any executable. No type metadata is stored in the binary unless you explicitly generate it.

For the dominant C++ use cases, the compile-time constraint is rarely a real limitation. Where you do need runtime dispatch, the correct pattern is to use P2996 to generate the dispatch table:

template <typename T>
auto make_getter_map() {
    std::unordered_map<std::string, std::function<std::any(T const&)>> map;
    template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
        map[std::string(std::meta::name_of(m))] = [](T const& obj) -> std::any {
            return obj.[:m:];
        };
    }
    return map;
}

The lambda is generated at compile time; the map is usable at runtime with string keys. This is the appropriate tradeoff for C++.

How It Compares to Other Languages

The closest analogue in another systems language is D’s compile-time reflection. D has had __traits(allMembers, T) and static foreach since the early 2000s, and C++26’s design clearly draws from D’s experience. The structural difference is that D’s reflection is a collection of special __traits keywords, while P2996’s std::meta::info is a genuine first-class value that composes naturally with existing constexpr machinery. The C++26 approach is more uniform and more extensible.

Rust’s procedural macros are frequently cited as Rust’s equivalent, but they operate on token streams rather than semantic information. A Rust proc macro receives the syntax of an item and produces new token streams; it cannot query a type’s memory layout, get the underlying type of an enum, or inspect access specifiers. Proc macros are closer in spirit to C++‘s old code generation tools than to P2996.

C# source generators, introduced in .NET 5, are the closest conceptual match: they generate code at compile time using Roslyn’s semantic model. The difference is that C# source generators are written as separate compilation plugins with an entirely different API, whereas C++26 reflection is integrated directly into the language. There is no plugin boundary, no separate build step, and no Roslyn syntax tree to navigate.

Implementation Status

The primary experimental implementation is Bloomberg’s clang-p2996 fork. It implements the ^ operator, [: :] splicing, and the core std::meta query functions. Compiler Explorer offers it as a compiler target under the clang-p2996 label, which is the most accessible way to experiment with the feature today. The EDG front-end, used by several commercial compilers, also had a working implementation during the proposal’s development phase. GCC and MSVC implementations have not been publicly announced as of this writing.

Given that C++26 standardization is targeting completion this year, production-ready implementations in mainline compilers are probably 12 to 24 months out from being something you can rely on in a shipping project.

What Changes in Practice

The practical impact of P2996 will show up first in library code. Serialization libraries, ORM frameworks, RPC generators, and testing utilities will rebuild their internals around reflection and retire their macro-based registration APIs. Users of those libraries will notice primarily that the macros disappear.

For application code, the immediate wins are enum-to-string without magic_enum’s caveats, generic debug printing, and straightforward struct serialization. More ambitious patterns, like automatic struct-of-arrays generation from array-of-structs types or compile-time-generated dispatch tables, will take longer to become idiomatic as the community develops conventions around template for and std::meta::define_class.

P2996 closes a gap that C++ developers have been filling with increasingly elaborate workarounds for over twenty years. The workarounds were creative, and some were genuinely impressive pieces of engineering. They were also a sustained argument that the language needed something better; C++26 provides it.

Was this interesting?