· 6 min read ·

What D's Production Binding Generator Already Teaches C++26

Source: isocpp

The isocpp.org article by Richard Hickling frames automatic Python binding generation from C++ as a near-future capability that C++26 reflection will enable. The underlying pattern — compile-time introspection used to derive bindings from type structure rather than maintain them by hand — is not new territory. D’s autowrap library, maintained by Symmetry Investments for use in systematic trading, has been generating Python, Excel, and Jupyter bindings from D code automatically for years, using D’s compile-time reflection to solve exactly the problem P2996 will solve for C++. The lessons from deploying that system are a concrete field preview.

How D Reflection Works at the Binding Layer

D has had compile-time introspection since its early versions, predating the C++26 reflection proposals by over a decade. The mechanism centers on __traits, a built-in compile-time query form:

// Returns a compile-time tuple of all member names as strings
alias members = __traits(allMembers, BlackScholesPricer);

// Check access level
static assert(__traits(getProtection, BlackScholesPricer.price) == "public");

// Get all overloads of a named function
alias price_overloads = __traits(getOverloads, BlackScholesPricer, "price");

// Iterate at compile time
static foreach (memberName; __traits(allMembers, BlackScholesPricer)) {
    // memberName is a string constant at compile time
    // need a secondary lookup to turn it back into usable code:
    alias m = __traits(getMember, BlackScholesPricer, memberName);
}

The critical distinction from C++26’s P2996 is that __traits returns member names as strings. Converting a name string back into a usable member reference requires __traits(getMember, T, name) as a separate step. P2996’s std::meta::info type is a first-class compile-time value representing the member entity itself, which splices back into code directly with [: :]. The two mechanisms accomplish the same structural enumeration, but P2996’s model requires less scaffolding for complex cases: forwarding arguments through generated wrappers, dispatching on return types, or handling const-qualification precisely.

The autowrap library builds on these primitives using D’s compile-time function evaluation and mixin mechanism. A D module that wants Python bindings generated registers itself with:

module mypricers;

struct BlackScholesPricer {
    double price(double spot, double strike, double vol, double rate, double expiry);
    double delta(double spot, double strike, double vol, double rate, double expiry);
    double rho(double spot, double strike, double vol, double rate, double expiry);
}

// Generates the full CPython extension at compile time
mixin(wrapAll!("mypricers", BlackScholesPricer));

wrapAll is a template that introspects BlackScholesPricer at compile time, walks its public non-template methods, and generates binding registration code for each one. Add vanna to the struct and it appears in Python at the next build. The binding file, in the traditional pybind11 sense, does not exist. There is only the D source and the extension it generates.

What the Production Record Shows

The automation works cleanly for the cases D’s type system expresses unambiguously: non-overloaded public methods with value-type parameters and return types, plain struct fields, enum members. This covers the bulk of a quantitative library’s surface area. The library’s own documentation and issue tracker are candid about where the coverage ends.

Overloaded functions require disambiguation. When a D struct exposes two compute methods that differ only in parameter type, autowrap cannot decide how to name them in Python or whether to unify them into a single callable with runtime dispatch. The practical resolution is the same one C++26 teams will face: use distinct method names for overloads on Python-facing APIs, or add explicit annotations to direct the binding generator. Neither approach is automatic.

Template instantiation is the sharpest limit. D templates, like C++ templates, only exist in concrete form when instantiated with specific types. autowrap can bind a concrete instantiation but cannot enumerate all possible ones. A generic Pricer(T) with no concrete uses in the module is invisible to the generator. For C++ teams planning P2996-based binding generation, the equivalent situation is reflecting a template class: ^std::vector<double> works, but there is no way to reflect std::vector in the abstract and generate bindings for all possible specializations. Each concrete type that Python consumers need must be named explicitly.

Return type handling falls into two categories. For value types, numeric primitives, and types with registered converters, the generated bindings are correct and complete. For types involving raw pointers or references to internally managed data, the generator cannot infer the ownership contract from the type signature alone. A method returning const Config* might return a pointer into internally owned storage, a freshly allocated object the caller should delete, or a view into data whose lifetime is tied to the parent struct. The correct binding differs in each case. autowrap requires explicit annotations for these cases; so will any C++26 binding generator built on P2996.

The GIL Problem Applies Equally

Python’s Global Interpreter Lock permits only one thread to execute Python bytecode at a time. When Python code calls into a native extension, well-written extensions release the GIL during compute-intensive work so other Python threads can proceed. Failing to release it means a pricing function that takes several milliseconds serializes the entire Python interpreter for its duration. In trading systems running concurrent strategies, this is a real correctness concern.

D’s interop layer requires the same explicit handling that pybind11 does. A binding generator cannot infer from a function’s signature whether calling it requires the GIL to remain held; that is a semantic statement about the function’s behavior. autowrap applies a configurable default — release the GIL for all calls — while allowing per-function overrides. Neither option is derived automatically from type structure.

The C++26 situation is identical. An auto_bind template built on P2996 must make a policy decision, uniform or per-function, about GIL handling. pybind11’s call guard mechanism covers the mechanical part:

// Manual annotation required — reflection cannot infer this
cls.def("price", &Pricer::price, py::call_guard<py::gil_scoped_release>());

The proposed path forward is P1854, which would allow user-defined attributes on declarations, readable via reflection at compile time:

[[py::nogil]]
double price(double spot, double strike, double vol, double rate, double expiry) const;

A binding template could read this attribute and emit the appropriate guard. P1854 is not in C++26. Until it arrives, GIL policy in reflection-generated bindings is a manually maintained annotation layer, the same compromise D teams have been navigating.

Why P2996’s Design Is More Ergonomic

D’s __traits model produces string names, and code generation from those names requires a secondary lookup per member. For a simple binding generator this is workable. For one that needs to forward variadic arguments correctly, specialize behavior based on return type, or generate different binding code for const versus non-const overloads, the string-based model adds indirection at each step.

P2996’s std::meta::info values, combined with expansion statements from P1306, handle the same iteration more directly:

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

    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:]
            );
        }
    }
}

The [:method:] splice directly produces the pointer-to-member expression pybind11 consumes. In the D equivalent, you obtain the name string, look up the member with a second __traits call, and build the wrapper around the result. Both approaches generate correct bindings; the C++26 version is shorter and produces diagnostics closer to ordinary C++ error messages when something goes wrong.

This ergonomic difference matters most at the boundaries of what the 70-80% automated coverage handles. Annotating the hard cases, writing the per-function GIL policy, and managing overload disambiguation all require reading and modifying the binding generator code. A model that is easier to read is easier to extend.

What This Means for C++26 Planning

The 70-80% automation estimate that circulates in C++26 reflection discussions is consistent with production experience from D. Structural cases generate automatically. The remainder, overload disambiguation, ownership semantics, GIL management, template specialization, reflects cases where type structure genuinely cannot encode the information needed for a correct binding decision.

For teams planning C++26 adoption on a 2027 or 2028 timeline, D’s experience points at specific preparation work worth doing before the toolchain arrives. Design Python-facing C++ APIs to avoid overloaded methods on the public surface where the overload encodes type-based polymorphism; Python’s duck typing makes distinct names more natural anyway. Prefer smart pointer or value returns over raw pointer returns for methods Python consumers will call; std::shared_ptr<T> encodes ownership where T* does not, and pybind11 handles the former without policy annotation. Document GIL intent for compute-heavy methods in comments using the exact form of the eventual P1854 annotation.

None of this requires waiting for C++26 toolchain support. These are API design decisions that make the C++ library cleaner for all consumers and reduce the manual annotation work when automation arrives. D’s experience with autowrap shows the destination is reachable. The Bloomberg-maintained experimental Clang fork implementing P2996, accessible today on Compiler Explorer, makes it testable in C++ now. What D teams learned in production is that the pattern works, the edges are specific and predictable, and the engineering to handle those edges is the same work regardless of whether you start today or in 2027.

Was this interesting?