· 6 min read ·

Twenty Years of C++ Trying to Know Its Own Types

Source: isocpp

The problem Richard Hickling describes in his isocpp.org piece on C++26 and Python bindings for algorithmic trading is real and worth solving. But the underlying limitation it exposes is older than pybind11, older than Python/C++ interop as a category, and considerably more general. C++ has historically been unable to inspect its own type structure at compile time without invasive workarounds. The history of those workarounds is a series of increasingly sophisticated attempts to give the compiler knowledge it should have had from the start.

The Era of Manual Annotation

The oldest approach is the X-macro, which dates to C rather than C++ but remained relevant long after object-oriented C++ had matured:

#define FIELDS \
    X(double, spot) \
    X(double, strike) \
    X(double, vol) \
    X(double, rate)

struct BlackScholesParams {
#define X(type, name) type name;
    FIELDS
#undef X
};

// Reuse the macro for serialization
void serialize(const BlackScholesParams& p) {
#define X(type, name) write(#name, p.name);
    FIELDS
#undef X
}

The macro carries the field list once, and each use redefines X to produce different output. It works, but it separates the struct definition from its own description. The real struct is the macro expansion, not the readable type above. Adding a field means updating one macro, which is better than updating every registration site independently, but the struct still does not know its own members in any principled sense.

Template Metaprogramming and the Type-List Era

C++03’s template facilities enabled a more ambitious approach. Andrei Alexandrescu’s Modern C++ Design introduced type lists as a way to manipulate collections of types at compile time. Libraries like Boost.MPL built elaborate compile-time algorithms over these structures: filtering, transforming, and iterating over sequences of types using recursive template instantiation.

For reflection purposes, the problem was that a type list knows nothing about a struct’s actual members unless you populate it manually. Libraries like Boost.Fusion introduced struct adaptation macros such as BOOST_FUSION_ADAPT_STRUCT, which generated the necessary template specializations to make a plain struct traversable as a Fusion sequence. This worked but came at a cost: the adaptation lived outside the struct definition, was syntactically heavy, and produced error messages that referenced multiple layers of template machinery when anything went wrong.

Boost.Hana, arriving with C++14, improved the ergonomics substantially by using constexpr values rather than types for sequence manipulation. Its BOOST_HANA_DEFINE_STRUCT macro defined a struct and its Hana adapter in one declaration:

struct BlackScholesParams {
    BOOST_HANA_DEFINE_STRUCT(BlackScholesParams,
        (double, spot),
        (double, strike),
        (double, vol),
        (double, rate)
    );
};

With this, you could iterate over members at compile time using hana::for_each and hana::members. The underlying technique was still macro-generated specializations, but the values-over-types philosophy previewed what P2996 would later formalize. Hana’s author, Louis Dionne, became one of the voices pushing compile-time computation toward value-based models as he moved to Apple and worked on libc++.

The Clever Hacks: magic_enum and Friends

For enumerations specifically, the situation was worse. There was no standard way to get an enum’s members as strings or to iterate over its values. Libraries like magic_enum exploited a compiler implementation detail: __PRETTY_FUNCTION__ or __FUNCSIG__ embeds template argument names as strings, and with some careful string parsing at compile time, you can extract enum value names. The approach is clever, impressively portable, and completely unsupported by the standard. It also has limits: it works only for enums whose values fall within a configurable range, because the technique requires instantiating a template for each potential value in that range.

The fact that magic_enum became a production dependency in serious codebases illustrates how significant the gap was. Engineers wanted this functionality badly enough to depend on behavior the standard did not guarantee.

Why the Reflection Proposals Kept Failing

The C++ committee has discussed adding reflection for over two decades. Proposals like N3996 and the Reflection Technical Specification reached varying stages of development before stalling. The common thread in what went wrong was the type-based design: reflected entities were themselves types, represented as specializations of template class hierarchies.

Consider what iterating over the members of a struct meant under this model. You had a type list of reflected member types, each a specialization carrying the member’s metadata. Filtering for public members required a recursive template predicate. Extracting names required accessing a static constant on each type in the filtered list. Combining results required pack expansions through template parameter lists. The resulting code was not just verbose; it hit compiler template depth limits on non-trivial types, produced diagnostic messages that referenced the reflection machinery rather than the user’s code, and required expertise in advanced template metaprogramming to write or maintain.

What P2996 Does Differently

P2996, accepted into the C++26 working draft, introduces a single core type: std::meta::info. This is a scalar value representing a reflected entity, obtained through the ^ operator:

constexpr auto r = ^BlackScholesParams;

From here, the reflection API uses ordinary consteval functions that take and return std::meta::info values:

constexpr auto members = std::meta::nonstatic_data_members_of(^BlackScholesParams);
// members is a std::vector<std::meta::info>, available at compile time

Filtering for public members uses standard range algorithms. Extracting names calls std::meta::identifier_of(r), which returns a std::string_view. The [:r:] splice operator converts a reflected entity back into the syntactic entity it represents, enabling uses like &T::[:member_info:] to produce a pointer-to-member.

The key insight, which previous proposals missed, is that compile-time computation does not require types as the unit of representation. Values compose better than types in almost every dimension: they can be passed to and returned from functions by value, stored in containers, filtered with predicates, and manipulated with standard algorithms. Moving reflections from the type domain to the value domain reduces the expertise required to use them from “advanced template metaprogramming” to “ordinary constexpr programming.”

This is why Hana’s values-over-types direction, and the progressive expansion of constexpr through C++11, C++14, C++17, and C++20, were movement toward the same destination. P2996 reaches it by making the compiler itself generate the values that describe its own program, rather than requiring programmers to encode that description through macros or template specializations.

Other Languages Got Here Sooner

It is worth noting that the C++ committee’s difficulty was not universal. D’s __traits system has offered compile-time member enumeration since D’s early development, using static foreach over __traits(allMembers, T). Zig’s @typeInfo returns a tagged union containing the complete type description, traversable with inline for. Both treat compile-time type metadata as ordinary values, iterate over it with language constructs that look like regular loops, and produce comprehensible error messages when something goes wrong.

C++‘s 20-year delay reflects the complexity of the existing language surface: compatibility constraints, the size of the type system, the need to handle templates and template specializations as first-class reflectable entities, and the committee’s caution about committing to a design that cannot be revised after standardization. P2996’s value-based approach is not just a good design choice; it is the result of observing what worked in other languages and what had failed repeatedly in C++ proposals.

The Python Binding Case as Payoff

Against this history, auto-generating pybind11 bindings is a natural first application of P2996 rather than a contrived one. The existing workarounds for the problem, Boost.Fusion adapters, external Clang-based generators like Binder, manually maintained registration blocks, are exactly the category of problem that static reflection eliminates. With P2996 and P1306’s expansion statements, the registration for a struct is derived from the struct definition directly:

template <typename T>
void auto_register(py::module_& m) {
    auto cls = py::class_<T>(m, std::meta::identifier_of(^T).data());

    template for (constexpr auto mem : std::meta::nonstatic_data_members_of(^T)) {
        if constexpr (std::meta::is_public(mem)) {
            cls.def_readwrite(
                std::meta::identifier_of(mem).data(),
                &T::[:mem:]
            );
        }
    }
}

The trading context Hickling uses makes the cost of the current approach concrete, but the same pattern applies wherever Python consumes C++ libraries: scientific computing stacks like NumPy’s extension ecosystem, game engines exposing scripting surfaces, computer vision pipelines bridging research Python to optimized C++ inference code. pybind11 is widespread in all of these, and the maintenance burden it creates scales with the rate of change in the C++ layer.

The remaining gaps are real, overloaded functions that require API design decisions, pointer return types where ownership semantics are underdetermined, default argument values that P2996 deliberately does not expose. P1854, which would standardize user-defined attributes accessible through reflection, addresses the annotation side of these gaps. It is proceeding through the committee but is not in C++26.

Compiler support is experimental as of mid-2026. Bloomberg’s Clang fork implements most of P2996 and is available on Compiler Explorer. Production usage is a 2027 or 2028 question depending on your compiler requirements and risk tolerance.

But the design is settled, and the reason it works, after two decades of failed attempts, is worth understanding. Treating reflections as compile-time values rather than types is not an incidental implementation choice. It is the insight that makes the feature usable, and it was arrived at by taking seriously what had gone wrong in every previous approach.

Was this interesting?