Object ownership, continued¶
This section covers intrusive reference counting as an alternative to shared pointers, and it explains the nitty-gritty details of how shared and unique pointer conversion is implemented in nanobind.
Intrusive reference counting¶
nanobind provides a custom intrusive reference counting solution that
completely solves the issue of shared C++/Python object ownership, while
avoiding the overheads and complexities of traditional C++ shared pointers
(std::shared_ptr<T>
).
The main limitation is that it requires adapting the base class of an object hierarchy according to the needs of nanobind, which may not always be possible.
Motivation¶
Consider the following simple class with intrusive reference counting:
class Object {
public:
void inc_ref() const noexcept { ++m_ref_count; }
void dec_ref() const noexcept {
if (--m_ref_count == 0)
delete this;
}
private:
mutable std::atomic<size_t> m_ref_count { 0 };
};
It contains an atomic counter that stores the number of references. When the counter reaches zero, the object deallocates itself. Easy and efficient.
The advantage of over std::shared_ptr<T>
is that no separate control block
must be allocated. Technical band-aids like std::enable_shared_from_this<T>
can also be avoided, since the reference count is always found in the object
itself.
However, one issue that tends to arise when a type like Object
is
wrapped using nanobind is that there are now two separate reference counts
referring to the same object: one in Python’s PyObject
, and one in
Object
. This can lead to a problematic reference cycle:
Python’s
PyObject
needs to keep theObject
instance alive so that it can be safely passed to C++ functions.The C++
Object
may in turn need to keep thePyObject
alive. This is the case when a subclass uses trampolines (NB_TRAMPOLINE
,NB_OVERRIDE
) to catch C++ virtual function calls and potentially dispatch them to an overridden implementation in Python. In this case, the C++ instance needs to be able to perform a function call on its own Python object identity, which requires a reference.
The source of the problem is that there are two separate counters that try to reason about the reference count of one instance, which leads to an uncollectable inter-language reference cycle.
The solution¶
We can solve the problem by using just one counter:
if an instance lives purely on the C++ side, the
m_ref_count
field is used to reason about the number of references.The first time that an instance is exposed to Python (by being created from Python, or by being returned from a bound C++ function), lifetime management switches over to Python.
The file nanobind/intrusive/counter.h
includes an official sample implementation of this functionality. It contains an extra optimization to pack either
a reference counter or a pointer to a PyObject*
into a single
sizeof(void*)
-sized field.
The most basic interface, intrusive_counter
represents an atomic
counter that can be increased (via intrusive_counter::inc_ref()
) or
decreased (via intrusive_counter::dec_ref()
). When the counter
reaches zero, the object should be deleted, which dec_ref()
indicates by
returning true
.
In addition to this simple counting mechanism, ownership of the object can also
be transferred to Python (via intrusive_counter::set_self_py()
). In
this case, subsequent calls to inc_ref()
and dec_ref()
modify the
reference count of the underlying Python object.
To incorporate intrusive reference counting into your own project, you would
usually add an intrusive_counter
-typed member to the base class of an object
hierarchy and expose it as follows:
#include <nanobind/intrusive/counter.h>
class Object {
public:
void inc_ref() noexcept { m_ref_count.inc_ref(); }
bool dec_ref() noexcept { return m_ref_count.dec_ref(); }
// Important: must declare virtual destructor
virtual ~Object() = default;
void set_self_py(PyObject *self) noexcept {
m_ref_count.set_self_py(self);
}
private:
nb::intrusive_counter m_ref_count;
};
// Convenience function for increasing the reference count of an instance
inline void inc_ref(Object *o) noexcept {
if (o)
o->inc_ref();
}
// Convenience function for decreasing the reference count of an instance
// and potentially deleting it when the count reaches zero
inline void dec_ref(Object *o) noexcept {
if (o && o->dec_ref())
delete o;
}
Alternatively, you could also inherit from intrusive_base
, which
obviates the need for all of the above declarations:
class Object : public nb::intrusive_base {
public:
// ...
};
The main change in the bindings is that the base class must specify a
nb::intrusive_ptr
annotation to inform an instance
that lifetime management has been taken over by Python. This annotation is
automatically inherited by all subclasses. In the linked example, this is done
via the Object::set_self_py()
method that we can now call from the class
binding annotation:
nb::class_<Object>(
m, "Object",
nb::intrusive_ptr<Object>(
[](Object *o, PyObject *po) noexcept { o->set_self_py(po); }));
Also, somewhere in your binding initialization code, you must register Python
reference counting hooks with the intrusive reference counter class. This
allows its implementation of the code in nanobind/intrusive/counter.h
to
not depend on Python (this means that it can be used in projects where Python
bindings are an optional component).
nb::intrusive_init(
[](PyObject *o) noexcept {
nb::gil_scoped_acquire guard;
Py_INCREF(o);
},
[](PyObject *o) noexcept {
nb::gil_scoped_acquire guard;
Py_DECREF(o);
});
These counter.h
include file references several functions that must be
compiled somewhere inside the project, which can be accomplished by including
the following file from a single .cpp
file.
#include <nanobind/intrusive/counter.inl>
Having to call inc_ref()
and dec_ref()
many times to
perform manual reference counting in project code can quickly become tedious.
Nanobind also ships with a ref<T>
RAII helper class to
help with this.
#include <nanobind/intrusive/ref.h>
void foo() {
/// Assignment to ref<T> automatically increases the object's reference count
ref<MyObject> x = new MyObject();
// ref<T> can be used like a normal pointer
x->func();
} // <-- ref::~ref() calls dec_ref(), which deletes the now-unreferenced instance
When the file nanobind/intrusive/ref.h
is included following
nanobind/nanobind.h
, it also exposes a custom type caster to bind functions
taking or returning ref<T>
-typed values.
That’s it. If you use this approach, any potential issues involving shared pointers, return value policies, reference leaks with trampolines, etc., can be avoided from the beginning.
Unique pointers¶
The following continues the discussion of unique pointers in the introductory section on object ownership and provides detail on how unique pointer conversion is implemented by nanobind.
Whereas std::shared_ptr<..>
could abstract over details concerning
storage and the deletion mechanism, this is not possible in the simpler
std::unique_ptr<..>
, which means that some of those details leak into
the type signature.
When the user calls a C++ function taking an argument of type std::unique_ptr<T,
Deleter>
from Python, ownership of that object must be transferred from C++ to Python.
When
Deleter
isstd::default_delete<T>
(i.e., the default when noDeleter
is specified), this ownership transfer is only possible when the instance was originally created by a new expression within C++ and nanobind has taken over ownership (i.e., it was created by a function returning a raw pointerT *value
withrv_policy::take_ownership
, or a function returning astd::unique_ptr<T>
). This limitation exists because theDeleter
will execute the statementdelete value
when the unique pointer expires, causing undefined behavior when the object was allocated within Python (the problem here is that nanobind uses the Python memory allocator and furthermore co-locates Python and C++ object storage. A delete expression cannot be used in such a case). nanobind detects this, refuses unsafe conversions with aTypeError
and emits a separate warning.To enable ownership transfer under all conditions, nanobind provides a custom
Deleter
namednb::deleter<T>
that uses reference counting to keep the underlyingPyObject
alive during the lifetime of the unique pointer. Following this route requires changing function signatures so that they usestd::unique_ptr<T, nb::deleter<T>>
instead ofstd::unique_ptr<T>
. This custom deleter supports ownership by both C++ and Python and can be used in all situations.
In both cases, a Python object may continue to exist after ownership was
transferred to C++ side. nanobind marks this object as invalid: any
operations involving it will fail with a TypeError
. Reverse ownership
transfer at a later point will make it usable again.
Binding functions that return a std::unique_ptr<T, Deleter>
always
works: nanobind will then acquire or reacquire ownership of the object.
Deleters other than std::default_delete<T>
or nb::deleter<T>
are
not supported.