Swift to Objective-C call with trailing closure calls wrong method

Viewed 177

Given the following Objective-C methods

@import Foundation;

NS_ASSUME_NONNULL_BEGIN

@interface TestClass : NSObject
// Variant 1. Notice that arg1 is nullable, and that all closure arguments are nullable.
+ (void)test:(NSString *_Nullable)arg1
                 action:(nullable void (^)(void))action;

// Variant 2. Notice that arg2 is non null, there is an additional, nullable, closure argument
+ (void)test:(NSString *)title
      action:(nullable void (^)(void))action
     action2:(nullable void (^)(void))action2;

@end

NS_ASSUME_NONNULL_END

I find that when I attempt to call the first method from Swift with fully specified args, it actually calls the second variant when I use a trailing closure

// Calls first variant (This is an erroneous claim, hence Matt's answer)
TestClass.test("arg1", action: {})

// Calls second variant
TestClass.test("arg2") {}

I was expecting Variant 1 to be called in both cases. I'm unclear if I'm doing something wrong or not. I also seem to have missed the fact that Swift could provide generate arguments at all when calling Obj-C methods and am struggling to find the relevant documentation on it.

If I replace the Obj-C TestClass with the equivalent


class TestClass {
    class func test(_ arg1: String?, action: (() -> ())? = nil) {
        
    }

    class func test(_ arg1: String!, action: (() -> ())? = nil, action2: (() -> ())? = nil) {
        // Should not get here
        assert(false)
    }

}

Then I get a compiler warning about ambiguous use of 'test' in both calls.

Tested on Xcode 12.3 and 12.4.

2 Answers

Obviously, I expect Variant 1 to be called in both cases.

Actually, I find that Variant 2 is called in both cases.

First generate the Swift interface for your Objective-C interface. You get this:

open class func test(_ arg1: String?, action: (() -> Void)? = nil)
open class func test(_ title: String, action: (() -> Void)?, action2: (() -> Void)? = nil)

We have now eliminated the Objective-C component from the story and can just use these methods in our testing:

typealias VoidVoid = () -> Void
func test(_ arg1: String?, action: VoidVoid? = nil) { print("1") }
func test(_ title: String, action: VoidVoid?, action2: VoidVoid? = nil) { print("2") }
func f() {
    test("arg1", action: {})
    test("arg2") {}
}

If we call f(), the console prints "2" twice. I don't get any compile error, but it does appear that the first variant is unreachable unless you omit both function arguments.

In matt’s answer, he shared his experience where the compiler would resolve both Swift calls to the second Objective-C variant of the method (the one with the action2 parameter). We should note that this behavior is unique to the fact that the two methods have a different nullability for the first argument, being an nullable in the first variant, and not nullable in the second variant.

Switch the nullability of the first parameter of the two methods, and the behavior changes, favoring the first variant. Likewise, if both of the renditions use the same nullability, then, again, the first variant will be used. Consider:

NS_ASSUME_NONNULL_BEGIN

@interface TestClass : NSObject

+ (void)test:(NSString * _Nullable)title
      action:(nullable void (^)(void))action;

+ (void)test:(NSString * _Nullable)title
      action:(nullable void (^)(void))action
     action2:(nullable void (^)(void))action2;

@end

NS_ASSUME_NONNULL_END

That this translates to the following Swift interface:

open class TestClass : NSObject {
    open class func test(_ title: String?, action: (() -> Void)? = nil)    
    open class func test(_ title: String?, action: (() -> Void)?, action2: (() -> Void)? = nil)
}

Now, both Swift calls will call the first variant, not the second:

TestClass.test("foo", action: {}) // variant 1
TestClass.test("foo") {}          // variant 1

In your example (where the first variant made the first argument nullable, but the second variant did not), it is resolving to the second variant because we passed a non-optional string.

Given that (hopefully) the first variant is just an Objective-C convenience method to the second variant, it probably does not matter which is called, but the moral of the story is that the resolution to the appropriate Objective-C method is subject to very subtle considerations that may not be obvious at a glance.


At the risk of premature optimization, if you really are concerned about ensuring that Swift always call the second Objective-C variant regardless of the nullability/optionality of the first parameter in these two variations, one could make the intent explicit with NS_REFINED_FOR_SWIFT as discussed in Improving Objective-C API Declarations for Swift. For example:

NS_ASSUME_NONNULL_BEGIN

@interface TestClass : NSObject

+ (void)test:(NSString * _Nullable)title
      action:(nullable void (^)(void))action NS_REFINED_FOR_SWIFT;

+ (void)test:(NSString * _Nullable)title
      action:(nullable void (^)(void))action
     action2:(nullable void (^)(void))action2 NS_REFINED_FOR_SWIFT;

@end

NS_ASSUME_NONNULL_END

And then manually declare your own explicit Swift interface:

extension TestClass {
    @inlinable
    static func test(_ title: String? = nil, action: (() -> Void)? = nil) {
        __test(title, action: action, action2: nil)
    }

    @inlinable
    static func test(_ title: String? = nil, action: (() -> Void)?, action2: (() -> Void)? = nil) {
        __test(title, action: action, action2: action2)
    }
}

That will always call the second variant of the Objective-C method. This eliminates method resolution behaviors that might be otherwise not be obvious, making your intent explicit.

Related