Skip to content

Basic object abstraction #5

Description

@emmatyping

We need a basic abstraction over the object layer.

This includes:

  1. PyObject - abstract objects
  2. str, bytes, list, tuple, etc. - concrete objects
  3. Conversion between concrete and abstract objects

Creating custom objects (classes) is a separate task

Things to keep in mind:

  1. since a PyObject can be mutated by C code anywhere at any time, we can never have an &mut PyObject.
  2. since & references are immutable we need to specify that the object abstraction has interior mutability

Therefore I propose we use the definition from PyO3:
https://github.com/PyO3/pyo3/blob/ab7b0d303be0c3948866519583dd32332f6c3d22/src/types/any.rs#L33
i.e. a transparent wrapper around UnsafeCell<ffi::PyObject>

Activity

  1. emmatyping commented on Dec 8, 2025

    @emmatyping
    Author

    Oh there's also details like owned and borrowed references to consider in the overall design.

  2. davidhewitt commented on Dec 9, 2025

    @davidhewitt

    I'm not actually sure if the PyO3 definition of PyAny is strictly needed any more. In the past we had &PyAny references as the main API, where the actual structure layout and size did matter more (we would cast back and forth &PyAny <-> *mut ffi::PyObject.

    That design didn't work very well for conveying ownership, which is why we replaced &PyAny with the current Bound<'py, PyAny> smart pointer.

    One possible alternative is to have struct PyAny(*mut ffi:: PyObject), however the lack of lifetime to prove the attachment of the thread to interpreter makes reference counting more awkward.

  3. davidhewitt commented on Dec 9, 2025

    @davidhewitt

    As an aside, if reference counting operations were defined to be legal at any time, even without the thread attached, then the lifetime on the type wouldn't be necessary for refcounting (via Clone & Drop).

    I think in such a world all refcounting operations would be treated like "remote" thread decrefs on the free threaded build and not be reconciled until GC.

  4. emmatyping commented on Dec 9, 2025

    @emmatyping
    Author

    That design didn't work very well for conveying ownership, which is why we replaced &PyAny with the current Bound<'py, PyAny> smart pointer.

    One possible alternative is to have struct PyAny(*mut ffi:: PyObject), however the lack of lifetime to prove the attachment of the thread to interpreter makes reference counting more awkward.

    Interesting! Perhaps everything would be implemented for Bound<'py, PyAny> to make sure users are keeping to the safe APIs?

    I think that seems reasonable.

    Regarding

    As an aside, if reference counting operations were defined to be legal at any time, even without the thread attached, then the lifetime on the type wouldn't be necessary for refcounting (via Clone & Drop).

    I doubt this is possible, unless the free-threaded GC changes significantly. The current free-threaded GC relies on stop-the-world mechanisms that halt all attached thread states and that would have to change to handle this otherwise user refcounting could race with the GC.

  5. davidhewitt commented on Dec 9, 2025

    @davidhewitt

    I doubt this is possible, unless the free-threaded GC changes significantly.

    Indeed, it's a dream for the far future 😂

  6. davidhewitt commented on Dec 9, 2025

    @davidhewitt

    Interesting! Perhaps everything would be implemented for Bound<'py, PyAny> to make sure users are keeping to the safe APIs?

    I think this is exactly where PyO3 ended up, yes.

  7. emmatyping commented on Dec 9, 2025

    @emmatyping
    Author

    Also FWIW, I would love to see a concurrent free-threaded GC, which would open the door to unattached reference counting, I think.

  8. emmatyping commented on Dec 12, 2025

    @emmatyping
    Author

    One possible alternative is to have struct PyAny(*mut ffi:: PyObject), however the lack of lifetime to prove the attachment of the thread to interpreter makes reference counting more awkward.

    Hm, would PyAny<'py>(*mut ffi::PyObject, PhantomData<'py>) work? I think there might be issues about sending the object between thread states, right?

    Also, I wonder if we could use NonNull<ffi::PyObject> over *mut ffi::PyObject both because it means we are less likely to de-reference a null pointer if we make a mistake, and also because it would provide nice symmetry if we have a Result<PyAny, ExecutedErr> where ExecutedErr always becomes null, and PyAny always is a valid object. (see #8 for more discussion of an error handling API)

  9. youknowone commented on Dec 12, 2025

    @youknowone

    Because we must care about performance, NonNull will be helpful.

    @davidhewitt must have the best idea about the this topic. Could you share a few options if you design it again from scratch? Do you suggest to follow PyO3 Bound as the best option?

    I doubt about PyAny<'py>(*mut ffi::PyObject, PhantomData<'py>) a bit.

    There are 2 axis of abstraction about this.

    Concrete type is one. PyObject need to be downcasted as str, int and other types. Ownership is another one. PyAny must have an interface to be PyString

    Borrow type is another one. CPython represent both borrowed pointer and RC-ed pointer as PyObject*. Probably Bound and Py also be related because it is also about the type of reference. this version of PyAny cannot distinguish these properties.

    In PyO3, PyAny is struct PyAny(UnsafeCell<ffi::PyObject>) instead of *mut PyObject.
    Then both &'py PyAny or Bound<'py, PyAny> makes sense.

  10. davidhewitt commented on Dec 12, 2025

    @davidhewitt

    Hm, would PyAny<'py>(*mut ffi::PyObject, PhantomData<'py>) work? I think there might be issues about sending the object between thread states, right?

    Yes, an "unattached" form without the lifetime is often necessary (e.g. for holding references when not attached to a thread state, as you note). This is why we have the Bound<'py, PyAny> abstraction, we also have an unattached form without the lifetime which is Py<PyAny>.

    Because we must care about performance, NonNull will be helpful.

    @davidhewitt must have the best idea about the this topic. Could you share a few options if you design it again from scratch? Do you suggest to follow PyO3 Bound as the best option?

    100% we should use NonNull, it gives the Rust compiler opportunity to reuse the null pointer value e.g. for the "tag" of the result. My mistake for not writing this originally.

    With the current functionality available in the Rust language, yes, I believe this Py<T> / Bound<'py, T> smart pointer pair with the object type as a generic argument is still the best form in my opinion.

    Borrow type is another one. CPython represent both borrowed pointer and RC-ed pointer as PyObject*. Probably Bound and Py also be related because it is also about the type of reference. this version of PyAny cannot distinguish these properties.

    This is great to mention. PyO3 models this as a third smart pointer Borrowed<'a, 'py, T>. 'a is the lifetime the borrowed pointer is known to be valid for, e.g. borrow on a tuple item is easy to relate back to a smart pointer referring to the full tuple.

    It turns out that Borrowed<'a, 'py, T> is occasionally helpful compared to &'a Bound<'py, T> because:

    • Borrowed is just a pointer rather than a reference-to-pointer, needed for some cases in the Python C API.
    • the alternative &'a Bound<'py, T> adds an implicit 'py: 'a lifetime bound (because the reference lifetime 'a cannot be longer than the lifetime 'pyof the data it references).Borrowed<'a, 'py, T>` has no such implicit constraint, sometimes this is useful to implement valid code.

    Technically in PyO3's current design there is space for a hypothetical BorrowedUnattached<'a, T> smart pointer which does not have attachment to a Python thread, but that hasn't seen practical need so we have not implemented it.


    In a hypothetical future Rust language, I would LOVE there to be an alternative way to model thread attachment than with the Python<'py> token / lifetime polluting the types. I believe that an idea called "contexts" previously most discussed in the space of async runtimes could provide a way to make certain functions only callable within a given context.

    In such a hypothetical future, I wonder if it would be possible to have only Py<T> and PyBorrowed<'a, T> and remove the lifetime-attached types.

    Probably not a practical solution for us right now, I would love to present this case to the Rust project at one of our future meetings.

  11. davidhewitt commented on Dec 12, 2025

    @davidhewitt

    Another possible problem is that many CPython internal APIs take the thread state directly, not fetching it from TLS. The Python<'py> token does not carry the actual thread state pointer, and I doubt we would want every Bound<'py, T> smart pointer to be a pair of (thread_state, object) pointers.

    PyO3 using the public API has not hit this problem (yet, subinterpreters might break it eventually).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions