Should we really rank overloads found through AnyObject lookup?

Currently we allow this to compile:

import Foundation

class C : NSObject {}
class D : C {}

@objc protocol P {
  @objc var i: D { get }
}

class X {
  @objc var i = C()
}

func foo(_ x: AnyObject) {
  let m = x.i // unambiguously refers to P's `i: D`
}

As due to overload ranking, we prefer the "more specialised" overload var i: D. IMO this is wrong, and could very well lead to the user accidentally performing the dynamic lookup on an X instance without realising that their code is unsound (it would effectively unsafe-bit-cast the C instance to a D instance).

It seems to me that we shouldn't perform any overload ranking whatsoever when it comes to overloads found through AnyObject lookup – we should make cases such as the above example ambiguous, and require the user to disambiguate which overload they want by providing context, e.g let m: C = x.i.

Any thoughts on this?

1 Like

I agree that overload ranking based on the type or kind of the receiver doesn't make sense. I'm more on the fence about other kinds of ranking, like "prefer the callee that doesn't require a default parameter". On the one hand, that's just as ambiguous, because they have different selectors; on the other, you can certainly specify the default parameter explicitly if that's what you wanted, and Objective-C doesn't have any such notion anyway.

Also, we still want this to work:

import Foundation

class C : NSObject {}

@objc protocol P {
  @objc var i: C { get }
}

class X: NSObject, P {
  @objc var i = C()
}

func foo(_ x: AnyObject) {
  let m = x.i // since X conforms to P, this must be the same. Ish.
}

(It goes without saying that this is a source-breaking change and would have to be conditionalized on language version.)

1 Like

OR just get rid of AnyObject lookup altogether, because using it is never a good idea anyways.

All of these are better:

(x as! P).i
(x as! X).i
(x as? P)?.i
(x as? X)?.i

Ah, I forgot about that particular ranking rule, thanks for mentioning it! I would be sympathetic towards making an exception for it. Though it appears we don't currently support applying default arguments to functions found through AnyObject lookup:

import Foundation

class C1 {
  @objc func foo(x: String, y: Int = 0) {}
}

func bar(_ x: AnyObject) {
  x.foo(x: "") // error: Cannot invoke 'foo' with an argument list of type '(x: String)'
}

(shall I file a bug for that?)

I agree that we want that to work – although note that it doesn't work because of any overload ranking rules (I actually just discovered the other day that we don't have a ranking rule for preferring a member in a concrete type over a protocol requirement, only one for protocol extensions – in fact it was the desire to implement such a rule that led me to this topic, as it would remove ambiguities for some AnyObject lookup cases that IMO should remain ambiguous).

Rather the above example works because we filter out results that have the same "dynamic result signature" for lookups on AnyObject (which for vars consists of the interface type, static-ness, and selector for the getter). This is also what prevents ambiguity in cases where the members aren't explicitly related, such as:

import Foundation

class C1 {
  @objc var i = 0
}

class C2 {
  @objc var i = 0
}

func foo(_ x: AnyObject) {
  let m = x.i 
}

Which I think should also remain unambiguous.

1 Like

There was actually a pitch to deprecate AnyObject lookup, which I would support:

@Slava_Pestov Is there any possibility of re-visiting this in the Swift 5 timeframe?

2 Likes

Okay, so after experimenting with this for a bit – I'm not sure it's actually feasible to ignore type-based overload ranking rules when doing AnyObject lookup, given that we don't currently have syntax for precisely disambiguating overloads.

I thought that saying let m: C = x.i would be sufficient to disambiguate in the case of:

import Foundation

class C : NSObject {}
class D : C {}

@objc protocol P {
  @objc var i: D { get }
}

class X {
  @objc var i = C()
}

func foo(_ x: AnyObject) {
  let m: C = x.i
}

but it wouldn't – as we would still rank the solutions equally (and introducing a score for superclass conversions opens up a whole other can of worms).

So really at this point, I'd say deprecating AnyObject lookup is the best way to solve this problem. It'd be great if we could re-visit the pitch for that.

Here's another example of non-obvious AnyObject lookup behaviour I found the other day:

import Foundation

let dict: AnyObject = [1: 4] as NSDictionary
print(dict[1] as Any) // nil
print(dict[1 as Any] as Any) // Optional(Optional(4))

The former dispatches to NSArray's objectAtIndexedSubscript:, whereas the latter dispatches to NSDictionary's objectForKeyedSubscript:.

Subscripts are particluarly bad for AnyObject, so another approach I've thought of in the past is hardcoding AnyObject subscripts as (Any) -> Any and (Int) -> Any, or some variation thereon. This roughly matches the signatures on NSDictionary and NSArray, which is where people most commonly want to use them, and all Objective-C subscripts are required to have essentially one of those two signatures at the (ObjC) ABI level anyway.

That would indeed simplify things – though if we were going to do that, I would prefer if the two subscripts had argument labels, so for example you would need to say either object[index: 0] or object[key: 0] to make it explicit which subscript selector you want to dispatch to. Otherwise IMO the distinction between object[1] and object[1 as Any] is a bit too subtle.

It would be interesting to know how much AnyObject subscripting is actually used in the wild – I would guess that it probably isn't used a whole lot. (I know people used to use it a lot for things like JSON parsing, back when we had to wrangle with [String: AnyObject]s and [AnyObject]s, but I suspect that would have sharply decreased after the id-as-Any changes and introduction of JSONDecoder).