· 7 min read ·

Two Languages, One Bridge: What Rust's PyO3 Reveals About C++26 Reflection

Source: isocpp

The algorithmic trading framing that Richard Hickling uses on isocpp.org is legitimate: Python for rapid strategy development, C++ for low-latency execution, and a binding layer connecting them that breaks whenever the C++ API changes. What the article does not mention is that Rust solved an equivalent problem several years earlier, using a completely different mechanism, and the comparison between the two approaches clarifies what C++26 reflection actually is and what it costs to deliver.

The Same Problem, Handled Differently

Both Rust and C++ face the Python interop problem. You write performance-critical code and want it callable from Python. The binding layer connecting them requires either manual effort or tooling assistance, and manual effort drifts.

Rust’s answer is PyO3, which reached production stability around version 0.14 in 2021 and has been the de facto standard for Rust/Python interop since. The approach is annotation-driven. You mark what you want exported:

use pyo3::prelude::*;

#[pyclass]
struct BlackScholesPricer {
    rate: f64,
    vol: f64,
}

#[pymethods]
impl BlackScholesPricer {
    #[new]
    fn new(rate: f64, vol: f64) -> Self {
        BlackScholesPricer { rate, vol }
    }

    fn price(&self, spot: f64, strike: f64, expiry: f64) -> f64 {
        // pricing logic
        todo!()
    }

    fn delta(&self, spot: f64, strike: f64, expiry: f64) -> f64 {
        todo!()
    }
}

#[pymodule]
fn pricers(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
    m.add_class::<BlackScholesPricer>()?;
    Ok(())
}

The #[pyclass] and #[pymethods] attributes trigger procedural macros that generate all the CPython FFI glue at compile time. The Rust source is the single source of truth. Remove delta, and the Python module no longer has it. Add a method under #[pymethods], and it appears on the next build. The mechanism works on stable Rust with no experimental compiler flags.

C++26’s answer through P2996 is structural. Instead of annotating what you want exported, you write a generic template that enumerates what already exists:

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

Nothing in the original C++ class changes. The auto_bind template applies to any class, including code from libraries you do not own.

Opt-In vs Structural: Why the Distinction Matters

PyO3’s annotation model requires you to decide what Python sees, item by item. For new Rust code written with Python consumers in mind, this is natural. Every exported item is labeled in source, and the intent is visible to anyone reading the code later.

The constraint appears when you need to wrap existing code you did not write. A C++ pricing library from a vendor, or an internal library maintained by a team with no Python mandate, cannot receive #[pyclass] annotations. You have no access to the source, or no authority to modify it. In that situation, PyO3 does not help. You are back to manual binding code, which is the maintenance problem Hickling describes.

P2996’s structural approach eliminates this constraint. The ^ operator reflects any C++ entity, regardless of how or when it was written. An auto_bind template works on a C++11 library from ten years ago just as well as on code written yesterday. The compiler’s type information was always there; P2996 makes it available to user code for the first time.

For trading infrastructure specifically, this asymmetry matters. The C++ pricing libraries at most firms predate any Python requirement. They were written by C++ engineers for C++ consumers, and they have stable, well-typed public interfaces. Reflecting on them without modifying them is not a convenience feature; it is the only viable automation path.

Why Rust Got There First

PyO3 delivered stable Python interop for Rust years before C++26 reflection will reach production compilers. The gap is not coincidental.

Rust’s procedural macros give library authors compile-time access to token streams. A #[pyclass] macro receives the struct definition as tokens, transforms it, and emits the generated FFI code. The macro does not need to reflect on the type from outside; it processes the annotated item directly, using the Rust compiler’s own parsing and type-checking infrastructure as the host.

C++ reflection needed to solve a harder problem. P2996 gives user code read access to the compiler’s type graph for any class, whether annotated or not. That required working through questions that Rust’s proc macro model avoids: how to represent reflected entities without causing template instantiation explosion (the answer: the value-based std::meta::info scalar type rather than type-encoded metadata), how to iterate over member lists at compile time without hitting depth limits (the answer: expansion statements from P1306), and how to splice a reflected entity back into usable pointer-to-member syntax (the answer: the [::] operator).

Prior C++ reflection proposals failed specifically because they chose type-based approaches. N3996 in 2014 and N4428 in 2015 encoded reflected metadata as template hierarchies. On any class with more than a handful of members, instantiation depth limits were exceeded and error messages became unreadable. P2996’s breakthrough, treating std::meta::info as a first-class value rather than a type, was not obvious. It took roughly a decade of committee iteration to arrive at. The Rust macro system sidesteps this entirely because proc macros operate at the token level, not the type level, which is a simpler and more constrained abstraction.

C++26’s result is more general than PyO3 precisely because it does not require annotation. It is also harder to deliver for the same reason.

The GIL Problem That Neither Approach Automates

Python’s Global Interpreter Lock allows only one thread to execute Python bytecode at a time. When Python calls into native code, well-written extensions release the GIL during the computation so other Python threads can proceed. Failing to release it means a computationally expensive pricing function serializes the entire Python interpreter for its duration.

PyO3 addresses this explicitly. A method that accepts the Python<'_> token can call allow_threads to release the GIL:

fn price(&self, py: Python<'_>, spot: f64, strike: f64, expiry: f64) -> f64 {
    py.allow_threads(|| {
        // GIL released; other Python threads can proceed
        self.compute_price(spot, strike, expiry)
    })
}

With pybind11, the equivalent is a call guard:

cls.def("price", &Pricer::price, py::call_guard<py::gil_scoped_release>());

The automatic binding generation that P2996 enables does not include GIL policy. For the same reason it cannot infer return value policies or ownership semantics, it cannot infer whether a given C++ function should release the GIL. The information lives in the function’s contract, not its type signature.

For algorithmic trading, where Python-facing methods are predominantly CPU-intensive pricing calculations, this is a real gap. An auto_bind template that generates bindings without releasing the GIL produces correct bindings that nonetheless serialize all Python threads through each pricing call. Whether to hold or release the GIL is a per-function semantic decision that the binding generator cannot make alone.

The path forward here is P1854, user-defined attributes inspectable via reflection, which would allow annotations directly in C++ source:

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

[[py::return_policy(reference_internal)]]
const Config& get_config() const;

A binding template could read these attributes at compile time and emit the appropriate call guards and return value policies automatically. P1854 is moving through the committee but is not in C++26. Until it ships, GIL policy in reflection-generated bindings is either a global default applied to all methods or a manually maintained exception list.

The free-threaded Python builds introduced experimentally in Python 3.13 (via PEP 703) reduce some of this pressure in the long run, but free-threaded builds are not yet the default and extension module compatibility with them is still being worked out across the ecosystem.

Which Approach Fits Which Context

For greenfield projects where language choice is open, PyO3 offers a complete, stable, well-documented path to Python interop today. The annotation model requires planning the Python API surface, but that planning is worthwhile regardless. Ownership semantics are explicit in source, GIL handling is visible where it matters, and Rust’s type system prevents entire categories of FFI mistakes that C++ allows.

For projects built on existing C++ infrastructure, which is most of quantitative finance, P2996 is the more appropriate approach once the toolchain support arrives. The structural model wraps code without touching it, derives binding information from the same type data the compiler uses during compilation, and shifts the failure mode from silent runtime mismatch to compile-time error. Boris Staletić’s field experiments applying P2996 to real financial code found roughly 70-80% automation coverage on typical class APIs, with the remaining fraction requiring explicit handling for overloads, return value policies, and GIL semantics.

The Bloomberg-maintained experimental Clang fork tracking P2996 is accessible via Compiler Explorer today, which makes the approach testable even before standardization completes. Production deployment on stable compilers is a 2027 to 2028 timeline depending on which compiler your project requires.

Hickling’s framing, that C++26 reflection dissolves the Python-versus-C++ trade-off in trading, is directionally correct. The more precise version is that it automates the part of the binding problem that was never a design decision: the mechanical transcription of a public interface that the compiler already understood. The parts that remain, GIL policy, ownership semantics, overload disambiguation, are cases where someone genuinely needs to make a choice. Reflection removes the overhead of re-describing everything that never required a choice in the first place. PyO3 did the same for Rust years earlier, through a different mechanism, for a different set of constraints. Both are answers to the same question, from languages that had different problems to solve.

Was this interesting?