The Binding Layer That Maintains Itself: C++26 Reflection for Python Interop
Source: isocpp
The performance case for calling C++ from Python is well established. Python executes strategy logic; C++ handles the execution path. The boundary crossing costs tens to hundreds of nanoseconds per call, which is acceptable in most systems. What is not well established is who maintains the layer between them, and what happens when it drifts from the code it describes.
Richard Hickling’s article on isocpp.org frames C++26 reflection as a way to eliminate the Python-vs-C++ trade-off in algorithmic trading. The more precise framing is this: reflection eliminates the manual transcription step that has been part of every Python-C++ bridge since ctypes.
Three Decades of Bridging, Same Problem
The Python FFI ecosystem has evolved steadily. ctypes, the original approach, handles C-compatible ABIs but does not understand C++ classes, virtual dispatch, or RAII. Boost.Python, introduced in 2002, solved the C++ object model problem and could expose full class hierarchies, but required the entire Boost dependency. pybind11, released in 2015, reduced that to a header-only library with a clean registration DSL and became the practical standard. nanobind, written by pybind11’s original author Wenzel Jakob in 2022, addressed pybind11’s compile-time and binary size overhead with roughly a 5x improvement in both dimensions.
Each generation solved something real. None of them changed the fundamental requirement that a developer must manually register every class, method, and property they want Python to see.
The registration code does not contain logic. It contains descriptions:
class BlackScholesPricer {
public:
double price(double spot, double strike, double vol,
double rate, double expiry);
double delta(double spot, double strike, double vol,
double rate, double expiry);
void set_dividend_yield(double q);
double get_dividend_yield() const;
};
// Separate binding file, maintained in parallel:
PYBIND11_MODULE(pricers, m) {
py::class_<BlackScholesPricer>(m, "BlackScholesPricer")
.def("price", &BlackScholesPricer::price)
.def("delta", &BlackScholesPricer::delta)
.def("set_dividend_yield", &BlackScholesPricer::set_dividend_yield)
.def("get_dividend_yield", &BlackScholesPricer::get_dividend_yield);
}
Rename price to fair_value and this binding silently breaks. The method disappears from Python’s view of the class. No compile error. No linker error. A runtime AttributeError at the call site, if you test it, or a production incident if you do not. The binding layer is a shadow of the real code that does not update automatically when the real code changes.
What Reflection Actually Provides
C++26 reflection, standardized as P2996 and accepted into the C++26 working draft at the November 2024 WG21 meeting in Wrocław, gives the compiler the ability to produce structured descriptions of its own type information as values. The word “values” is doing real work here.
Previous attempts at compile-time reflection in C++, including the reflexpr proposal (P0194), represented reflection results as types. Iterating over the members of a struct meant recursive template specialization. Filtering those members required trait definitions. Passing a reflected entity to a helper meant encoding it in a template parameter. The machinery was correct in theory and nearly unusable in practice.
P2996 uses a scalar type, std::meta::info, to represent any reflected entity. The reflect operator ^ produces one:
constexpr std::meta::info r = ^BlackScholesPricer;
constexpr auto methods = std::meta::member_functions_of(r);
Because the results are values, they compose with the standard library directly. std::ranges::filter, std::ranges::transform, range-for loops, and constexpr std::vector<std::meta::info> all work without additional machinery. Compile errors name actual identifiers rather than template instantiation stacks. The design trade-off is explicit: std::meta::info only exists during constant evaluation, so P2996 provides no runtime reflection capability at all. This is consistent with C++‘s general approach of paying at compile time to avoid paying at runtime.
The companion proposal P1306 adds expansion statements, which allow iterating over a compile-time range with per-element instantiation:
template for (constexpr auto method : std::meta::member_functions_of(^T)) {
// Body instantiated once per method of T
}
Together, P2996 and P1306 make binding generation expressible as a library function.
Automatic Binding Generation
The bind_class function below handles all public non-special member functions and public data members of any class passed to it. The registrations that previously required manual maintenance are now derived from the source:
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:]);
}
}
}
PYBIND11_MODULE(pricers, m) {
bind_class<BlackScholesPricer>(m, "BlackScholesPricer");
}
The splice operator [: :] materializes a std::meta::info value back into a code context; &T::[:method:] produces a pointer-to-member from the reflected method. std::meta::identifier_of returns the source name as a string_view, which pybind11 uses for the Python-side name.
Rename price to fair_value in the C++ source and the binding updates automatically on the next build. The synchronization obligation is gone.
Enum bindings follow the same pattern:
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:]);
}
}
The 70-80% Ceiling
Boris Staletić’s extended experiment with reflection-based binding generation over a month of production-equivalent C++ puts the automation ceiling at roughly 70-80% of a typical API surface. The remaining 20-30% requires decisions that the compiler cannot make from source structure alone.
Overloaded methods are the most common case. P2996 can enumerate all overloads of a function by name, but it cannot decide whether Python should see them as a single function with dynamic dispatch or as separately named methods. That is an API design choice.
Return value policies are another. pybind11’s .def() optionally takes a py::return_value_policy argument specifying ownership semantics: whether Python owns the returned object, whether it borrows a reference into C++ memory, whether it copies. The correct policy depends on the lifetimes involved in the specific case, which are not always visible from the type signature alone.
Default argument values present a subtler limit. P2996 can detect that a parameter has a default, but the proposal does not currently provide access to the default expression’s value. P1854, which standardizes attribute reflection, would close some of this gap by allowing annotations like [[py::return_policy(reference_internal)]] that a binding generator could read.
For the cases reflection cannot handle automatically, the options are explicit specialization of the binding template for specific types, or a thin manual layer on top of the generated bindings. The manual layer shrinks from hundreds of registrations to a handful of policy decisions.
The Failure Mode That Matters
The change that reflection produces in practice is a shift in when binding errors surface. Manual pybind11 bindings fail at runtime when the registered name or signature no longer matches the C++ source. Reflection-based bindings fail at compile time when the binding template instantiation does not type-check against the class.
For a CI pipeline, this is the difference between a test catching the problem and the problem arriving in production. For a quant research workflow where a C++ engineer maintains the bridge and a researcher consumes it from Python, it means the researcher’s Python code breaks loudly at build time rather than silently at runtime, and the engineer does not spend time updating binding files when the C++ API evolves.
Implementation Status
The reference implementation lives in Bloomberg’s Clang fork and is accessible through Compiler Explorer under “clang (experimental reflection)”. The EDG frontend has the most complete implementation as of early 2026 and is what the P2996 authors use for testing. Upstream Clang has received partial merges from the Bloomberg work under experimental flags. GCC has ongoing community-driven implementation. MSVC has no announced timeline.
The C++26 standard is expected to finalize in late 2026. Compiler vendors typically follow the standard by one to two years for production-quality support, which puts realistic adoption in 2027-2028 for Clang-heavy shops. Teams dependent on MSVC will wait longer.
The current Compiler Explorer support is sufficient for prototyping and for validating the binding generation approach against real class hierarchies before the feature reaches production compilers. The core API is stable enough that code written today against the Bloomberg fork should require minimal changes when upstream support arrives.
What Stays the Same
P2996 does not change the performance characteristics of the Python-C++ boundary. A call from Python to C++ through pybind11 costs the same whether the binding was written manually or generated by reflection. The binary output is identical; reflection is entirely a compile-time operation with no runtime trace.
What changes is the relationship between C++ source and Python API. In the manual model, they are maintained separately and diverge silently. In the reflection model, the Python API is derived from the C++ source at build time, and any divergence is a compile error. The maintenance cost scales with the rate of API change rather than with the size of the API surface.
For systems where the C++ core evolves frequently, that difference compounds. PyTorch has tens of thousands of lines of operator binding code. The Rosetta biomolecular modeling suite has a comparable volume. The cost of maintaining those bindings is not a one-time investment; it is a continuous tax on every API change. Reflection does not eliminate that tax for the policy decisions that require human judgment, but it removes it for the mechanical registrations that outnumber the policy decisions by a large margin.