Skip to content

Commit 534fd8c

Browse files
authored
Better handling for Python subclasses of types that use nb::new_() (#859)
* Better handling for Python subclasses of types that use nb::new_() In Python, `__new__` takes a first argument indicating the type of object it should create, which allows one to derive from classes that use `__new__` and obtain reasonable semantics. nanobind's `nb::new_()` wrapper currently ignores this argument, with somewhat surprising results: ``` // C++ struct Base { ... }; nb::class_<Base>(m, "Base").def(nb::new_(...))...; class Derived(mod.Base): pass >>> type(Derived()) is mod.Base True ``` This PR makes that case work more like it does in Python, so that `Derived()` produces an instance of `Derived`. This is possible safely because both `Base` and `Derived` believe they wrap the same C++ type. Since we can't easily intervene before the `Base` object is created, we use a call policy to intervene afterwards, and return an object of type `Derived` that wraps a `Base*` without ownership. The original `Base` object's lifetime is maintained via a keep-alive from the returned wrapper. There is not much actual code, but it's subtle so the comments are pretty long. This feature requires allowing a call policy's postcall hook to modify the return value, which was not previously supported. I chose to implement this by allowing the postcall hook to take the return value via `PyObject*&`; then a passed `PyObject*` can initialize either that or to the documented `handle`. I didn't document this new capability, because it's somewhat obscure and easy to mess up the reference counting; I figure anyone that really needs it will be able to figure it out. An alternative if you don't want to add this ability to call policies in general would be to define a new function attribute just for the new-fixup case, but that felt more invasive to me.
1 parent 28d93fa commit 534fd8c

File tree

6 files changed

+93
-5
lines changed

6 files changed

+93
-5
lines changed

docs/changelog.rst

+9
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ Version TBD (not yet released)
5656
<https://github.com/wjakob/nanobind/pull/847>`__, commit `b95eb7
5757
<https://github.com/wjakob/nanobind/commit/b95eb755b5a651a40562002be9ca8a4c6bf0acb9>`__).
5858

59+
- It is now possible to create Python subclasses of C++ classes that
60+
define their constructor bindings using :cpp:struct:`nb::new_() <new_>`.
61+
Previously, attempting to instantiate such a Python subclass would instead
62+
produce an instance of the base C++ type. Note that it is still not possible
63+
to override virtual methods in such a Python subclass, because the object
64+
returned by the :cpp:struct:`new_() <new_>` constructor will generally
65+
not be an instance of the alias/trampoline type.
66+
(PR `#859 <https://github.com/wjakob/nanobind/pull/859>`__)
67+
5968
Version 2.4.0 (Dec 6, 2024)
6069
---------------------------
6170

docs/classes.rst

+23
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,29 @@ type object that Python passes as the first argument of ``__new__``
11041104
``.def_static("__new__", ...)`` and matching ``.def("__init__", ...)``
11051105
yourself.
11061106

1107+
Two limitations of :cpp:struct:`nb::new_ <new_>` are worth noting:
1108+
1109+
* The possibilities for Python-side inheritance from C++ classes that
1110+
are bound using :cpp:struct:`nb::new_ <new_>` constructors are substantially
1111+
reduced. Simple inheritance situations (``class PyPet(Pet): ...``) should
1112+
work OK, but you can't :ref:`override virtual functions <trampolines>`
1113+
in Python (because the C++ object returned by :cpp:struct:`new_ <new_>`
1114+
doesn't contain the Python trampoline glue), and if :cpp:struct:`new_ <new_>`
1115+
is used to implement a polymorphic factory (like if ``Pet::make()`` could
1116+
return an instance of ``Cat``) then Python-side inheritance won't work at all.
1117+
1118+
* A given C++ class must expose all of its constructors via ``__new__`` or
1119+
all via ``__init__``, rather than a mixture of the two.
1120+
The only case where a class should bind both of these methods is
1121+
if the ``__init__`` methods are all stubs that do nothing.
1122+
This is because nanobind internally optimizes object instantiation by
1123+
caching the method that should be used for constructing instances of each
1124+
given type, and that optimization doesn't support trying both methods.
1125+
If you really need to combine nontrivial ``__new__`` and nontrivial
1126+
``__init__`` in the same type, you can disable the optimization by
1127+
defining a :ref:`custom type slot <typeslots>` of ``Py_tp_new`` or
1128+
``Py_tp_init``.
1129+
11071130
.. note::
11081131

11091132
Unpickling an object of type ``Foo`` normally requires that

docs/faq.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ Stable ABI extensions are convenient because they can be reused across Python
379379
versions, but this unfortunately only works on Python 3.12 and newer. Nanobind
380380
crucially depends on several `features
381381
<https://docs.python.org/3/whatsnew/3.12.html#c-api-changes>`__ that were added
382-
in version 3.12 (specifically, `PyType_FromMetaclass()`` and limited API
382+
in version 3.12 (specifically, ``PyType_FromMetaclass()`` and limited API
383383
bindings of the vector call protocol).
384384

385385
Policy on Clang-Tidy, ``-Wpedantic``, etc.

include/nanobind/nb_attr.h

+2-2
Original file line numberDiff line numberDiff line change
@@ -459,11 +459,11 @@ process_postcall(PyObject **args, std::integral_constant<size_t, NArgs>,
459459
template <size_t NArgs, typename Policy>
460460
NB_INLINE void
461461
process_postcall(PyObject **args, std::integral_constant<size_t, NArgs> nargs,
462-
PyObject *result, call_policy<Policy> *) {
462+
PyObject *&result, call_policy<Policy> *) {
463463
// result_guard avoids leaking a reference to the return object
464464
// if postcall throws an exception
465465
object result_guard = steal(result);
466-
Policy::postcall(args, nargs, handle(result));
466+
Policy::postcall(args, nargs, result);
467467
result_guard.release();
468468
}
469469

include/nanobind/nb_class.h

+37-2
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,39 @@ namespace detail {
416416
}
417417
}
418418
}
419+
420+
// Call policy that ensures __new__ returns an instance of the correct
421+
// Python type, even when deriving from the C++ class in Python
422+
struct new_returntype_fixup_policy {
423+
static inline void precall(PyObject **, size_t,
424+
detail::cleanup_list *) {}
425+
NB_NOINLINE static inline void postcall(PyObject **args, size_t,
426+
PyObject *&ret) {
427+
handle type_requested = args[0];
428+
if (ret == nullptr || !type_requested.is_type())
429+
return; // somethign strange about this call; don't meddle
430+
handle type_created = Py_TYPE(ret);
431+
if (type_created.is(type_requested))
432+
return; // already created the requested type so no fixup needed
433+
434+
if (type_check(type_created) &&
435+
PyType_IsSubtype((PyTypeObject *) type_requested.ptr(),
436+
(PyTypeObject *) type_created.ptr()) &&
437+
type_info(type_created) == type_info(type_requested)) {
438+
// The new_ constructor returned an instance of a bound type T.
439+
// The user wanted an instance of some python subclass S of T.
440+
// Since both wrap the same C++ type, we can satisfy the request
441+
// by returning a pyobject of type S that wraps a C++ T*, and
442+
// handling the lifetimes by having that pyobject keep the
443+
// already-created T pyobject alive.
444+
object wrapper = inst_reference(type_requested,
445+
inst_ptr<void>(ret),
446+
/* parent = */ ret);
447+
handle(ret).dec_ref();
448+
ret = wrapper.release().ptr();
449+
}
450+
}
451+
};
419452
}
420453

421454
template <typename Func, typename Sig = detail::function_signature_t<Func>>
@@ -446,13 +479,15 @@ struct new_<Func, Return(Args...)> {
446479
return func_((detail::forward_t<Args>) args...);
447480
};
448481

482+
auto policy = call_policy<detail::new_returntype_fixup_policy>();
449483
if constexpr ((std::is_base_of_v<arg, Extra> || ...)) {
450484
// If any argument annotations are specified, add another for the
451485
// extra class argument that we don't forward to Func, so visible
452486
// arg() annotations stay aligned with visible function arguments.
453-
cl.def_static("__new__", std::move(wrapper), arg("cls"), extra...);
487+
cl.def_static("__new__", std::move(wrapper), arg("cls"), extra...,
488+
policy);
454489
} else {
455-
cl.def_static("__new__", std::move(wrapper), extra...);
490+
cl.def_static("__new__", std::move(wrapper), extra..., policy);
456491
}
457492
cl.def("__init__", [](handle, Args...) {}, extra...);
458493
}

tests/test_classes.py

+21
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,27 @@ def test46_custom_new():
901901
assert t.NewStar("hi", "lo", value=10).value == 12
902902
assert t.NewStar(value=10, other="blah").value == 20
903903

904+
# Make sure a Python class that derives from a C++ class that uses
905+
# nb::new_() can be instantiated producing the correct Python type
906+
class FancyInt(t.UniqueInt):
907+
@staticmethod
908+
def the_answer():
909+
return 42
910+
911+
@property
912+
def value_as_string(self):
913+
return str(self.value())
914+
915+
f1 = FancyInt(10)
916+
f2 = FancyInt(20)
917+
# The derived-type wrapping doesn't preserve Python identity...
918+
assert f1 is not FancyInt(10)
919+
# ... but does preserve C++ identity
920+
assert f1.lookups() == u4.lookups() == 3 # u4, f1, and anonymous
921+
assert f1.the_answer() == f2.the_answer() == 42
922+
assert f1.value_as_string == "10"
923+
assert f2.value_as_string == "20"
924+
904925
def test47_inconstructible():
905926
with pytest.raises(TypeError, match="no constructor defined"):
906927
t.Foo()

0 commit comments

Comments
 (0)