· 6 min read ·

C++26 Reflection: The Value-Based Design That Finally Makes It Work

Source: isocpp

C++26 reflection is finally here, and the design is worth understanding carefully not just for what it does but for how it arrived at its current shape. Bernard Teo’s recent writeup on isocpp.org covers the flavours of the feature well; what I want to trace is the design lineage, because the choices made in P2996 are deliberate reactions to a decade of failed or unsatisfying attempts at the same problem.

The Problem Reflection Was Supposed to Solve

Before C++26, writing generic code that needed to know about struct members, enum names, or function parameters required one of a handful of unsatisfying workarounds.

magic_enum is the most instructive example. It works by instantiating function templates for every integer in a configurable range, stringifying the template parameter via __PRETTY_FUNCTION__ or __FUNCSIG__, and parsing the result. It is impressively clever and thoroughly non-portable. The default range is -128 to 127, and compile times scale with range size. It works well enough that it has millions of downloads, which tells you everything about the gap it was filling.

Boost.PFR takes a different approach: it exploits aggregate initialization and structured bindings to iterate over struct members without macros, but it cannot recover field names and only works on aggregates with no constructors, no private members, and no base classes. Boost.Hana is more composable but requires you to annotate every struct with BOOST_HANA_DEFINE_STRUCT, an invasive macro that must appear inside the class definition. The X-macro pattern is even more manual. All of these are workarounds, not solutions.

The earlier standards committee attempts at proper reflection also left something to be desired.

The Type-Based Dead End

Proposals like N4428 (2015) and the long-running P0194 series introduced a reflexpr() operator that produced a type rather than a value. To query a reflected entity you wrote type trait specializations:

// Pre-P2996 style (reflexpr approach, never standardized)
using meta_Point = reflexpr(Point);
using members = std::reflect::get_data_members_t<meta_Point>;
using first = std::reflect::get_element_t<0, members>;
std::string name = std::reflect::get_name_v<first>;

The result of reflexpr() was a unique opaque type, and you queried it through template specializations. This mirrors the <type_traits> design: std::is_integral<T> returns a type with a ::value member. For types that already exist, that pattern works fine. For reflection metadata, it creates a problem: you cannot store a reflected entity in a variable, pass it to a function, or iterate over a collection of them without the full machinery of template metaprogramming.

Iterating over all members of a struct meant building a type list, writing a recursive template that peeled off one element at a time, and specializing the terminal case. It was functional in the same way that writing a sort using bubble sort is functional. The reflexpr approach was never standardized, partly because composing these type-level operations was sufficiently painful that the ergonomics gap over existing workarounds was unconvincing.

The Shift to Values

P1240 in 2019 introduced the idea that reflection should return a value, not a type. This is the key insight behind P2996. A reflection is a constexpr value of type std::meta::info, an opaque scalar type that can be stored in variables, passed to functions, put into arrays, and filtered with standard algorithms, all at compile time:

constexpr std::meta::info r = ^Point;  // ^ is the reflection operator
constexpr auto members = std::meta::nonstatic_data_members_of(r);
// members is a std::vector<std::meta::info> at compile time

The ^ operator produces a meta::info value from any C++ entity: types, functions, variables, enumerators, namespaces, members. The std::meta namespace provides consteval functions that query these values. Because meta::info is a plain value, you can do things that are impossible in the type-based model:

consteval std::size_t count_public_fields(std::meta::info type) {
    std::size_t n = 0;
    for (auto m : std::meta::nonstatic_data_members_of(type))
        if (std::meta::is_public(m)) ++n;
    return n;
}

static_assert(count_public_fields(^Point) == 2);

A regular consteval function, a loop, standard control flow. The value-based design makes reflection feel like programming rather than type-level incantation.

The Splice Operator

The third piece is [: ... :], the splice operator. It takes a meta::info value and produces the corresponding C++ construct in context. In type position it produces a type; in expression position it produces an expression; in member access position it produces a field access:

struct Point { int x, y; };
Point p{3, 4};

constexpr auto field = ^Point::x;
p.[:field:] = 10;  // compiles to: p.x = 10

Splice eliminates the decltype/std::declval gymnastics that were previously needed to turn a reflected type back into usable code. The compiler resolves [:r:] to whatever entity r refers to, fully typed.

The combination of ^, std::meta::*, and [::] lets you write a generic struct printer that works on any type without macros:

template <typename T>
void print_fields(const T& obj) {
    template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
        std::cout << std::meta::name_of(m) << " = " << obj.[:m:] << "\n";
    }
}

The template for construct is the compile-time iteration primitive: it expands the loop body once per element, similar to pack expansion, but over an arbitrary constexpr range. Each iteration produces distinct instantiated code, which is why you can use [:m:] inside the body even though m’s type is always meta::info.

The same pattern handles enum-to-string without any compiler extension or range tricks:

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

This is zero-overhead: the generated code is a series of comparisons against compile-time constants, identical to a hand-written switch. magic_enum reaches the same destination through a much more circuitous route.

What the Rest of the World Already Has

It is worth noting where C++ sits relative to languages that already have reflection. D has had compile-time introspection via __traits since around 2007, and static foreach for compile-time iteration. The mechanism is string-based rather than value-based: __traits(allMembers, T) returns a tuple of strings, and you access members via __traits(getMember, obj, "name"). It works, but member access via a string literal is less type-safe than C++26’s splice.

Rust has no structural reflection at all. The serde ecosystem provides serialization through procedural macros that parse the struct’s AST at compile time. It is powerful within its domain but is not a general reflection facility. The Rust team has consistently declined to add language-level reflection, preferring macro-based solutions. C++26 takes the opposite position.

Java and C# both have runtime reflection, which solves a different set of problems. Runtime reflection enables plugin architectures, dependency injection frameworks, and deserialization into types not known at compile time. C++26 reflection does none of these things. It is strictly a compile-time facility; std::meta::info values do not exist at runtime. If you need to invoke a method by name read from a config file, you still need a hand-written type registry or a library like RTTR. The two models are complementary, not competing.

Implementation Status

The most complete public implementation is the Bloomberg/EDG experimental Clang fork, which is accessible on Compiler Explorer under “Clang (experimental P2996).” Most of the core features work: the ^ operator, std::meta query functions, template for, and splicing. P3294, the companion proposal for token-sequence code injection that enables generating new declarations at compile time, is partially implemented in a separate experimental branch.

Mainline Clang has infrastructure patches in review but is not yet usable end-to-end as of early 2026. GCC has no public implementation. MSVC has none either. This is typical of major language features at the moment of standardization; the gap between “accepted into the working draft” and “shipping in your toolchain” is usually one to three years for something this significant.

The Boilerplate Problem, Finally Addressed

The accumulation of workarounds around C++ struct introspection has been a persistent source of friction: serialization libraries that require registration macros, enum libraries that parse stringified template names, struct comparison utilities that need manual field lists. Each workaround has known limitations and compilation costs that compound in large codebases.

P2996 addresses all of them from the same mechanism. The design is clean because it learned from a decade of prior proposals, and because the shift to value-based reflection gave the feature the composability it needed to replace the workarounds rather than just adding another one alongside them.

The experimental implementation exists. The syntax is stable enough to experiment with on Compiler Explorer today. By the time C++26 compilers ship broadly, this will have been in development for longer than most language features, which is usually a good sign for how thoroughly it has been thought through.

Was this interesting?