The Array Bridge and the Object Bridge: What C++26 Reflection Actually Fixes
Source: isocpp
The Python/C++ performance bridge in algorithmic trading is typically discussed as a single problem: Python strategy code calls into a C++ execution engine, and the connection between them needs to be fast, correct, and low-maintenance. That framing obscures an important distinction. In practice, there are two structurally different Python/C++ interop patterns in a trading system, and they have different maintenance characteristics and different solutions.
Richard Hickling’s piece on isocpp.org argues that C++26 reflection collapses the Python-versus-C++ trade-off by generating bindings automatically. The argument is sound, but it applies more precisely to one of those two patterns than the other. Understanding the split tells you which parts of your codebase will benefit from P2996 and which were already handled.
The Array Bridge: Zero-Copy Data Exchange
A large class of Python/C++ interaction in trading doesn’t involve calling C++ methods on objects at all. It involves handing C++ a buffer of numerical data, a price history, a volatility surface, a grid of option parameters, and receiving a buffer of results back. Python passes a NumPy array; C++ processes it and returns another NumPy array. The binding for this pattern is thin and stable.
NumPy’s buffer protocol (PEP 3118) is the infrastructure underpinning this. It defines a standard memory layout for typed arrays that allows C and C++ code to access Python’s data directly, without copying. Both pybind11 and nanobind expose this through typed array wrappers:
// nanobind: accept a 2D array of option parameters from Python, return greeks
nb::ndarray<double, nb::ndim<2>, nb::c_contig>
compute_greeks(nb::ndarray<double, nb::ndim<2>, nb::c_contig> params) {
auto data = params.data(); // raw double* into the numpy buffer
size_t n = params.shape(0);
// operate directly on the numpy memory: no copy, no marshalling
auto result = new double[n * 5];
for (size_t i = 0; i < n; ++i) {
double spot = data[i * 5 + 0];
// ... compute delta, gamma, vega, theta, rho ...
}
return nb::ndarray<double, nb::ndim<2>, nb::c_contig>(
result, {n, 5}, nb::capsule(result, [](void* p) noexcept {
delete[] static_cast<double*>(p);
}));
}
params.data() returns a raw pointer directly into the NumPy array’s backing memory. C++ operates on the same bytes Python holds. The returned array can similarly wrap a C++ allocation that Python manages through the buffer interface, with no round-trip copy.
For vectorized pricing code, the kind that prices 10,000 options in a single call, this pattern is already solved. The binding is simple: one function, one array in, one array out. It doesn’t drift because there’s nothing to drift. The C++ function signature is essentially array -> array and the pybind11 or nanobind binding is a single .def() call that doesn’t change when you improve the algorithm, refactor the internals, or optimize the loop. You described the interface once and the interface hasn’t changed.
This is the majority of the data-intensive research workflow: pass a pandas DataFrame of historical prices, get back a vector of signals or risk metrics, run analysis in Python. The GIL can be released during the C++ computation with nb::call_guard<nb::gil_scoped_release>(), so other Python threads aren’t blocked. The pattern composes well and its binding requirements are minimal.
The Object Bridge: Where Maintenance Cost Accumulates
The second interop pattern involves richer objects. A volatility surface model is instantiated with parameters, calibrated against market data via a .fit() call, queried through named accessors for implied vols and local vols, and configured through fields that evolve over time as the model is improved. A risk engine has state, multiple update methods, and a configuration type that the C++ team extends as new risk factors are added. This is the object API pattern, and it’s where the binding maintenance burden accumulates.
When a C++ engineer adds curvature() to VolSurface, a quant researcher calling it from Python gets an AttributeError. Nothing in the build told them the method was missing from the binding; the Python binding file compiled cleanly, because it’s a separate description of the API that doesn’t know the API changed. If the binding author was diligent, they’ll add the .def() call promptly. If they missed it, the researcher assumes curvature isn’t implemented yet and works around it, possibly duplicating the computation in Python at much lower performance.
The current standard for this pattern with pybind11 requires enumerating every exposed member:
PYBIND11_MODULE(risk, m) {
py::class_<VolSurface>(m, "VolSurface")
.def(py::init<int, int>())
.def("fit", &VolSurface::fit)
.def("implied_vol", &VolSurface::implied_vol)
.def("local_vol", &VolSurface::local_vol)
.def("atm_vol", &VolSurface::atm_vol)
.def("skew", &VolSurface::skew)
.def("term_structure", &VolSurface::term_structure)
.def_readwrite("strike_cutoff", &VolSurface::strike_cutoff)
.def_readwrite("expiry_max", &VolSurface::expiry_max);
}
This is the API described twice: once in the C++ header, once in the binding file. nanobind improves compile times and binary size significantly, roughly 3 to 5 times smaller binaries and faster incremental builds compared to pybind11, but the structure of the problem is unchanged. Two representations of the same API exist, and they can diverge.
What P2996 Provides for the Object Bridge
P2996, accepted into the C++26 working draft at the WG21 Wrocław meeting in November 2024, introduces std::meta::info: a compile-time value representing any program entity. The ^ operator produces a reflection of a type, member, or function. Paired with expansion statements from P1306, it enables iterating over a class’s members at compile time and generating binding code programmatically:
template <typename T>
void auto_bind(nb::module_& m) {
auto cls = nb::class_<T>(m, std::meta::identifier_of(^T).data());
template for (constexpr auto field :
std::meta::nonstatic_data_members_of(^T)) {
if constexpr (std::meta::is_public(field)) {
cls.def_rw(std::meta::identifier_of(field).data(), [:field:]);
}
}
template for (constexpr auto fn : std::meta::member_functions_of(^T)) {
if constexpr (std::meta::is_public(fn)
&& !std::meta::is_special_member(fn)) {
cls.def(std::meta::identifier_of(fn).data(), &T::[:fn:]);
}
}
}
NB_MODULE(risk, m) {
auto_bind<VolSurface>(m);
auto_bind<RiskEngine>(m);
}
When curvature() is added to VolSurface, the next build exposes it in Python. The binding file becomes a list of types to expose, not a description of each type’s contents. For public non-overloaded methods and data members, the binding is derived from the C++ source at compile time rather than maintained in parallel.
Boris Staletić’s month-long experiment applying this approach to production-equivalent code found roughly 70 to 80 percent automation coverage for typical classes. The remaining fraction, covering overloaded methods, default argument values that P2996 deliberately does not reflect, and return value ownership semantics that the type signature doesn’t encode, requires either explicit disambiguation or API design choices that make the information available to reflection. These are real limits, but they’re limits on the hard cases that already required explicit decisions before C++26.
How They Compose in a Real System
A realistic quant research workflow mixes both patterns. The data path is vectorized: pass a 2D array of historical option parameters to compute_greeks(), get back a matrix of sensitivities, analyze the results in Python using NumPy and pandas. The C++ here processes bulk data; the binding is thin and stable.
The object path handles configuration and lifecycle: instantiate a VolSurface with market parameters, call .fit() to calibrate it against observed prices, then pass the fitted model to the vectorized pricing functions as a parameter. The model object is stateful; its API evolves as the C++ team extends the model.
The vectorized data path is largely working today, independent of C++26. The object API path is where API drift accumulates over a product’s lifetime, and that’s the path reflection addresses.
The Gap That Remains
Neither bridge handles GIL management automatically. For the array bridge, releasing the GIL during a long C++ computation prevents it from blocking all other Python threads:
m.def("compute_greeks", &compute_greeks,
nb::call_guard<nb::gil_scoped_release>());
For reflection-generated object bindings, GIL policy can’t be inferred from a method’s type signature. A price() method that runs for 200 microseconds should release the GIL; a get_name() accessor that returns a cached string probably should not. That semantic distinction lives in the function’s contract, not its declared types.
P1854, a companion proposal for user-defined attributes inspectable at compile time, would allow annotating GIL policy and return value ownership directly in C++ source. A reflection-based generator could then read those annotations and emit the appropriate guards automatically. P1854 is moving through the committee but is not confirmed for C++26.
The practical timeline for C++26 in production is 2027 to 2028 depending on toolchain requirements. The Bloomberg Clang fork tracking P2996 is available through Compiler Explorer now for experimentation.
The distinction between the array bridge and the object bridge is worth keeping in mind when evaluating this feature. If most of your Python/C++ surface is batch functions over NumPy arrays, the binding maintenance problem is already small and reflection contributes little. If you maintain rich object APIs that evolve alongside an active C++ codebase, which is the typical situation in quantitative finance with continuously developed pricing models and risk engines, reflection converts an ongoing maintenance obligation into a compiler derivation. Those are different situations, and knowing which one you’re in tells you how much C++26 reflection will change your workflow when it ships.