fix: bind noexcept and ref-qualified methods from unregistered base classes (#5992)

* Strip noexcept from cpp17 function type bindings

* Fix a bug and increase test coverage

* Does this fix it?

* Silence clang-tidy issue

* Simplify method adapter with macro and add missing rvalue adaptors + tests

* Supress clang-tidy errors

* Improve test coverage

* Add additional static assert

* Try to resolve MSVC C4003 warning

* Simplify method adaptor into 2 template instatiations with enable_if_t

* Fix ambiguous STL template

* Close remaining qualifier consistency gaps for member pointer bindings.

A production-code review after #2234 showed that ref-qualified member pointers were still inconsistently handled across def_buffer, vectorize, and overload_cast, so this adds the missing overloads with focused tests for each newly-supported signature.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify why def_buffer/vectorize omit rvalue-qualified overloads.

These comments were added while reviewing the qualifier coverage follow-up, to document that buffer/vectorized calls operate on existing Python-owned instances and should not move-from self.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add compile-only overload_cast guard for ref-qualified methods.

This was added as a maintenance follow-up to the qualifier-consistency work, so future changes that introduce overload_cast ambiguity or wrong ref/noexcept resolution fail at compile time.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refactor overload_cast_impl qualifier overloads with a macro.

As part of the qualifier-consistency maintenance follow-up, this reduces duplication in overload_cast_impl while preserving the same ref/noexcept coverage and keeping pedantic-clean macro expansion.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Expose __cpp_noexcept_function_type to Python tests and use explicit skip guards.

This replaces hasattr-based optional assertions with skipif-gated noexcept-only tests so skipped coverage is visible in pytest output while keeping non-noexcept checks always active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add static_assert in method_adaptor to guard that T is a member function pointer.

Suggested by @Skylion007 in PR #5992 review comment [T007].

Made-with: Cursor

* automatic clang-format change (because of #6002)

---------

Co-authored-by: Ralf W. Grosse-Kunstleve <rgrossekunst@nvidia.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Aaron Gokaslan
2026-03-26 19:21:32 -04:00
committed by Ralf W. Grosse-Kunstleve
parent c0cfa96555
commit 3cb5a763c1
10 changed files with 1015 additions and 21 deletions

View File

@@ -167,6 +167,123 @@ public:
double sum() const { return rw_value + ro_value; }
};
// Issue #2234: noexcept methods in an unregistered base should be bindable on the derived class.
// In C++17, noexcept is part of the function type, so &Derived::method resolves to
// a Base member function pointer with noexcept, requiring explicit template specializations.
class NoexceptUnregisteredBase {
public:
// Exercises cpp_function(Return (Class::*)(Args...) const noexcept, ...)
int value() const noexcept { return m_value; }
// Exercises cpp_function(Return (Class::*)(Args...) noexcept, ...)
void set_value(int v) noexcept { m_value = v; }
// Exercises cpp_function(Return (Class::*)(Args...) & noexcept, ...)
void increment() & noexcept { ++m_value; }
// Exercises cpp_function(Return (Class::*)(Args...) const & noexcept, ...)
int capped_value() const & noexcept { return m_value < 100 ? m_value : 100; }
private:
int m_value = 99;
};
class NoexceptDerived : public NoexceptUnregisteredBase {
public:
using NoexceptUnregisteredBase::NoexceptUnregisteredBase;
};
// Exercises cpp_function(Return (Class::*)(Args...) &&, ...) and
// cpp_function(Return (Class::*)(Args...) const &&, ...) via an unregistered base.
class RValueRefUnregisteredBase {
public:
// Exercises cpp_function(Return (Class::*)(Args...) &&, ...).
// Moves m_payload to verify that std::move(*c).*f is used in the lambda body.
std::string take() && { // NOLINT(readability-make-member-function-const)
return std::move(m_payload);
}
// Exercises cpp_function(Return (Class::*)(Args...) const &&, ...)
int peek() const && { return m_value; }
#ifdef __cpp_noexcept_function_type
// Exercises cpp_function(Return (Class::*)(Args...) && noexcept, ...)
std::string take_noexcept() && noexcept { // NOLINT(readability-make-member-function-const)
return std::move(m_payload);
}
// Exercises cpp_function(Return (Class::*)(Args...) const && noexcept, ...)
int peek_noexcept() const && noexcept { return m_value; }
#endif
private:
int m_value = 77;
std::string m_payload{"rref_payload"};
};
class RValueRefDerived : public RValueRefUnregisteredBase {
public:
using RValueRefUnregisteredBase::RValueRefUnregisteredBase;
};
// Exercises overload_cast with noexcept member function pointers (issue #2234).
// In C++17, overload_cast must have noexcept variants to resolve noexcept overloads.
struct NoexceptOverloaded {
py::str method(int) noexcept { return "(int)"; }
py::str method(int) const noexcept { return "(int) const"; }
py::str method(float) noexcept { return "(float)"; }
};
// Exercises overload_cast with noexcept free function pointers.
int noexcept_free_func(int x) noexcept { return x + 1; }
int noexcept_free_func(float x) noexcept { return static_cast<int>(x) + 2; }
// Exercises overload_cast with ref-qualified member function pointers.
struct RefQualifiedOverloaded {
py::str method(int) & { return "(int) &"; }
py::str method(int) const & { return "(int) const &"; }
py::str method(float) && { return "(float) &&"; }
py::str method(float) const && { return "(float) const &&"; }
#ifdef __cpp_noexcept_function_type
py::str method(long) & noexcept { return "(long) & noexcept"; }
py::str method(long) const & noexcept { return "(long) const & noexcept"; }
py::str method(double) && noexcept { return "(double) && noexcept"; }
py::str method(double) const && noexcept { return "(double) const && noexcept"; }
#endif
};
// Compile-only guard: catch overload_cast resolution regressions/ambiguities early.
using RefQualifiedOverloadedIntCast = py::detail::overload_cast_impl<int>;
using RefQualifiedOverloadedFloatCast = py::detail::overload_cast_impl<float>;
static_assert(
std::is_same<decltype(RefQualifiedOverloadedIntCast{}(&RefQualifiedOverloaded::method)),
py::str (RefQualifiedOverloaded::*)(int) &>::value,
"");
static_assert(std::is_same<decltype(RefQualifiedOverloadedIntCast{}(
&RefQualifiedOverloaded::method, py::const_)),
py::str (RefQualifiedOverloaded::*)(int) const &>::value,
"");
static_assert(
std::is_same<decltype(RefQualifiedOverloadedFloatCast{}(&RefQualifiedOverloaded::method)),
py::str (RefQualifiedOverloaded::*)(float) &&>::value,
"");
static_assert(std::is_same<decltype(RefQualifiedOverloadedFloatCast{}(
&RefQualifiedOverloaded::method, py::const_)),
py::str (RefQualifiedOverloaded::*)(float) const &&>::value,
"");
#ifdef __cpp_noexcept_function_type
using RefQualifiedOverloadedLongCast = py::detail::overload_cast_impl<long>;
using RefQualifiedOverloadedDoubleCast = py::detail::overload_cast_impl<double>;
static_assert(
std::is_same<decltype(RefQualifiedOverloadedLongCast{}(&RefQualifiedOverloaded::method)),
py::str (RefQualifiedOverloaded::*)(long) & noexcept>::value,
"");
static_assert(std::is_same<decltype(RefQualifiedOverloadedLongCast{}(
&RefQualifiedOverloaded::method, py::const_)),
py::str (RefQualifiedOverloaded::*)(long) const & noexcept>::value,
"");
static_assert(
std::is_same<decltype(RefQualifiedOverloadedDoubleCast{}(&RefQualifiedOverloaded::method)),
py::str (RefQualifiedOverloaded::*)(double) && noexcept>::value,
"");
static_assert(std::is_same<decltype(RefQualifiedOverloadedDoubleCast{}(
&RefQualifiedOverloaded::method, py::const_)),
py::str (RefQualifiedOverloaded::*)(double) const && noexcept>::value,
"");
#endif
// Test explicit lvalue ref-qualification
struct RefQualified {
int value = 0;
@@ -498,6 +615,161 @@ TEST_SUBMODULE(methods_and_attributes, m) {
= decltype(py::method_adaptor<RegisteredDerived>(&RegisteredDerived::do_nothing));
static_assert(std::is_same<Adapted, void (RegisteredDerived::*)() const>::value, "");
// test_noexcept_base (issue #2234)
// In C++17, noexcept is part of the function type. Binding a noexcept method from an
// unregistered base class must resolve `self` to the derived type, not the base type.
py::class_<NoexceptDerived>(m, "NoexceptDerived")
.def(py::init<>())
// cpp_function(Return (Class::*)(Args...) const noexcept, ...)
.def("value", &NoexceptDerived::value)
// cpp_function(Return (Class::*)(Args...) noexcept, ...)
.def("set_value", &NoexceptDerived::set_value)
// cpp_function(Return (Class::*)(Args...) & noexcept, ...)
.def("increment", &NoexceptDerived::increment)
// cpp_function(Return (Class::*)(Args...) const & noexcept, ...)
.def("capped_value", &NoexceptDerived::capped_value);
// test_rvalue_ref_qualified_methods: rvalue-ref-qualified methods from an unregistered base.
// method_adaptor must rebind &&/const&& member pointers to the derived type.
py::class_<RValueRefDerived>(m, "RValueRefDerived")
.def(py::init<>())
// cpp_function(Return (Class::*)(Args...) &&, ...)
.def("take", &RValueRefDerived::take)
// cpp_function(Return (Class::*)(Args...) const &&, ...)
.def("peek", &RValueRefDerived::peek)
#ifdef __cpp_noexcept_function_type
// cpp_function(Return (Class::*)(Args...) && noexcept, ...)
.def("take_noexcept", &RValueRefDerived::take_noexcept)
// cpp_function(Return (Class::*)(Args...) const && noexcept, ...)
.def("peek_noexcept", &RValueRefDerived::peek_noexcept)
#endif
;
// Verify that &&-qualified methods cannot be called on lvalues, only on rvalues.
// This confirms that the cpp_function lambda must use std::move(*c).*f, not c->*f.
#if __cplusplus >= 201703L
static_assert(!std::is_invocable<std::string (RValueRefUnregisteredBase::*)() &&,
RValueRefUnregisteredBase &>::value,
"&&-qualified method must not be callable on lvalue");
static_assert(std::is_invocable<std::string (RValueRefUnregisteredBase::*)() &&,
RValueRefUnregisteredBase &&>::value,
"&&-qualified method must be callable on rvalue");
#endif
// Verify method_adaptor preserves &&/const&& qualifiers when rebinding.
using AdaptedRRef = decltype(py::method_adaptor<RValueRefDerived>(&RValueRefDerived::take));
static_assert(std::is_same<AdaptedRRef, std::string (RValueRefDerived::*)() &&>::value, "");
using AdaptedConstRRef
= decltype(py::method_adaptor<RValueRefDerived>(&RValueRefDerived::peek));
static_assert(std::is_same<AdaptedConstRRef, int (RValueRefDerived::*)() const &&>::value, "");
#ifdef __cpp_noexcept_function_type
// method_adaptor must also handle noexcept member function pointers (issue #2234).
// Verify the noexcept specifier is preserved in the resulting Derived pointer type.
using AdaptedConstNoexcept
= decltype(py::method_adaptor<NoexceptDerived>(&NoexceptDerived::value));
static_assert(
std::is_same<AdaptedConstNoexcept, int (NoexceptDerived::*)() const noexcept>::value, "");
using AdaptedNoexcept
= decltype(py::method_adaptor<NoexceptDerived>(&NoexceptDerived::set_value));
static_assert(std::is_same<AdaptedNoexcept, void (NoexceptDerived::*)(int) noexcept>::value,
"");
using AdaptedRRefNoexcept
= decltype(py::method_adaptor<RValueRefDerived>(&RValueRefDerived::take_noexcept));
static_assert(std::is_same < AdaptedRRefNoexcept,
std::string (RValueRefDerived::*)() && noexcept > ::value,
"");
using AdaptedConstRRefNoexcept
= decltype(py::method_adaptor<RValueRefDerived>(&RValueRefDerived::peek_noexcept));
static_assert(std::is_same < AdaptedConstRRefNoexcept,
int (RValueRefDerived::*)() const && noexcept > ::value,
"");
#endif
// test_noexcept_overload_cast (issue #2234)
// overload_cast must have noexcept operator() overloads so it can resolve noexcept methods.
#ifdef PYBIND11_OVERLOAD_CAST
py::class_<NoexceptOverloaded>(m, "NoexceptOverloaded")
.def(py::init<>())
// overload_cast_impl::operator()(Return (Class::*)(Args...) noexcept, false_type)
.def("method", py::overload_cast<int>(&NoexceptOverloaded::method))
// overload_cast_impl::operator()(Return (Class::*)(Args...) const noexcept, true_type)
.def("method_const", py::overload_cast<int>(&NoexceptOverloaded::method, py::const_))
// overload_cast_impl::operator()(Return (Class::*)(Args...) noexcept, false_type) float
.def("method_float", py::overload_cast<float>(&NoexceptOverloaded::method));
// overload_cast_impl::operator()(Return (*)(Args...) noexcept)
m.def("noexcept_free_func", py::overload_cast<int>(noexcept_free_func));
m.def("noexcept_free_func_float", py::overload_cast<float>(noexcept_free_func));
py::class_<RefQualifiedOverloaded>(m, "RefQualifiedOverloaded")
.def(py::init<>())
// overload_cast_impl::operator()(Return (Class::*)(Args...) &, false_type)
.def("method_lref", py::overload_cast<int>(&RefQualifiedOverloaded::method))
// overload_cast_impl::operator()(Return (Class::*)(Args...) const &, true_type)
.def("method_const_lref",
py::overload_cast<int>(&RefQualifiedOverloaded::method, py::const_))
// overload_cast_impl::operator()(Return (Class::*)(Args...) &&, false_type)
.def("method_rref", py::overload_cast<float>(&RefQualifiedOverloaded::method))
// overload_cast_impl::operator()(Return (Class::*)(Args...) const &&, true_type)
.def("method_const_rref",
py::overload_cast<float>(&RefQualifiedOverloaded::method, py::const_))
# ifdef __cpp_noexcept_function_type
// overload_cast_impl::operator()(Return (Class::*)(Args...) & noexcept, false_type)
.def("method_lref_noexcept", py::overload_cast<long>(&RefQualifiedOverloaded::method))
// overload_cast_impl::operator()(Return (Class::*)(Args...) const & noexcept, true_type)
.def("method_const_lref_noexcept",
py::overload_cast<long>(&RefQualifiedOverloaded::method, py::const_))
// overload_cast_impl::operator()(Return (Class::*)(Args...) && noexcept, false_type)
.def("method_rref_noexcept", py::overload_cast<double>(&RefQualifiedOverloaded::method))
// overload_cast_impl::operator()(Return (Class::*)(Args...) const && noexcept, true_type)
.def("method_const_rref_noexcept",
py::overload_cast<double>(&RefQualifiedOverloaded::method, py::const_))
# endif
;
#else
// Fallback using explicit static_cast for C++11/14
py::class_<NoexceptOverloaded>(m, "NoexceptOverloaded")
.def(py::init<>())
.def("method",
static_cast<py::str (NoexceptOverloaded::*)(int)>(&NoexceptOverloaded::method))
.def("method_const",
static_cast<py::str (NoexceptOverloaded::*)(int) const>(&NoexceptOverloaded::method))
.def("method_float",
static_cast<py::str (NoexceptOverloaded::*)(float)>(&NoexceptOverloaded::method));
m.def("noexcept_free_func", static_cast<int (*)(int)>(noexcept_free_func));
m.def("noexcept_free_func_float", static_cast<int (*)(float)>(noexcept_free_func));
py::class_<RefQualifiedOverloaded>(m, "RefQualifiedOverloaded")
.def(py::init<>())
.def("method_lref",
static_cast<py::str (RefQualifiedOverloaded::*)(int) &>(
&RefQualifiedOverloaded::method))
.def("method_const_lref",
static_cast<py::str (RefQualifiedOverloaded::*)(int) const &>(
&RefQualifiedOverloaded::method))
.def("method_rref",
static_cast<py::str (RefQualifiedOverloaded::*)(float) &&>(
&RefQualifiedOverloaded::method))
.def("method_const_rref",
static_cast<py::str (RefQualifiedOverloaded::*)(float) const &&>(
&RefQualifiedOverloaded::method))
# ifdef __cpp_noexcept_function_type
.def("method_lref_noexcept",
static_cast<py::str (RefQualifiedOverloaded::*)(long) & noexcept>(
&RefQualifiedOverloaded::method))
.def("method_const_lref_noexcept",
static_cast<py::str (RefQualifiedOverloaded::*)(long) const & noexcept>(
&RefQualifiedOverloaded::method))
.def("method_rref_noexcept",
static_cast < py::str (RefQualifiedOverloaded::*)(double)
&& noexcept > (&RefQualifiedOverloaded::method))
.def("method_const_rref_noexcept",
static_cast < py::str (RefQualifiedOverloaded::*)(double) const && noexcept
> (&RefQualifiedOverloaded::method))
# endif
;
#endif
// test_methods_and_attributes
py::class_<RefQualified>(m, "RefQualified")
.def(py::init<>())