· 6 min read ·

The End of the Enum Hack: C++26 Reflection and a Decade of Workarounds

Source: isocpp

Reflection is coming to C++26, and Bernard Teo’s retrospective on the flavours of it is a good occasion to look at what the feature actually is, what it replaces, and why the design choices in P2996 matter more than the headline capability.

The headline is straightforward: you can now write code that introspects the members of a struct, the enumerators of an enum, or the parameters of a function, at compile time, with full type information, using normal C++ control flow. No macros, no external tools, no compiler extensions beyond the standard.

What We Were Doing Before

The absence of reflection in C++ was never really an absence of reflection. It was an absence of first-class language support, which meant the community built workarounds for every use case independently.

magic_enum is the most widely used one. It achieves enum-to-string by inspecting the compiler’s output from __PRETTY_FUNCTION__ or __FUNCSIG__, which includes the name of the enumerator when you instantiate a template with it:

template<auto V>
constexpr auto enum_name_impl() {
    // Extract from "auto enum_name_impl() [V = Color::Green]"
    return __PRETTY_FUNCTION__;
}

The library parses that string at compile time to extract the name. It works. It’s clever. It is also undefined behavior by any strict reading of the standard, relies entirely on implementation-defined output formats, and breaks for enums whose values fall outside a fixed scanning range (typically -128 to 128). The magic_enum library has 10,000+ stars on GitHub, which tells you something about how badly this capability was needed.

boost.pfr takes a different approach for structs. It uses a structured binding trick: if you can decompose an aggregate with auto& [a, b, c], you can count the members by trying increasing binding counts until it fails to compile. Then you can iterate them. No names, no types by name, just positional access. It works only for simple aggregates with no user-defined constructors, no private members, no base classes.

Boost.Hana went further with a macro annotation approach:

BOOST_HANA_DEFINE_STRUCT(Point,
    (int, x),
    (double, y)
);

hana::for_each(hana::members(p), [](auto pair) {
    std::cout << hana::to<char const*>(hana::first(pair)) << "\n";
});

This gives you names and values, but you have to declare your struct using the macro. Your struct is now owned by the reflection system, not the language. The compile times were also notoriously painful; Hana’s heavy template instantiation was a real cost on large codebases.

These libraries are all filling the same hole. P2996 closes it.

What P2996 Actually Looks Like

The core of the proposal is two new constructs: a reflection operator ^ and a splice operator [: :]. The reflection operator takes a name and returns a std::meta::info value, which is an opaque scalar type representing a compile-time entity. The splice operator takes a std::meta::info value and turns it back into whatever it represents.

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

constexpr auto r2 = ^Point::x;              // reflects the member x
Point p{1, 2.0};
p.[:r2:] = 99;                              // equivalent to p.x = 99

The std::meta namespace provides a set of consteval functions that query info values:

constexpr auto members = std::meta::nonstatic_data_members_of(^Point);
for (auto m : members) {
    // m is a std::meta::info; identifier_of gives you the name
    std::cout << std::meta::identifier_of(m) << "\n";
}

Enum-to-string, the use case that motivated magic_enum, becomes clean and standards-conforming:

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::identifier_of(e);
    }
    return "<unknown>";
}

enum class Color { Red, Green, Blue };
static_assert(enum_to_string(Color::Green) == "Green");

The template for construct is new syntax that expands a compile-time range into a sequence of instantiations, where each iteration can have a distinct type. It’s what boost::hana::for_each was approximating.

The Design Decision That Matters

Earlier C++ reflection proposals, particularly P1240 and the reflexpr Technical Specification, used a type-based model. reflexpr(T) returned a type, and you queried it with type-level operations:

// Old reflexpr approach (rejected)
using meta_T = reflexpr(MyStruct);
using members = std::experimental::reflect::get_data_members_t<meta_T>;

This looks familiar to anyone who has used <type_traits>, but it’s deeply cumbersome for anything non-trivial. Iterating over members requires template metaprogramming, not a loop. Conditionals require std::conditional_t, not if. The whole thing fights against C++‘s actual execution model.

P2996 chose values instead of types. std::meta::info is a scalar literal type. A collection of members is a std::vector<std::meta::info>. You can use if, for, std::find_if, and any other constexpr-compatible construct on it. The code reads like code, not like template incantations.

This is the same insight that Zig arrived at earlier with @typeInfo. Zig’s comptime reflection returns a tagged union from a builtin function, and you work with it using ordinary comptime control flow:

fn printFields(comptime T: type) void {
    const info = @typeInfo(T);
    switch (info) {
        .Struct => |s| inline for (s.fields) |field| {
            std.debug.print("{s}\n", .{field.name});
        },
        else => {},
    }
}

Zig got here first, and it worked well. P2996 is more expressive for C++‘s richer type system (templates, function overloads, access control, namespaces), but the fundamental insight is the same: reflection should give you values you can work with in ordinary code, not a parallel type-level language you have to think in separately.

What This Actually Unlocks

The immediate beneficiaries are the use cases that have been hacked around for years.

Serialization libraries like nlohmann/json currently require either manual to_json/from_json specializations or macro-based struct registration. With P2996, a generic serializer becomes straightforward:

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 += ",";
        result += '"';
        result += std::meta::identifier_of(m);
        result += "\":";
        result += serialize_value(obj.[:m:]);
        first = false;
    }
    result += "}";
    return result;
}

No annotation, no code generation step, no macro. The struct’s members are introspected directly.

ORM systems, RPC frameworks, scripting language bindings, and test framework introspection all follow the same pattern. The code that currently requires either a build-time code generator (like protobuf’s protoc) or a runtime reflection system (like Java’s java.lang.reflect) can be done at compile time in standard C++, with zero runtime overhead.

The companion code injection proposals go further, allowing you to programmatically construct new class members inside consteval functions. This is the mechanism through which Herb Sutter’s metaclasses concept could eventually be implemented as a library feature rather than a language feature.

Implementation Status

This is not theoretical. Bloomberg maintains a public Clang fork that implements a substantial portion of P2996, and it’s available right now on Compiler Explorer under the experimental Clang P2996 compilers. You can run the enum-to-string example today.

GCC has active work in progress, and EDG (the front end used by Intel, ARM, and Coverity) has an implementation used for conformance testing. MSVC has not shipped a public implementation yet, though the team has been tracking the proposal.

P2996 was merged into the C++26 working draft at WG21’s Wroclaw meeting in November 2024. The standard is not finalized yet, but the core design is settled.

What Takes Longer

Some things won’t change overnight. The compile-time overhead of heavy reflection is real. Enumerating members of a large class hierarchy in a widely-included header will add build time, and the Bloomberg implementation shows that the overhead is manageable but not free. Build time is already a perennial complaint in large C++ codebases.

The code injection portion of the story is still in flux. std::meta::define_class and the ability to programmatically inject new members were part of some P2996 revisions but deferred in others; that part of the feature may land in C++29 rather than C++26.

Access control is also exactly what you’d expect: you can reflect public members from anywhere, but reflecting private members requires being in a privileged context (inside the class, or in a consteval friend). This is correct behavior, but it means a generic serializer can only see what the type exposes publicly, the same as any other code.

None of these are reasons to be less interested. The core capability is here. The enum hack is over. The struct annotation macros can retire. Twenty-plus years of workarounds for a single missing feature is a long time, and C++ is finally done making us compensate for it.

Was this interesting?