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

Downgrading using Deref #58

Closed
13 tasks done
madsmtm opened this issue Nov 2, 2021 · 8 comments · Fixed by #234
Closed
13 tasks done

Downgrading using Deref #58

madsmtm opened this issue Nov 2, 2021 · 8 comments · Fixed by #234
Labels
enhancement New feature or request
Milestone

Comments

@madsmtm
Copy link
Owner

madsmtm commented Nov 2, 2021

We often want to send messages to methods defined on the superclass; in Objective-C this is automatically supported by the runtime, and we utilize this in objc2_foundation by creating traits for each superclass INSObject, INSString, ... and implement them for our object.

The problems

  • Usage is more cumbersome for the user, since they have to import both the object they want to use and all the traits for the superclasses. E.g. when using NSMutableArray you often have to do:

    use objc2_foundation::{NSMutableArray, INSMutableArray, INSArray, INSObject};

    (Could be solved by adding a prelude module which exports these traits).

  • When defining functions taking an object the creator has to remember to create a generic function over the helper trait, instead of just taking the object they were interested in:

    use objc2_foundation::{INSObject, INSString, NSString};
    
    fn my_fn(obj: &impl INSObject) { // Instead of just `obj: &NSObject`
        println!("{}", obj.description())
    }
    
    let obj = NSString::new();
    my_fn(&obj);
  • The traits contain default functions (e.g. INSObject::description), which are then made generic on use (the compiler creates NSObject::description, NSString::description, NSArray::description, ...). These functions are identical however, leading to an increase in code-size.

The solution

Rust does not have inheritance, but it has something that can emulate it; Deref/DerefMut! Using these, we can "downgrade" objects, e.g. convert &NSMutableString to &NSString, and that to &NSObject, and use methods defined on NSObject.

This is not a new idea, fruity uses this approach, see its subclass! and objc_subclass! macros, though they don't create mutable references to objects. objrs does it as well in their #[objrs(class, ...)] macro.

Safety

Since objects are always just pointers into heap, and msg_send! doesn't care if the given pointer has one type or the other, the pointer casting part should be safe. However, we also have to ensure Rust's safety invariants are upheld, especially in relation to unique/aliased references and lifetimes.

Notable is that NSObject::new wouldn't be callable from NSString, so you at least can't end up with thinking you have a NSString when the actual class is NSObject.

Some cases to consider:

  • Is &NSString -> &NSObject safe?
    • Yes NSString can be used in exactly the same way as an NSObject can. Also, implementing INSObject for NSString has the same effect.
    • Note that NSObject::new is not callable from NSString, so we can't accidentally create an Id<NSString, Owned> that way
    • NSObject::copy would not be present because not all NSObjects implement NSCopying, but it would be safe in this case (because NSString implement NSCopying).
    • We have INSObject::Ownership right now, which other things (NSArray?) might make extra assumptions from; we should perhaps move it to NSCopying::Ownership? Done in Remove INSObject::Ownership #90.
    • Note also that you could have two different variables at the same time, x: &NSString and y: &NSObject, pointing to the same object.
  • Is &mut NSString -> &mut NSObject safe?
    • Yes. Mutability does not change anything in this consideration, since the lifetime of &mut NSObject is tied to the original lifetime (and there is no way to get e.g. Id<NSObject, Owned> from that safely!)
  • Is &NSArray<T, O> -> &NSObject safe?
    • Type information is discarded!
    • But the lifetime of T is still present in the returned reference.
  • Is &NSMutableArray<T, O> -> &NSArray<T, O> safe?
    • Yes, the type parameters have the same semantic meaning.
    • See also the NSMutableString -> NSString discussion.
  • Is Id<NSString, Shared> -> Id<NSObject, Shared> safe?
    • Equivalent to the first point, except there is no lifetime carried over. NSString is 'static anyway, so this is safe.
  • Is Id<NSString, Shared> -> Id<NSObject, Owned> safe?
    • Of course no - this would violate aliasing rules. Included for completeness.
  • Is Id<NSString, Owned> -> Id<NSObject, Owned> safe?
    • The owned NSString would be consumed, and only the owned NSObject would remain; we would be free to mutate it as an ordinary object, since it is an ordinary object.
  • Is Id<NSString, Owned> -> Id<NSObject, Shared> safe?
    • Can be done through Ids From implementation, and the above.
  • Is Id<NSMutableString, Owned> -> Id<NSString, Owned> safe?
    • On initial inspection no.
    • But, it actually is! Remember, the reason we want to avoid &mut NSString is because copy returns the same string (and we would get aliasing references); but an instance that is actually NSMutableString, that we just treat as an NSString, will always return a new NSString in copy.
  • Is &MyObject<'a> -> &NSObject safe?
    • Yes, the lifetime information is contained in the reference!
    • Note that retaining an object that you only have a reference to is already not safe, so we can just piggy-back on that.
  • Is Id<MyObject<'a>, Shared> -> Id<NSObject, Shared> safe?
    • No, lifetime information is discarded. Same solution as above.
  • Is Id<NSArray<T, O>, O> -> Id<NSObject, O> safe?
    • Only when T: 'static!
  • Do we even need &mut NSObject? Can't we just do without?
    • I think it's fine to have, as far as I can see it doesn't cause us any problems.

Ergonomics

  • How do we downcast Ids? An inherent method on Id - I went with a trait, see Add ClassType trait #234
  • Add fn as_deref<T: Deref>(x: Id<T, O>) -> Id<T::Target, O>? Inherent or associated method? What should the bounds on T::Target be?
@madsmtm madsmtm added the enhancement New feature or request label Nov 2, 2021
@madsmtm
Copy link
Owner Author

madsmtm commented Nov 3, 2021

Should also consider the safety of "upgrading", e.g. NSObject to Option<NSString> with a call to isKindOfClass:.

EDIT: While it may be possible in some instances (and even necessary, looking at you NSUserDefaults), it is not possible to do in general!

@madsmtm madsmtm mentioned this issue Dec 3, 2021
madsmtm added a commit that referenced this issue Dec 13, 2021
Instead, it is present on the types that actually need it. This is in preparation for bigger changes to objc2-foundation, see #58.
madsmtm added a commit that referenced this issue Dec 19, 2021
Instead, it is present on the types that actually need it. This is in preparation for bigger changes to objc2-foundation, see #58.
@madsmtm
Copy link
Owner Author

madsmtm commented Dec 19, 2021

Downside: Objects like NSData can't Deref to &[u8] like it otherwise naturally would, instead all access has to go through a method

@madsmtm
Copy link
Owner Author

madsmtm commented Jan 6, 2022

Regarding downcasting Ids:

pub unsafe trait ObjectType: Message {}

pub unsafe trait NonRootObjectType: ObjectType {
    type Super: ObjectType;
    fn as_super(&self) -> &Self::Super;
    fn as_super_mut(&mut self) -> &mut Self::Super;
}

impl<T: NonRootObjectType, O: Ownership> Id<T, O> {
    pub fn into_super(self) -> Id<T::Super, O> { // as_deref
        unsafe { self::transmute(self) } // Because of guarantees that `NonRootObjectType` requires
    }
}

Very similar to Deref, but it has different semantics (that allows Id::into_super to work).

Alternative would be to place restrictions on ObjectType that Deref must work a certain way.

@madsmtm
Copy link
Owner Author

madsmtm commented Jan 6, 2022

Yet another thing: For better ergonomics many APIs will want to take Into<&NSObject> or similar; this can be supported with macros that implement From for all subclasses. Or maybe AsRef/AsMut?

But how should we support it for Into<Id<NSObject, O>>? I think yet another trait is required here that is similar to AsRef. Or can we again place restrictions on ObjectType to allow these?

EDIT: Code idea:

pub unsafe trait SubclassOf<T: Message>: Message { // Similar to `AsRef`
    fn as_super(&self) -> &T;
    fn as_super_mut(&mut self) -> &mut T;
}

// All objects are subclasses of themselves
unsafe impl<T: Message> SubclassOf<T> for T {
    fn as_super(&self) -> &T {
        self
    }
    fn as_super_mut(&mut self) -> &mut T {
        self
    }
}

impl<T: Message, U: Message, O: Ownership> Id<T, O>
where:
    T: SubclassOf<U>,
{
    pub fn into_super(self) -> Id<U, O> {
        unsafe { self::transmute(self) } // Because of guarantees that `SubclassOf` requires
    }
}

@madsmtm
Copy link
Owner Author

madsmtm commented Jan 6, 2022

See also: https://rust-unofficial.github.io/patterns/anti_patterns/deref.html

(We should link to this in docs when this is done)

@madsmtm
Copy link
Owner Author

madsmtm commented Jan 10, 2022

Another downside: With this change we lose the ability to generically create classes of a certain type (e.g. generically creating an instance of NSData while allowing it to actually be NSMutableData under the hood), though I do not think this is something anybody would ever need to do.

A bit bigger issue is that class methods also can't be called generically.

@madsmtm
Copy link
Owner Author

madsmtm commented Mar 2, 2022

This bears resemblance to subtyping and variance, we should consider the safety in light of that.

EDIT: I did, results below:

Objective-C is designed with this somewhat in mind already: NSArray<T> is covariant over T, this is stated in the docs.

Looking at the variance table, we can determine that the following situations are always safe:

  • NSString is a subtype of NSObject, which means that the conversion &NSString -> &NSObject is sound because &T is covariant over T.
  • A function fn myfn(&NSString) { ... } could soundly be used where an fn(&NSMutableString) is expected, which matches the fact that functions are contravariant over their parameters.

Interestingly, the conversion &mut NSString -> &mut NSObject would by initial inspection not be deemed sound by this, because &mut T is invariant over T. However, since &mut NSObject doesn't actually give you the capability to change the class of the original object (without using ffi::object_setClass, which is unsafe), it is still sound! See for example the following code:

fn evil_feeder(pet: &mut NSAnimal) {
    let spike: Id<NSDog, Owned> = ...;
    let spike: Id<NSAnimal, Owned> = Id::into_superclass(spike);
    *pet = spike; // Doesn't work, since `NSAnimal` is a ZST (and in the future, !Sized)
                  // mem::replace wouldn't work either
}

fn main() {
    let mut mr_snuggles: Id<NSCat, Owned> = ...;
    evil_feeder(&mut mr_snuggles);  // Replaces mr_snuggles with a Dog
    mr_snuggles.meow();             // OH NO, MEOWING DOG!
}

This is also why we can allow Id<T, Owned> to be covariant over T.

In essence, since the actual instance of the class in Objective-C is moved to the runtime, we can soundly modify the class (call a mutating method) because the runtime remembers all the constraints of the original class.

@madsmtm madsmtm added this to the objc2 v0.3 milestone Apr 2, 2022
@madsmtm
Copy link
Owner Author

madsmtm commented Jun 8, 2022

Note:

NSObject is not Send because then Id<T, O> -> Id<NSObject, O> would not be sound (could get deallocated from the wrong thread).
And it is not Sync because then &T -> &NSObject would not be sound (could be accessed from the wrong thread).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant