· 6 min read ·

C++26 Reflection and the pybind11 Problem: What a Month of Experiments Reveals

Source: isocpp

The problem with pybind11 is visible in any large project that has one: the binding code doubles as a shadow API that trails the C++ source. Add a method, add a .def(). Rename a parameter, rename the py::arg(). Change a return type’s ownership semantics, wrestle with py::return_value_policy. Projects like Rosetta, the protein structure modeling suite, maintain tens of thousands of lines of binding code alongside the C++ core. PyTorch’s operator bindings exceed 10,000 lines. The maintenance arithmetic compounds quickly.

The tooling response was to build external generators. Binder, used by Rosetta itself, runs as a Clang-based tool: it parses C++ headers using Clang’s AST machinery, walks the parsed tree, and emits pybind11 .cpp files. SWIG does something similar with its own parser and its own .i interface language. Custom scripts built on libclang’s Python API exist in dozens of internal toolchains. These tools share a structural position: they sit outside the compiler, read its output, and generate new input for it.

Boris Staletić’s month-long experiment, described retrospectively in November 2025 on isocpp.org, explores a different position: inside the compiler, writing binding generation as ordinary C++ template code that runs during compilation. P2996, accepted into the C++26 working draft at the Wrocław meeting in November 2024, provides the machinery. What the experiment reveals is what that positional change actually buys, and where the hard part of binding generation remains unsolved regardless of where the generator lives.

The Structural Layer P2996 Provides

P2996 introduces std::meta::info, a first-class value type representing a reflected program entity. The ^ operator produces a reflection:

constexpr std::meta::info r = ^MyClass;
const auto members = std::meta::nonstatic_data_members_of(^MyClass);

The std::meta namespace provides query functions: std::meta::nonstatic_data_members_of(^T) returns a range of std::meta::info values, one per non-static data member. std::meta::identifier_of(r) returns the member name as string_view. std::meta::is_public(r) reports access. The splice operator [:r:] converts a reflected member back into an expression usable in code, so &T::[:r:] produces a pointer-to-member.

The value-based design is the defining choice of P2996, distinguishing it from earlier reflection proposals and from D’s __traits-based model. Reflections in P2996 are values, not types, which means you can store them in constexpr std::vector, filter them with std::ranges::filter, and pass them between consteval functions as ordinary arguments. Manipulation uses standard C++ algorithms rather than template metaprogramming idioms.

P1306 (expansion statements) provides the iteration mechanism that makes this practical. To use a reflected member inside generated code, the loop variable must be a compile-time constant in each iteration. A regular for loop does not guarantee this. The template for construct expands each iteration as a separate instantiation:

template for (constexpr auto field : std::meta::nonstatic_data_members_of(^T)) {
    // 'field' is constexpr here; the body is instantiated once per field
    std::cout << std::meta::identifier_of(field) << ": " << obj.[:field:] << "\n";
}

P1306 and P2996 are tightly coupled in practice. Reflection without expansion statements can compute things at consteval time; it cannot generate distinct code per member. The two proposals are proceeding through the C++ committee together.

What a Binding Generator Looks Like

Using both proposals together, the structural layer of binding generation becomes:

template <typename T>
void bind_class(py::module_& m, const char* name) {
    auto cls = py::class_<T>(m, name);

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

    template for (constexpr auto method : std::meta::members_of(^T)) {
        if constexpr (std::meta::is_public(method)
                   && std::meta::is_nonstatic_member_function(method)
                   && !std::meta::is_special_member(method)) {
            cls.def(std::meta::identifier_of(method).data(), &T::[:method:]);
        }
    }
}

For a class with no overloaded methods and only publicly-owned members, this produces correct, synchronized binding code with zero maintenance cost. The identifier_of call ensures names stay current with C++ identifiers by construction. Enum binding becomes equally mechanical:

template <typename E> requires std::is_enum_v<E>
void bind_enum(py::module_& m, const char* name) {
    auto e = py::enum_<E>(m, name);
    template for (constexpr auto v : std::meta::enumerators_of(^E)) {
        e.value(std::meta::identifier_of(v).data(), [:v:]);
    }
}

Staletić found these structural cases worked cleanly. The value-based model showed its payoff: filtering and querying reflections with familiar range algorithms felt natural rather than adversarial.

Still, the experimental Clang implementation of template for was rough in places. Certain combinations of splice expressions and expansion statements produced confusing template errors or failed silently. The design is sound; the implementation maturity, as of late 2025, was not production-ready. This is expected for a feature of this complexity and novelty.

Where Structural Access Is Not Enough

The hard cases in binding generation are not about P2996’s completeness. They are about information that no type system representation carries.

Overloads. When compute appears twice in members_of(^T), the expression &T::[:method:] is ambiguous. P2996 provides std::meta::parameters_of(method) and std::meta::return_type_of(method), so constructing the parameter pack for py::overload_cast is mechanically feasible. The unsolved problem is the Python API design question: should two C++ overloads become one Python function with runtime dispatch, or two functions with distinct names? That is a policy decision that structural metadata does not encode.

Default argument values. P2996 explicitly does not reflect default argument expressions. std::meta::has_default_argument(param) tells you a default exists, but not what it is. Binder handles this by operating on Clang’s full AST, where default expressions are available as subtrees that can be serialized back to source text. This is one concrete capability that an AST-level external tool has over an in-compiler reflection-based generator: it sees things the C++ type system deliberately does not model.

Return value policies. pybind11 exposes six distinct return value policies because a raw pointer return type does not encode ownership intent. const Config* might return a pointer to an internally owned singleton (reference), a borrowed pointer whose lifetime is tied to the bound object (reference_internal), or something the caller owns (take_ownership). Reflection provides the return type; the contract lives in documentation or naming conventions that static metadata cannot access.

This boundary is what Staletić’s experiment identified most precisely. Reflection handles the mechanical layer well. The semantic layer requires input that no language feature can supply unilaterally.

The Annotation Path Forward

The natural extension is user-defined attributes inspectable at compile time. If C++ authors can annotate return value policy directly:

[[py::return_policy(reference_internal)]]
const Config* get_config() const;

[[py::name("compute_int")]]
void compute(int x);

Then a reflection-based generator can read the attribute and emit the correct policy. P1854, which proposes user-defined attributes inspectable via reflection, has been moving through the committee in tandem with P2996. P2996 alone does not standardize attribute access; experimental implementations have extension points, but nothing binding generation can rely on in shipping code.

This is where the comparison with external tools becomes instructive in an unexpected direction. Binder’s .binder configuration files serve exactly this annotation function: they let developers specify which classes to expose, how to handle specific overloads, and which ownership semantics apply. The config file is an external annotation layer that decouples policy from code. A C++26 reflection-based generator needs an equivalent layer, and the more natural home for it is inline in C++ source using attributes, provided P1854 or something similar reaches standardization.

What Changes by Moving Inside the Compiler

Even accounting for what reflection cannot do, the positional difference matters in two specific ways.

The first is integration. External tools produce .cpp files that you check in and maintain separately. When the C++ API changes, those files are stale until the tool re-runs, and re-running requires the tool to be available and configured in the build system. A reflection-based generator compiles as part of the normal build; the bindings are always current without a separate step.

The second is template instantiation. External tools see whatever template instantiations appeared in the translation units they analyzed. This requires either analyzing every entry point or explicitly listing instantiations in configuration, which is exactly the class of tedious manual work that motivated the generator in the first place. Reflection sees the full C++ type system at the point of instantiation. For libraries with template-heavy APIs, this removes an entire category of configuration burden that binder users deal with regularly.

D reached an analogous position with __traits and static foreach years before C++26 was on the horizon. D’s compile-time introspection covers the same structural layer, and the D ecosystem settled into the same hybrid: the reflection handles the common case, attributes and escape hatches handle the exceptions. The C++ community is arriving at the same conclusion through Staletić’s experiments rather than years of ecosystem evolution.

The practical ceiling from this work is roughly 70 to 80 percent automation for a typical mixed C++ class: data members, non-overloaded public methods, and enumerations covered; overloads, defaults, and ownership semantics requiring annotation. That fraction represents real engineering value for large libraries where mechanical binding maintenance dominates. The design space around the remaining portion, specifically how to express policy in C++ itself rather than in a separate configuration language, is where the interesting work on this problem will happen next.

Was this interesting?