Objective-C interoperability: Eliminate NSObjectProtocol

Hi all,

TL;DR

I propose to completely eliminate the NSObject protocol (called NSObjectProtocol) from Swift, leaving only a deprecated typealias (to AnyObject) as a backward-compatibility shim.

Motivation

The Objective-C NSObject protocol, imported into Swift as NSObjectProtocol, is more harmful than helpful in Swift:

  1. The API it provides is not particularly useful in Swift, either because the functionality is better expressed via Swift language features (is and as? rather an isKind(of:) or conforms(to:)), Swift protocols (isEqual(_:), hash, description, debugDescription are covered by Equatable, Hashable, CustomStringConvertible, and CustomDebugStringConvertible, respectively), or is obsolete in Swift (retain/release). The remaining API (basically responds(to:) and perform(_:)) is available on NSObject-the-class, so it's still accessible from Swift. Therefore, there isn't much reason to ever form a type in Swift that involves NSObjectProtocol, or define a Swift protocol that inherits from NSObjectProtocol, because you're better off using the more-general AnyObject.
  2. Correctly conforming to NSObjectProtocol is nearly impossible without inheriting from NSObject, because you would need to poke into the Objective-C runtime directly to implement most of the API described in #1. Worse, you shouldn't reimplement this functionality, because the (invisible) root class of Swift-defined classes, SwiftObject, already implements the NSObject protocol, so trying to implement NSObjectProtocol for a Swift-defined class that doesn't inherit NSObject will break more things than it fixes.
  3. Implementing some common Objective-C protocols (such as UITableViewDelegate) requires one to implement NSObjectProtocol. Due to #2, this means that Swift-defined delegate classes often have to inherit from NSObject for the sole purpose of fulfilling this effectively-vacuous requirement.

Proposed Solution

The primary change involves eliminating NSObjectProtocol from imported Objective-C APIs. Specifically:

  • The NSObjectProtocol protocol itself is not visible to Swift programs.
  • NSObjectProtocol is dropped from the inheritance list of any imported Objective-C protocol, e.g., UITableViewDelegate will not mention NSObjectProtocol in its inherited protocols.
  • NSObjectProtocol is removed from the list of protocols to which an imported Objective-C class conforms (e.g., the imported class NSObject will no longer state its conformance to NSObjectProtocol).
  • Qualified types in Objective-C will drop the NSObject protocol on import, e.g., an Objective-C type id<NSCopying, NSObject> will be imported as NSCopying rather than NSCopying & NSObjectProtocol. The type id<NSObject> will simply be imported as AnyObject.
  • Introduce a deprecated type alias into the ObjectiveC module overlay to ease transition:
    public typealias NSObjectProtocol = AnyObject
    

Source Compatibility

The relative rarity of NSObjectProtocol in Swift should mitigate most source-compatibility concerns, and the deprecated type alias makes most existing uses of NSObjectProtocol still compile. For example, all of the following will still work:

class X: NSObjectProtocol { }       // okay: the constraint is redundant now
protocol P: NSObjectProtocol { }  // okay: this means that it's a class-constrained protocol
func composition(_: NSCoding & NSObjectProtocol) { } // okay: treated as `NSCoding`
func solo(_: NSObjectProtocol) { } // okay: treated as `AnyObject`

It is conceivable that some code like the above uses the NSObjectProtocol API directly, e.g., with a direct call to responds(to:) on an instance of type NSObjectProtocol or NSCoding & NSObjectProtocol. The latter case will always fail (because NSCoding does not have that method), while the former case will have a subtle change in behavior: (some instance of AnyObject).responds(to: selector) will use AnyObject lookup, which will find responds(to:) in NSObject-the-class, allowing the call to succeed but wrapping the result in an implicitly-unwrapped optional.

Implementation

For the curious, my implementation of this pitch is available here. At the time of this writing, I'm still waiting back on the source compatibility suite testing; I'll check back in with those results later, once we get a sense of how much breakage this will cause.

Doug

24 Likes

Sounds great to me. In practice, though, I think that it'd be better to import id<NSObject> as Any, just like we do id, and similarly typealias NSObjectProtocol to just Any for source compatibility. There were a few places in Apple's SDK where wanton <NSObject> protocol specifiers in ObjC interfered with id-as-Any's effectiveness, and I don't know that importing the class constraint buys much.

I initially imported id<NSObject> as Any in bridgeable positions, and it broke when compiling one of the overlays. That might not be indicative of the wider Swift code base, and I'm happy to try it both ways to see how things work. I'd prefer to import as Any where we can!

I'm all up for it, found it redundant quite a few times, though your example that should still work doesn't:

typealias ObjCProtocol = AnyObject
class X: ObjCProtocol {} // error: inheritance from non-protocol, non-class type 'ObjCProtocol' (aka 'AnyObject')

Is this something that will be addressed as a special case in the implementation (I've gone through the changes in the pull request and I can't see anything that would treat it specially)? Tried it with Swift 4.1 snapshot from 2/8/2018.

Ah, this hack relies on a Swift 3 compatibility thing. Thanks! I'll update my pull request appropriately.
FWIW, my test of this code is here in the pull request: [Experiment] [Objective-C interoperability] Eliminate import of NSObjectProtocol. by DougGregor Ā· Pull Request #14654 Ā· apple/swift Ā· GitHub

Doug

+1 from me.

This one has really bitten me a lot. Just getting rid of this would be a major +1!

4 Likes

Same.

I think point 1 of the proposal may be scrutinised as not causing enough harm (with similar arguments to the DictionaryLiteral discussion) even if it’s not ideal.

Point 2 highlights danger but this seems like a theoretical problem rather than something people actively trip up on. Manual conformance to ā€˜NSObjectProtocol’ is hard, as described, so basically nobody attempts it. This means that the SwiftObject conflicts are not realised in shipping code. Granted, I don’t have empirical proof of these claims.

However, removing the superfluous NSObject requirements for Objective-C class delegates is easily enough motivation to drive this forward on its own, IMO. Every iOS developer runs into this.

I have a few questions so that I can fully understand the outcome of this proposal. Today on Apple platforms using i.g. UIKit one is forced to use a NSObject subclass when you want to conform to protocols such as UIScrollViewDelegate. After this proposal this restriction will be eliminated. As far as I know even in the Obj-C runtime there two different kinds of classes, the one that inherits from NSObject and the (pure?) Swift class. The difference is easy to tell when you try to override a protocol requirement inside an extension on a subclass. The Swift class will fail while the other one will succeed.

  • What will happen to my (pure?) Swift class if I now conform it to a protocol like UIScrollViewDelegate?
    (I don't expect and really don't want an implicit bridge to some kind of NSObject subclass here.)

  • What about optional protocol requirements from such protocols?
    Will it force me to add @objc attributes everywhere on my pure Swift classes that conform to those protocols?

Personally I try to write pure Swift code as much as possible and don't rely on NSObject subclasses.


Would it make sense to fix protocol P where Self : SomeClass glitch and fold it into protocol P : SomeClass and then import the previous protocol Q : NSObjectProtocol as protocol Q : NSObject?

Allowing non-@objc Swift classes and value types to conform to Objective-C protocols would not just fall out of this. I think that's still an interesting long-term goal to pursue (which is part of why I think we should map id<NSObject> to Any if possible).

2 Likes

I support this 100%

I can write this in a playground:

@objc protocol AnObjcProtocol {}

class ObjcClass: NSObject, AnObjcProtocol {}
var objcObject = ObjcClass()
isKnownUniquelyReferenced(&objcObject) // false

class SwiftClass: AnObjcProtocol {}
var swiftObject = SwiftClass()
isKnownUniquelyReferenced(&swiftObject) // true

and it compiles fine, despite having a pure-swift class conforming to an Objective-C protocol. (I'm double-checking with isKnownUniquelyReferenced to see if it truly is a native Swift object, as Objective-C objects always return false there.) I'm not sure when this has changed, but I could sear this did not work at some point in the past.

So to me, it looks like getting rid of NSObjectProtocol is all that's needed to allow native Swift classes to conform to Objective-C protocols.

Also, I'm all for making NSObjectProtocol disappear.

1 Like

Thanks @michelf, I misremembered whether we allowed non-NSObject subclasses to conform to @objc protocols.

Uh,... NSObjectProtocol already supports multiple unrelated classes (NSObject and NSProxy). Did you realize that? Not all classes that support the protocol are part of the NSObject hierarchy, so killing the protocol will break Apple's code.

Interesting debate, but... never mind?

Swift tries to infer the @objc when you've written an implementation that matches a requirement of an @objc protocol. If that's not kicking in for you, please file a bug.

That's fine; Swift classes can conform to @objc protocols now. Only @objc protocols that inherited from NSObjectProtocol forced a Swift class to inherit from NSObject.

No, yo won't have to write @objc everywhere to conform to the protocols. The best way to make sure you've gotten it right is to put the implementations of optional @objc protocol requirements in the same extension that defines the conformance.

Independently, it does make sense to fix that glitch. However, I don't think we want the to make the protocol you mentioned require an NSObject base class, though, because that would exclude non-NSObject-deriving classes from conforming to the protocol Q. We're effectively trying to loosen requirements here, so Swift classes need not inherit NSObject to conform to those protocols.

Doug

You're missing the point of the pitch, so I'll see if I can make this clearer.

My contention is that every class you come into contact with in a Swift program already conforms to NSObjectProtocol, because (1) on the Objective-C side, both NSObject and NSProxy conform to NSObjectProtocol, and (2) the hidden Swift root class conforms to NSObjectProtocol. Hence, NSObjectProtocol provides very little over AnyObject.

Doug

This sounds like a great direction to me! Joe's comment about mapping to Any makes sense to me if it can be made to work.

Source compatibility update

The fallout from this change is not too bad. There are effectively two kinds of failures we see in the source compatibility suite:

  1. Explicit use of responds(to:) on a value of a protocol type that has (now suppressed) inheritance from NSObjectProtocol, e.g., this example from RxDataSources:
    let forwardDelegateResponds = (self.forwardToDelegate() as? UITableViewDataSource)?.responds(to: commitForRowAtSelector)
    
  2. Extensions of NSObjectProtocol, e.g., this example from ReactiveCocoa:
    extension NSObjectProtocol {
      @nonobjc internal var associations: Associations<Self> {
        return Associations(self)
      }
    }
    

Neither has an "obvious" fix within the language. For #2, one could perhaps treat extension NSObjectProtocol as extension NSObject, but while that would make this extension work... it has clients that would need to be updated to use NSObject as well:

extension Reactive where Base: NSObjectProtocol { ... uses associations ... }

If we were to extend Swift further, to allow extensions of AnyObject, it would make #2 "just work" (all class types would get this behavior), and #1 could be fixed by extending AnyObject with the relevant parts of NSObjectProtocol's API. But, that's a significant language extension on which to gate this proposal.

Doug

It seems like the AnyObject extension would be a sensible albeit non trivial option.

+1 thanks for the suggestion Doug :).

Douglas_Gregor

    February 15

Source compatibility update

The fallout from this change is not too bad. There are effectively two kinds of failures we see in the source compatibility suite:

  1. Explicit use of responds(to:) on a value of a protocol type that has (now suppressed) inheritance from NSObjectProtocol, e.g., this example from RxDataSources:
>   let forwardDelegateResponds = (self.forwardToDelegate() as? UITableViewDataSource)?.responds(to: commitForRowAtSelector)
  1. Extensions of NSObjectProtocol, e.g., this example from ReactiveCocoa:
>     extension NSObjectProtocol {
>   @nonobjc internal var associations: Associations<Self> {
>   return Associations(self)
>   }
>   }

Neither has an ā€œobviousā€ fix within the language. For #2, one could perhaps treat extension NSObjectProtocol as extension NSObject, but while that would make this extension work… it has clients that would need to be updated to use NSObject as well:

> extension Reactive where Base: NSObjectProtocol { ... uses associations ... }

Would it be possible for us to keep NSObjectProtocol for compatibility, continue to import conformances to it from ObjC classes, but drop it as a requirement on imported protocol-constrained function types or protocols? If we do that, we still might also be able to deprecate NSObjectProtocol when referenced in Swift 5 code, while leaving it around as a real protocol for compatibility with existing Swift <=4 code.

1 Like

Leaving it around would address the extension case (#2) and its uses, although we'd still break the responds(to:) case (#1) that depends on using the NSObjectProtocol API on a UITableViewDataSource. Maybe that's okay, and still serves (some) of the goals of the pitch.

Another, weaker approach is to deprecate NSObjectProtocol in Swift but make all Swift-defined classes implicitly conform to NSObjectProtocol. That gives some of the end-user benefits of the pitch (one can conform to UITableViewDataSource without inheriting NSObject), but doesn't achieve any of the other benefits of eliminating NSObjectProtocol.

Doug