rust-objc icon indicating copy to clipboard operation
rust-objc copied to clipboard

More ergonomic references

Open madsmtm opened this issue 3 years ago • 2 comments

Introduction

Hey, I've been thinking about how to make Objective-C's and Rust's memory models play nicer together.

Objective-C has two different "states" for references: Retained and autoreleased. In Rust there's the concept of ownership, which means we're the only place that holds a reference to that object - this is how we get safe mutability.

As such, in Rust, an Objective-C reference can be in a multitude of different "states":

  • Retained and owned
  • Retained and aliased
  • Autoreleased and owned
  • Autoreleased and aliased

In Rust we also have the borrow checker, so we can furthermore safely copy and return references without needing to retain and autorelease them first (provided they do not outlive the Objective-C reference).

The idea

I would like to propose an addition to objc that makes it possible to handle these references safely and ergonomically for users of the library. The idea is to add two smart pointers, Owned and Retained (names are up for bikeshedding), and modify the autoreleasepool function to carry some lifetime information (this would be the only breaking change, and could easily be ignored for people who don't want it).

See the following code snippets for a better explanation:

  • Retained and owned:

    /// Works a lot like `Box`
    struct Owned<T>(*const T);
    
    impl<T> Deref<Target = T> for Owned<T> {
        ...
    }
    // We can impl DerefMut because we're the only owner
    impl<T> DerefMut<Target = T> for Owned<T> {
        ...
    }
    
  • Retained and aliased:

    /// The generic version of StrongPtr, works a lot like `Arc`
    struct Retained<T>(*mut T);
    
    impl<T> Deref<Target = T> for Retained<T> {
        ...
    }
    
    impl<T> Clone for Retained<T> {
        ... // Increments the reference count
    }
    
    // We can't impl DerefMut, since there may be multiple references, and hence it would be unsafe to modify the data
    
  • Autoreleased and owned:

    fn autoreleasepool<T>(f: impl FnOnce(&AutoreleasePool) -> T) -> T {
        let pool = unsafe { AutoreleasePool::new() };
        f(&pool)
    }
    
    // The pool is not actually important, but the lifetime information it carries is!
    autoreleasepool(|&'p pool| {
        // The reference can be mutable because we've just created the object,
        // hence we're the only owner
        let x: &'p mut T = T::new().autorelease(pool);
    });
    
    // The lifetime prevents us from using the reference outside the pool:
    
    let mut x: Option<&mut T> = None;
    //  ----- `x` declared here, outside of the closure body
    autoreleasepool(|&'p pool| {
    //               -------- `pool` is a reference that is only valid in the closure body
        x = Some(T::new().autorelease(pool));
    //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `pool` escapes the closure body here
    });
    
  • Autoreleased and aliased:

    // Immutable version of "owned and autoreleased"
    autoreleasepool(|&'p pool| {
        let x: &'p T = T::get_from_somewhere_autoreleased(pool);
    });
    

Library authors would then be able to create functions like MyObject::new that would return an Owned<MyObject> and MyObject::get_from_somewhere that would take a &'p AutoreleasePool parameter, and return &'p MyObject.

For some of these things to be sound we'll need some nightly features or debug assertions (probably nightly features under a feature flag, debug assertions if not), but I think it's doable.

Outro

I'll be brewing a few PRs in the coming days, but would you, @SSheldon, welcome this addition? (Of course, it'll need modifications)

As a real-world usecase, I know there are several minor memory leaks in the MacOS backend of winit, but I haven't bothered to fix them because I know they will appear again after a refactor. If objc had some better mechanism for keeping track of references, like the one described in this issue (or if someone has a better idea), we would be able to eliminate most of these issues.

madsmtm avatar May 27 '21 11:05 madsmtm

A few more examples of usage:

struct MyObject {
    _data: [u8; 0], // Should maybe be UnsafeCell?
}
// On nightly:
extern type MyObject;

let obj: Owned<MyObject> = unsafe { Owned::new(msg_send![class!(MyObject), new]) };
let normal_reference: &MyObject = &*obj;
let mutable_reference: &mut MyObject = &mut *obj;
autoreleasepool(move |pool| {
    let autoreleased_reference: &mut MyObject = retained.autorelease(pool);
    // The reference lasts until here, and is deallocated afterwards
});

fn get_from_somewhere<'p>(pool: &'p AutoreleasePool) -> &'p MyObject {
    unsafe {
        let obj: *const MyObject = msg_send![class!(MyObject), get_from_somewhere];
        &*obj
    }
}

let retained: Retained<MyObject> = autoreleasepool(|pool| {
    let obj: &MyObject = get_from_somewhere(pool);
    Retained::retain(obj)
});

madsmtm avatar May 27 '21 11:05 madsmtm

After making this I discovered objc-id, which does much of what I wanted, and https://github.com/SSheldon/rust-objc/issues/24 which details why this is a separate crate - but there's still some improvements I'd like to make (like the autoreleasepool lifetime trick), so I'll keep this open.

madsmtm avatar Jun 03 '21 16:06 madsmtm