The binding problem is a good one to stress-test reflection against because it is purely mechanical. For every public method on a C++ class, you write a .def() call that names the method and takes its address. For every public data member, you write .def_readwrite() or .def_readonly(). The boilerplate is straightforward to produce once, but it drifts the moment you add a method, rename a parameter, or change a return type. For a mid-sized C++ library with a Python API, the binding code becomes a second codebase that shadows the first.
Boris Staletić’s experiment from November 2025 is one of the first published accounts of using C++26 reflection to automate this generation in real code. It is worth reading as a retrospective: P2996 was accepted into the C++26 working draft at Wrocław in November 2024, and by late 2025 the Bloomberg experimental Clang fork had stabilized enough that writing a month of production-oriented reflection code was feasible. The findings are candid. Some things work better than you would expect. Others expose specific gaps that are not incidental but structural.
What pybind11 Binding Code Is Actually Doing
To understand where reflection helps, it is worth cataloguing what pybind11 binding code contains. For a class T with mixed members:
PYBIND11_MODULE(mylib, m) {
py::class_<T>(m, "T")
.def(py::init<int, std::string>())
.def("compute", &T::compute)
.def("compute", py::overload_cast<int, int>(&T::compute)) // overload
.def_readwrite("value", &T::value)
.def_readonly("name", &T::name)
.def("process", &T::process,
py::arg("input"),
py::arg("flags") = ProcessFlags::Default)
.def_static("create", &T::create);
}
Each element requires from the C++ side: a string name, a pointer-to-member or function pointer, and optionally type information for overload disambiguation and default values for named arguments. P2996 can supply most of these. Where it cannot is in the semantic layer: what a raw pointer return value means for object lifetime, whether an overloaded name should map to one Python name or several, and what the default expression evaluates to.
The Basic Approach with Expansion Statements
The core pattern uses std::meta::members_of combined with template for, the compile-time iteration construct from P1306. These two proposals are tightly coupled in practice; P2996 provides the reflection values, and P1306 provides the only ergonomic way to iterate over them and generate distinct code per element.
template <typename T>
void bind_members(py::class_<T>& cls) {
template for (constexpr auto m : std::meta::members_of(^T)) {
if constexpr (std::meta::is_public(m)) {
if constexpr (std::meta::is_nonstatic_data_member(m)) {
if constexpr (std::meta::is_const(std::meta::type_of(m))) {
cls.def_readonly(
std::meta::name_of(m).data(), &T::[:m:]);
} else {
cls.def_readwrite(
std::meta::name_of(m).data(), &T::[:m:]);
}
}
if constexpr (std::meta::is_nonstatic_member_function(m)
&& !std::meta::is_special_member_function(m)) {
cls.def(
std::meta::name_of(m).data(), &T::[:m:]);
}
}
}
}
^T reflects the type into a std::meta::info value. std::meta::members_of returns a compile-time range of meta::info values for all direct members. std::meta::name_of(m) returns a std::string_view of the member identifier. [:m:] in the expression &T::[:m:] splices the reflected member back into a pointer-to-member expression. The predicate functions (is_public, is_nonstatic_data_member, and so on) are consteval functions in the std::meta namespace.
For a class with only simple non-overloaded methods and public data members, this generates complete binding code with zero manual work. The names stay synchronized with the C++ identifiers by construction, which eliminates an entire category of binding drift.
What Works Cleanly
Data members are essentially solved. Const correctness flows through automatically via std::meta::is_const on the member type. Static members are distinguishable with std::meta::is_static_member. The reflected name always matches the C++ identifier, so renames propagate without touching the binding file.
Simple non-overloaded member functions work almost as well. For a class with 40 methods and no overloads, the above pattern generates 40 .def() calls. The maintenance burden drops to zero for that portion of the API surface.
Enum-to-string and string-to-enum conversions, which pybind11 supports through py::enum_, also automate cleanly:
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::name_of(v).data(), [:v:]);
}
}
This is the kind of boilerplate that has historically been generated by external tools or written by hand and left to rot.
The Overload Problem
When a class has overloaded methods, the &T::[:m:] pattern becomes ambiguous. The compiler cannot resolve which overload you mean without a cast. pybind11 provides py::overload_cast<ArgTypes...> for disambiguation, and generating it requires knowing the parameter types of each overload.
P2996 provides std::meta::parameters_of(m) and std::meta::return_type_of(m), so the type information is accessible. The mechanical part of building an overload_cast expression is feasible. The harder problem is the Python API design question: when two C++ overloads exist, should they map to the same Python name (using pybind11’s overloading mechanism) or different names? Reflection cannot answer that. It knows that two overloads exist; it cannot know which Python API would be more useful.
Staletić’s write-up describes this as one of the sharper friction points. The generator either needs to expose all overloads under the same Python name (which works but can confuse Python users expecting a single dispatch) or generate disambiguated names mechanically (which produces a poor API). Neither is satisfying without a way for the C++ author to annotate intent.
Default Arguments Are Not Reflected
One of pybind11’s more valuable features is exposing C++ default arguments to Python callers:
cls.def("process", &T::process,
py::arg("input"),
py::arg("flags") = ProcessFlags::Default,
py::arg("timeout_ms") = 5000);
P2996 explicitly does not reflect default argument expressions. You can determine from the reflection data that a parameter has a default (there is a predicate for this), but you cannot extract the value of that default. The information exists in the AST during parsing but is not surfaced through the std::meta query API.
Libclang-based tools like binder handle this by operating over the full Clang AST, where default expressions are available as subtrees that can be serialized back to source text. That is a different architecture: a separate build-time tool that reads headers and writes .cpp files rather than a library template that runs during compilation. Each approach has different tradeoffs around build system integration and incremental compilation, but the AST-based approach wins on completeness for this specific feature.
The Semantic Layer Reflection Cannot Reach
The deeper limitation is that reflection queries structure, not intention. The meta::info for a member function encodes its signature but says nothing about ownership conventions, Python-side lifecycle, or API stability.
pybind11’s return value policies exist because raw pointer return types do not encode their ownership semantics. A function returning const Config* might be returning a pointer to an internally owned singleton (policy: reference), a pointer that the caller should delete (policy: take_ownership), or something whose lifetime is tied to the bound object (policy: reference_internal). Reflection sees a pointer return type; choosing the right policy requires understanding the function’s contract.
User-defined attributes, the subject of ongoing proposals like P1854, would provide a path forward:
[[py::return_policy(reference)]]
const Config* get_config() const;
Reflection-based binding code could read this attribute and emit the correct policy. P2996 alone does not include attribute reflection as a standard feature, though the experimental implementation has extension points. This is the specific gap where the automatic approach still requires falling back to manual annotation, or an escape hatch for individual methods.
What the Experiment Reveals
The pybind11 problem ends up demonstrating that C++26 reflection’s practical ceiling is roughly 70-80% automation for a typical mixed C++ class. Data members, simple methods, and enumerations are essentially fully covered. Overloads, defaults, and ownership semantics require input that the language’s type system does not currently carry.
That fraction represents genuine engineering value. For a C++ library with hundreds of classes, eliminating the mechanical portion of binding maintenance is significant even if some annotation remains. The pattern Staletić arrived at after a month of iteration is a hybrid: reflection handles the structural layer, and a small set of attributes or manual override hooks handles the semantic layer.
D’s __traits and static foreach reached the same ceiling years earlier, and the D ecosystem settled into roughly the same pattern. The libraries that use D’s reflection most effectively treat it as covering the common case with a defined extension point for everything else. C++ is arriving at the same conclusion through a month of experiments rather than years of ecosystem evolution, which is at least more efficient.