Automating Python Bindings with C++26 Reflection: What the Compiler Already Knew
Source: isocpp
Every Python/C++ hybrid project carries a hidden maintenance cost. You write a C++ class that does something computationally expensive, expose it to Python by hand, and ship it. Then someone adds a parameter during a refactor. The binding file goes stale. Nothing tells you until runtime, or until a Python test fails in a way that takes twenty minutes to trace back to a missing .def() call.
Richard Hickling’s piece on isocpp.org frames this in the context of algorithmic trading, where the Python-versus-C++ tradeoff shows up most sharply. But the binding maintenance problem is not unique to finance. Any codebase that wraps C++ in Python lives with it. C++26 reflection is the most structurally sound solution the language has yet produced, not primarily because it reduces boilerplate, though it does, but because it builds the binding layer from the same type information the compiler uses when it type-checks your code. You are no longer writing a parallel description of your API; you are reading the authoritative one.
What pybind11 Actually Asks You to Write
pybind11 is the current standard for Python/C++ bindings. It works well. But consider what a typical binding module looks like for even a modest class:
// C++ header -- the ground truth
class Pricer {
public:
Pricer(double rate, double vol);
double price(const std::string& ticker, double spot, double strike, double expiry);
double delta(const std::string& ticker, double spot, double strike, double expiry);
double gamma(const std::string& ticker, double spot, double strike, double expiry);
void set_risk_free_rate(double r);
double get_risk_free_rate() const;
};
// bindings.cpp -- a manual replica
PYBIND11_MODULE(pricer, m) {
pybind11::class_<Pricer>(m, "Pricer")
.def(pybind11::init<double, double>())
.def("price", &Pricer::price)
.def("delta", &Pricer::delta)
.def("gamma", &Pricer::gamma)
.def("set_risk_free_rate", &Pricer::set_risk_free_rate)
.def("get_risk_free_rate", &Pricer::get_risk_free_rate);
}
Every method listed twice. Add vega and theta to the C++ class and nothing stops you from forgetting the binding file. The compiler will not warn you. At scale, with dozens of classes and hundreds of methods, this is a real maintenance surface.
nanobind, Wenzel Jakob’s 2022 redesign of pybind11, fixes compile time and binary size considerably, but keeps the same manual .def() authorship model. SWIG takes a different approach: it parses your headers with its own parser and generates wrappers. That parser has been fighting C++ for thirty years. Templates, complex overloads, and modern language features have each caused it grief at various points. cppyy uses Cling, the LLVM-based C++ interpreter, to generate bindings at runtime, which is clever but introduces a heavyweight dependency that few latency-sensitive systems are willing to accept.
The deeper issue is that every tool before C++26 reflection works with a reconstruction of your type information, not the original. SWIG parses headers. pybind11 asks you to re-describe them. cppyy interprets them at runtime. None of them have access to the type model the compiler builds during ordinary compilation.
What P2996 Actually Provides
P2996, the C++26 static reflection proposal, introduces compile-time access to the compiler’s internal type graph. The core mechanism is the ^ operator, which produces a value of type std::meta::info representing any named entity:
constexpr auto t = ^Pricer; // reflects the class type
constexpr auto f = ^Pricer::price; // reflects a member function
std::meta::info is a first-class compile-time value. You can pass it to consteval functions, store it in constexpr variables, and query it through the std::meta namespace:
// Get a string_view of the entity's name
std::meta::identifier_of(^Pricer::price) // -> "price"
// Enumerate public non-static member functions
for (auto m : std::meta::members_of(^Pricer)) {
if (std::meta::is_public(m) && std::meta::is_function(m)) {
// m is a std::meta::info for each qualifying method
}
}
The complement is the splice operator [: :], which converts a std::meta::info back into something usable as a name or pointer in generated code:
auto ptr = [:m:]; // produces a pointer-to-member for method m
These two mechanisms together let you enumerate a class’s methods at compile time and generate code for each one, without writing anything specific to any particular class. The binding author writes a template; the compiler fills it in.
Automated Binding Generation
The pattern for pybind11 looks roughly like this:
template <typename T>
void auto_bind(pybind11::module& m) {
constexpr auto info = ^T;
auto cls = pybind11::class_<T>(
m, std::string(std::meta::identifier_of(info)).c_str()
);
// Iterate over all public non-static member functions
template for (constexpr auto member : std::meta::members_of(info)) {
if constexpr (std::meta::is_public(member) &&
std::meta::is_nonstatic_member_function(member)) {
cls.def(
std::string(std::meta::identifier_of(member)).c_str(),
[:member:]
);
}
}
}
// Usage: entire binding module for Pricer
PYBIND11_MODULE(pricer, m) {
auto_bind<Pricer>(m);
}
auto_bind is written once. Every class you pass to it gets bound with zero additional code. Add a method to Pricer in C++, and the binding updates at the next build with no changes to the binding file, because the binding file no longer describes anything specific.
This is the structural change: the binding is now derived from the type, not authored alongside it. The information was always there; P2996 makes it readable.
The template for construct in the example above is the compile-time expansion mechanism from P2996, which iterates over ranges of std::meta::info values. The exact spelling continues to evolve slightly as the proposal advances through standardization. Clang’s experimental reflection branch, which you can try on Compiler Explorer with the -freflection flag, tracks the proposal closely and is the most complete implementation available as of early 2026.
Why Algo Trading Feels This Pain Most
Quantitative development cycles are unusually fast. A researcher prototypes a pricing model or a signal in Python over a day or two using NumPy and pandas. If it looks promising, the latency-sensitive version gets written in C++. That C++ implementation then needs to be callable from Python for backtesting against the full historical dataset.
In that workflow, binding authorship sits in the critical path. It is not a one-time cost. The C++ API gets refined as the model is tuned. Each refinement that touches the public interface triggers a binding update. At a firm with many researchers and many models, this maintenance load accumulates into something real, and it is the kind of work that blocks iteration rather than advancing it.
Reflection does not eliminate the expertise required to write fast C++ numerical code. It eliminates the separate task of re-describing that code to Python. Those are different skills, and a team with strong quant developers should not be spending engineering cycles on binding maintenance.
What Is Still Hard
Automated binding generation handles the common case cleanly. Several situations still require explicit attention.
Overloaded functions need disambiguation. If Pricer::price has two signatures, reflection enumerates both, but pybind11 needs help choosing which to expose or how to differentiate them at the Python call site. You can write policy into the auto_bind template, but it cannot read your mind.
Non-trivial return types need converters. A function returning Eigen::Matrix<double, Dynamic, Dynamic> requires a registered type caster so pybind11 knows how to turn it into a NumPy array. Reflection can read the return type, but cannot invent the conversion logic.
Default argument values are not part of the C++ type system. P2996 reflection does not expose them, so any defaults you want visible on the Python side still require manual annotation.
Templates require explicit instantiation. You can reflect a concrete instantiation like std::vector<double>, but you cannot enumerate all possible instantiations of a function template. Each concrete type you want exposed must be named.
These are real constraints, but they are edge cases relative to the volume of binding code that reflection eliminates. The majority of public methods on numeric types in a trading system are single-signature, return concrete types, and carry no defaults worth exposing.
The Compiler Already Had This Information
The content pybind11 asks you to type out manually has always existed inside the compiler. It built a complete type graph when it compiled your headers. It knew every method name, every parameter type, every return type, every access specifier. What P2996 does is surface that information to user code, where it can drive binding generation instead of being discarded at the end of the compile.
This framing matters because it clarifies what the feature actually is. It is not new capability in the sense of doing something previously impossible. It is access to something the language was already computing and not sharing. The binding boilerplate problem existed not because the information was unavailable, but because there was no standard mechanism for user code to read it.
Production use requires waiting for stable C++26 toolchain support, realistically late 2026 or 2027 depending on your compiler vendor. But the design is stable enough that investing in auto-binding infrastructure now, against the Clang experimental branch, is a reasonable forward bet for any team running Python/C++ hybrid systems at meaningful scale.