The Bridge Writes Itself: C++26 Reflection and the Python Binding Problem
Source: isocpp
The problem isn’t that Python and C++ can’t interoperate. They’ve interoperated for years through tools like pybind11, nanobind, and SWIG. The problem is that every time you add a method to a C++ class, someone has to update the binding file. In an algorithmic trading context, where a pricing engine might have dozens of methods and a team that iterates quickly, that binding file becomes a maintenance liability: stale, incomplete, and quietly out of sync with the actual C++ API.
Richard Hickling’s article on the ISO C++ blog frames this as a Python-vs-C++ choice that C++26 makes unnecessary. The framing is correct, but there’s more to unpack in how the reflection machinery solves the problem and why earlier approaches fell short.
What pybind11 Asks of You
pybind11 is the standard tool for this work and it handles most edge cases well. Consider a typical binding file for an options pricer:
// bindings.cpp
#include <pybind11/pybind11.h>
#include "black_scholes.hpp"
namespace py = pybind11;
PYBIND11_MODULE(pricing, m) {
py::class_<BlackScholesPricer>(m, "BlackScholesPricer")
.def(py::init<double, double, double>())
.def("price", &BlackScholesPricer::price)
.def("delta", &BlackScholesPricer::delta)
.def("gamma", &BlackScholesPricer::gamma)
.def("vega", &BlackScholesPricer::vega)
.def("theta", &BlackScholesPricer::theta);
}
This code has a structural problem: it duplicates your API. Every public method exists twice, once in black_scholes.hpp and once in bindings.cpp. When you add rho() to the pricer, the binding file compiles cleanly. Python just silently cannot call rho(). You find out when a strategy tries to use it at runtime, not at build time.
nanobind, the more recent reimplementation by the same author (Wenzel Jakob), improves compile times and binary size substantially, but shares this structural issue. SWIG uses a separate interface file to describe the API, which arguably makes things worse: you now have three representations of the same API, the header, the interface file, and any Python wrapper code on top.
The root cause in all three tools is the same: the binding is a declaration, not a derivation. You are telling the tool about your API rather than having the tool discover it.
What C++26 Reflection Actually Provides
P2996, the reflection proposal accepted for C++26, takes a value-based approach that makes compile-time API discovery tractable. Previous attempts at introspection in C++ worked through types: template metaprogramming, std::type_traits, Boost.Hana. The limitation is that types are not first-class values; you cannot put them in a constexpr array and iterate without substantial scaffolding.
P2996 introduces std::meta::info, an opaque constant value that represents a program entity: a type, a function, a member, a namespace. You produce these values with the ^^ reflect operator and splice them back into code with [: :]. The core functions live in a new <meta> header:
#include <meta>
constexpr auto r = ^^BlackScholesPricer;
// Get all members as a constexpr range
constexpr auto members = std::meta::members_of(r);
// Interrogate each member at compile time
template for (constexpr auto m : members) {
constexpr std::string_view n = std::meta::name_of(m);
constexpr bool is_fn = std::meta::is_nonstatic_member_function(m);
}
The template for syntax comes from the companion expansion statement proposal P1306. Each iteration of the loop is a separate instantiation at compile time; this is code generation, not a runtime loop. The value-based model is what enables this: because std::meta::info values can live in constexpr containers and be passed to consteval functions, you can build up and manipulate a list of members just like you would a list of integers.
Auto-Generating Bindings
With these primitives you can write a generic binding generator that requires no changes when you extend a class:
#include <meta>
#include <pybind11/pybind11.h>
namespace py = pybind11;
template <typename T>
void auto_bind(py::module_& m, const char* class_name) {
auto cls = py::class_<T>(m, class_name);
template for (constexpr auto member : std::meta::members_of(^^T)) {
if constexpr (std::meta::is_public(member)
&& std::meta::is_nonstatic_member_function(member)
&& !std::meta::is_constructor(member)) {
cls.def(
std::meta::name_of(member).data(),
[](T& self, auto&&... args) -> decltype(auto) {
return (self.[:member:])(std::forward<decltype(args)>(args)...);
}
);
}
}
}
PYBIND11_MODULE(pricing, m) {
auto_bind<BlackScholesPricer>(m, "BlackScholesPricer");
auto_bind<LocalVolPricer>(m, "LocalVolPricer");
}
The (self.[:member:]) syntax splices the reflected member info back into a member function call expression. When you add rho() to BlackScholesPricer, this module recompiles and the new method is available from Python with no manual change. The binding file is now a list of types, not a list of methods. You declare intent once and the compiler fills in the details.
Constructor handling requires slightly more care since you need to reflect on constructor parameter types and produce the corresponding py::init<...>() call, but the mechanism is the same. The reflection predicates is_constructor, parameters_of, and type_of give you everything you need.
Rust’s PyO3 Takes a Different Route
PyO3 solves the same problem in Rust using procedural macros rather than language-level reflection. The approach is annotation-based:
use pyo3::prelude::*;
#[pyclass]
struct BlackScholesPricer {
spot: f64,
vol: f64,
rate: f64,
}
#[pymethods]
impl BlackScholesPricer {
fn price(&self) -> f64 { /* ... */ }
fn delta(&self) -> f64 { /* ... */ }
fn rho(&self) -> f64 { /* ... */ } // automatically bound
}
The #[pymethods] macro reads the impl block’s AST at compile time and generates the registration code. The developer experience here is genuinely good, and PyO3 is one of the more ergonomic interop stories in any language ecosystem.
But there is a meaningful difference in mechanism. PyO3 requires annotating the implementation: if you add a method in an unannotated impl block, it will not be bound. C++26 reflection-based binding works over all public members of any class without any annotation on the class itself. You can bind a type from a vendor library or a shared internal component without touching its source, as long as you have a header. That distinction matters in environments where the C++ code lives outside teams’ direct control.
D’s Compile-Time Reflection as Prior Art
D has had compile-time introspection since its early versions. The __traits(allMembers, T) feature, combined with compile-time function evaluation, allows exactly this kind of binding generator. The autowrap library generates Python, Excel, and Jupyter bindings for D code automatically using these mechanisms, and it has been doing so in production trading environments for years.
C++ is arriving at this capability later, but with a more legible mechanism. D’s __traits requires substantial CTFE scaffolding to handle complex cases. P2996’s named predicates (is_public, is_nonstatic_member_function, is_constructor) and the composable consteval function model are more readable and easier to build on. The value-based design also makes error messages substantially better than D’s template-based introspection, which produces famously cryptic diagnostics.
What Reflection Does Not Fix
The binding generation problem is largely solved by this approach. The bridge maintenance burden goes away. The harder problems in Python/C++ interop remain unchanged.
The Global Interpreter Lock means that if your C++ code runs multi-threaded work alongside Python, you need explicit py::gil_scoped_release guards around compute-intensive sections. A reflective binding generator cannot infer which operations are safe to run without the GIL; that requires semantic knowledge about the computation, not just structural knowledge about the API.
Memory ownership across the boundary remains nuanced. pybind11 and nanobind both have explicit policies for whether Python or C++ owns an object returned from a method. Getting this wrong causes leaks or double-frees. Reflection can see that a method returns a pointer; it cannot determine whether that pointer is owned by the caller or refers into a C++-managed data structure. Ownership annotations on the C++ side, communicated somehow to the binding generator, are still necessary for non-trivial return types.
Exception handling requires care in any hybrid codebase. C++ exceptions crossing the Python boundary need to be caught and re-raised as Python exceptions, which pybind11 handles via its exception translator mechanism. An auto-generated binding that does not also enumerate and register application-specific exception types will surface those as opaque runtime errors on the Python side.
None of these are problems reflection was designed to solve. They are problems of semantics, not structure. Reflection handles the structural problem, which is real and significant, while leaving the semantic problems to explicit human annotation.
The Broader Shift
When binding maintenance is cheap, teams can move the performance boundary more freely. You can prototype in Python, identify a hot path, implement it in C++, and expose it back without a project-stopping binding rewrite. The boundary becomes porous in a productive way.
The article from Hickling is right to frame this as eliminating a false choice between Python flexibility and C++ performance. The reason reflection accomplishes this where earlier C++ techniques did not comes down to the specific design decision in P2996: treating reflected entities as first-class constant values rather than as template parameters. That choice, not reflection in the abstract, is what makes compile-time iteration over class members tractable without template metaprogramming gymnastics. The binding generator example is the cleanest demonstration of why that design decision was the right one.