· 6 min read ·

The Template Instantiation Problem That Makes C++26 Reflection More Useful Than Simple Examples Suggest

Source: isocpp

The conversation around C++26 reflection and Python binding automation, kicked off most recently by Richard Hickling’s piece on isocpp.org, typically uses a simple example: a BlackScholesPricer class with a handful of public methods, registered with pybind11 through a block of .def() calls. The mechanism is clear in that context. What it obscures is that production quantitative finance C++ is heavily templated, and that template-heavy code is where the comparison between P2996 and existing external tools most clearly favors in-compiler reflection.

How Quant C++ Is Actually Structured

QuantLib, the most widely referenced open-source quantitative finance library, parameterizes pricing engines on stochastic process types, term structure models, and exercise specifications. In-house quant libraries at trading firms frequently go further with policy-based design. A single PricerEngine template parameterized on option type, numerical method, and model type produces many distinct concrete types from one definition:

template <typename OptionType, typename ModelType, typename NumericsPolicy>
class PricerEngine {
public:
    using result_type = typename ModelType::result_type;
    result_type price(const typename OptionType::params_type& params) const;
    result_type delta(const typename OptionType::params_type& params) const;
    result_type vega(const typename OptionType::params_type& params) const;
};

// Concrete specializations defined as aliases
using VanillaBS     = PricerEngine<Options::European, Models::BlackScholes, Numerics::ClosedForm>;
using VanillaHeston = PricerEngine<Options::European, Models::Heston, Numerics::Integration>;
using BarrierBS     = PricerEngine<Options::Barrier, Models::BlackScholes, Numerics::FiniteDifference>;

Quant researchers want to drive VanillaBS, VanillaHeston, and BarrierBS from Python notebooks. For every model variant a researcher adds, the Python binding layer needs to follow.

The External Tool Problem With Templates

Binder is the most capable external solution for pybind11 generation. It runs as a Clang-based tool outside the compiler, parses C++ headers using the Clang AST, and emits pybind11 registration code. Rosetta Commons uses it for protein modeling infrastructure at a scale where manual binding maintenance is not viable.

For template classes, Binder requires explicit configuration files specifying which instantiations to expose. That is not a Binder-specific limitation; it is a structural constraint. Binder processes the template definition before any particular instantiation has been requested, so it cannot know which specializations exist in your codebase without being told. You list them:

# Binder configuration
+include <pricer_engine.h>
+namespace mylib

+instantiate PricerEngine<Options::European, Models::BlackScholes, Numerics::ClosedForm>
+instantiate PricerEngine<Options::European, Models::Heston, Numerics::Integration>
+instantiate PricerEngine<Options::Barrier, Models::BlackScholes, Numerics::FiniteDifference>

When a researcher adds PricerEngine<Options::Barrier, Models::Heston, Numerics::MonteCarlo>, someone must update the configuration file and re-run Binder before the new type appears in Python. The failure mode is silent: the new instantiation is simply absent from the Python module until a human notices the gap. For a research-facing library where new model variants appear continuously, this configuration maintenance is structurally the same problem as manually updated pybind11 registrations — pushed one level up into tooling.

How P2996 Handles the Same Situation

P2996, accepted into the C++26 working draft at the Wrocław WG21 meeting in November 2024, works on instantiated types rather than template definitions. The reflect operator ^T produces a std::meta::info value for whatever concrete type T resolves to. For a template specialization, that means reflecting on the fully instantiated type with all policy parameters substituted, without any configuration.

The generic binding template applies to policy-based types directly:

template <typename T>
void bind_class(py::module_& m, std::string_view name) {
    auto cls = py::class_<T>(m, name.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:]
            );
        }
    }
}

PYBIND11_MODULE(pricers, m) {
    bind_class<VanillaBS>(m, "VanillaBS");
    bind_class<VanillaHeston>(m, "VanillaHeston");
    bind_class<BarrierBS>(m, "BarrierBS");
}

When PricerEngine<Options::Barrier, Models::Heston, Numerics::MonteCarlo> is added to the library, the team adds one line to the module definition. Reflection iterates over the new specialization’s public members automatically. The template for expansion statement from companion proposal P1306 instantiates the loop body once per member, producing the binding code at compile time. No configuration file, no separate tool run, no possibility of the binding tool diverging from the compiler that actually processes the code.

That last point is the structural advantage. Binder consumes compiler output; it does not participate in compilation. P2996 reflection runs during normal compilation, so it sees exactly the type information the compiler uses to build the binary. The two cannot diverge.

What Reflection Actually Sees in a Specialization

std::meta::members_of(^VanillaBS) returns the public members of that specific instantiation, after template parameter substitution. If the Numerics::ClosedForm policy adds a closed_form_solution method and Numerics::MonteCarlo adds path_count and seed configuration, the reflection for each specialization sees only the members that actually exist on that concrete type. This is more useful than what a header-parsing tool can do with a template definition.

std::meta::template_arguments_of(^T) additionally exposes the template argument list for a specialization. This enables compile-time decisions based on which policies are active:

template <typename T>
void bind_class(py::module_& m, std::string_view name) {
    auto cls = py::class_<T>(m, name.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)) {
            // Release GIL for Monte Carlo methods (expensive computation)
            if constexpr (/* inspect template_arguments_of(^T) for NumericsPolicy */) {
                cls.def(std::meta::identifier_of(method).data(),
                        &T::[:method:],
                        py::call_guard<py::gil_scoped_release>());
            } else {
                cls.def(std::meta::identifier_of(method).data(),
                        &T::[:method:]);
            }
        }
    }
}

The compiler’s knowledge of the type system is fully available to the reflection machinery in ways an external tool operating outside the compiler cannot replicate. GIL policy selection based on numerical method, return value policy based on result type ownership, const-correctness flow from is_const on member types — all of these become decidable compile-time questions once you have structural access to the instantiated type.

The Compile-Time Cost Is Linear

template for generates one compiler instantiation per member. A class with 40 public methods produces 40 instantiation steps per bind_class call. Bloomberg’s experimental Clang fork measurements show roughly 2 to 5x compile time increase for structs with 50 to 100 members when generating full binding code, compared to the manual approach.

The key property is that this growth is linear in member count, not superlinear. Template metaprogramming approaches like those in Boost.Hana produce recursive instantiation graphs that grow faster than linearly on larger types. P2996 trades compile time predictably, which matters for CI pipelines and incremental build cycles in active development.

The practical mitigation is architectural: put reflection-heavy binding code in translation units that compile infrequently. Adding a new model specialization triggers a rebuild of the binding module but not of the core pricing library.

Where Binder Retains One Advantage

Binder, parsing the full Clang AST, can extract default argument values from function declarations and embed them in generated binding code as py::arg("name") = value. P2996’s std::meta::has_default_argument detects that a default exists; it cannot retrieve the value. This is deliberate: the C++ type system does not model default expressions as compile-time values, and surfacing them through in-compiler reflection requires a more invasive change than P2996’s scope covers.

For template-parameterized pricers where constructors take many configuration parameters with market-convention defaults, this means either exposing Python callers to positional arguments or writing the constructor binding manually. Libraries already using named parameter structs sidestep this: struct members do not have “default arguments” in the pybind11 sense, and reflection handles aggregate initialization of named parameter types naturally.

Boris Staletić’s month-long field experiment applying P2996 to real financial code found roughly 70 to 80 percent automation coverage for typical class APIs. The remaining friction — overloaded methods requiring disambiguation, pointer return types with implicit ownership contracts, default argument values — maps to a specific set of cases, most of which are resolvable through deliberate C++ API design rather than waiting on future proposals.

Where Things Stand

The Bloomberg-maintained clang-p2996 fork implements the core of P2996 and P1306, accessible today on Compiler Explorer under experimental Clang configurations. The C++26 standard targets publication in late 2026, with production compiler support on a 2027 to 2028 timeline depending on your toolchain.

Hickling’s framing, that C++26 reflection dissolves the Python-versus-C++ trade-off in algorithmic trading, holds up in the template-heavy case more than simple examples convey. External tools manage template instantiation through configuration; in-compiler reflection manages it through compilation itself. For quant libraries where model variants multiply with research velocity, that is not a minor ergonomic improvement. It is the difference between binding infrastructure that scales automatically with the type system and binding infrastructure that requires maintenance every time a researcher adds a pricing model.

Was this interesting?