Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Major improvements to the std::function<> caster (roundtrips, cyclic GC) #95

Merged
merged 1 commit into from
Oct 25, 2022

Conversation

wjakob
Copy link
Owner

@wjakob wjakob commented Oct 25, 2022

This commit adds:

  • Roundtrip support! When a Python function has been wrapped in a std::function<>, subsequent conversion to a Python object will return the original Python function object.

    Note that this is the opposite of pybind11's std::function<> caster, where roundtrip support is implemented for C function pointers (which isn't possible in nanobind currently, and seems less useful in retrospect).

  • Building on the previous point, the following C++ snippet

    nb::object o = nb::cast(std_function_instance, nb::rv_policy::none); 

    can be used to attempt a conversion of a std::function<> instance into a Python object. This will either return the function object or an invalid (!o.is_valid()) object if the conversion fails.

What is the point of these changes? A useful feature of nanobind is that one can set callback methods on bound C++ instances that redirect control flow back to Python.

a = MyCppClass()
a.f = lambda x: ...

A major potential issue here are reference leaks. What if the lambda function assigned to a.f captures some variables from the surrounding environment, which in turn reference the instance a? Then we have a reference cycle that spans the Python <-> C++ boundary, and that whole set of objects will never be deleted.

Fortunately, Python provides a garbage collector that can collect such cycles, but we must provide it with further information so that it can properly do its job. It must be able to traverse the C++ instance to discover contained Python objects.

Below is a fully worked out example that realizes such a traversal using the newly added features.

// Type definition
struct MyCppClass {
    // A callback function that could be implemented in either language
    std::function<void(void>) f;
};

// Traversal method that may be invoked by Python's cyclic GC
int mycppclass_tp_traverse(PyObject *self, visitproc visit, void *arg) {
    MyCppClass *m = nb::inst_ptr<MyCppClass>(self);

    // If 'f' is a Python function object, then traverse it recursively
    nb::object f = nb::cast(m->f, nb::rv_policy::none);
    Py_VISIT(f.ptr());

    return 0;
}

// Callback to register additional type slots in the bindings
void mycppclass_type_callback(PyType_Slot **s) noexcept {
    *(*s)++ = { Py_tp_traverse, (void *) mycppclass_tp_traverse };
}

// .. binding code ...
nb::class_<MyCppClass>(m, "MyCppClass", nb::type_callback(mycppclass_type_callback))
    .def(nb::init<>())
    .def_readwrite("f", & MyCppClass::f);

This commit also adds an example of such a cycle to the test suite, which will fail if it cannot be garbage-collected.

- Roundtrip support! When a Python function has been wrapped in a
  ``std::function<>``, subsequent conversion to a Python object will
  return the original Python function object.

  Note that this is the opposite of pybind11's ``std::function<>``
  caster, where roundtrip support is implemented for C function pointers
  (which isn't possible in nanobind currently, and seems less useful in
  retrospect).

- Building on the previous point, the following C++ snippet
  ```cpp
  nb::object o = nb::cast(std_function_instance, nb::rv_policy::none);
  ```
  can be used to attempt a conversion of a ``std::function<>`` instance
  into a Python object. This will either return the function object or
  an invalid (``!o.is_valid()``) object if the conversion fails.

Why was this added? A useful feature of nanobind is that one can set
callback methods on bound C++ instances that redirect control flow
back to Python.

```python
a = MyCppClass()
a.f = lambda x: ...
```

A major potential issue here are reference leaks. What if the lambda
function assigned to ``a.f`` captures some variables from the
surrounding environment, which in turn reference the instance `a`? Then
we have a reference cycle that spans the Python <-> C++ boundary, and
that whole set of objects will never be deleted.

Fortunately, Python provides a garbage collector that can collect such
cycles, but we must provide it with further information so that it can
properly do its job. It must be able to traverse the C++ instance to
discover contained Python objects.

Below is a fully worked out example.

```cpp
// Type definition
struct MyCppClass {
    // A callback function that could be implemented in either language
    std::function<void(void>) f;
};

// Traversal method that may be invoked by Python's cyclic GC
int mycppclass_tp_traverse(PyObject *self, visitproc visit, void *arg) {
    MyCppClass *m = nb::cast<MyCppClass *>(nb::handle(self));
    if (m) {
        nb::object f = nb::cast(m->f, nb::rv_policy::none);

        // If 'f' is a Python function object, then traverse it recursively
        if (f.is_valid())
            Py_VISIT(f.ptr());
    }

    return 0;
};

// Callback to register additional type slots in the bindings
void mycppclass_type_callback = [](PyType_Slot **s) noexcept {
    *(*s)++ = { Py_tp_traverse, (void *) mycppclass_tp_traverse };
};

// .. binding code ...
nb::class_<MyCppClass>(m, "MyCppClass", nb::type_callback(mycppclass_type_callback))
    .def(nb::init<>())
    .def_readwrite("f", &FuncWrapper::f);
```

This commit also adds an example of such a cycle to the test suite,
which will fail if it cannot be garbage-collected.
@wjakob wjakob merged commit c12dcd5 into master Oct 25, 2022
wjakob added a commit that referenced this pull request Oct 25, 2022
…GC) (#95)

This commit adds:

- Roundtrip support! When a Python function has been wrapped in a
  ``std::function<>``, subsequent conversion to a Python object will
  return the original Python function object.

  Note that this is the opposite of pybind11's ``std::function<>``
  caster, where roundtrip support is implemented for C function pointers
  (which isn't possible in nanobind currently, and seems less useful in
  retrospect).

- Building on the previous point, the following C++ snippet
  ```cpp
  nb::object o = nb::cast(std_function_instance, nb::rv_policy::none);
  ```
  can be used to attempt a conversion of a ``std::function<>`` instance
  into a Python object. This will either return the function object or
  an invalid (``!o.is_valid()``) object if the conversion fails.

What is the objective of these changes? A useful feature of nanobind is
that one can set callback methods on bound C++ instances that redirect
control flow back to Python.

```python
a = MyCppClass()
a.f = lambda x: ...
```

A major potential issue here are reference leaks. What if the lambda
function assigned to ``a.f`` captures some variables from the
surrounding environment, which in turn reference the instance `a`? Then
we have a reference cycle that spans the Python <-> C++ boundary, and
that whole set of objects will never be deleted.

Fortunately, Python provides a garbage collector that can collect such
cycles, but we must provide it with further information so that it can
properly do its job. It must be able to traverse the C++ instance to
discover contained Python objects.

Below is a fully worked out example that realizes such a traversal using
the newly added features.

```cpp
// Type definition
struct MyCppClass {
    // A callback function that could be implemented in either language
    std::function<void(void>) f;
};

// Traversal method that may be invoked by Python's cyclic GC
int mycppclass_tp_traverse(PyObject *self, visitproc visit, void *arg) {
    MyCppClass *m = nb::cast<MyCppClass *>(nb::handle(self));
    if (m) {
        nb::object f = nb::cast(m->f, nb::rv_policy::none);

        // If 'f' is a Python function object, then traverse it recursively
        if (f.is_valid())
            Py_VISIT(f.ptr());
    }

    return 0;
};

// Callback to register additional type slots in the bindings
void mycppclass_type_callback = [](PyType_Slot **s) noexcept {
    *(*s)++ = { Py_tp_traverse, (void *) mycppclass_tp_traverse };
};

// .. binding code ...
nb::class_<MyCppClass>(m, "MyCppClass", nb::type_callback(mycppclass_type_callback))
    .def(nb::init<>())
    .def_readwrite("f", &MyCppClass::f);
```

This commit also adds an example of such a cycle to the test suite,
which will fail if it cannot be garbage-collected.
@wjakob wjakob deleted the function_caster branch October 25, 2022 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant