Swift 到 Objective-C 调用尾随闭包调用了错误的方法

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

给定以下 Objective-C 方法

@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

我发现当我尝试使用完全指定的参数从 Swift 调用第一个方法时,当我使用尾随闭包时它实际上调用了第二个变体

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

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

我原以为在这两种情况下都会调用变体 1。我不清楚我是否做错了什么。我似乎也错过了 Swift 在调用 Obj-C 方法时可以提供生成参数的事实,并且正在努力寻找相关文档。

如果我用等价物替换 Obj-C TestClass


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

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

}

然后我收到一个编译器警告,关于在两个调用中对 'test' 的使用不明确。

在 Xcode 12.3 和 12.4 上测试。

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

实际上,我发现在这两种情况下都调用了变体 2。

首先为你的Objective-C接口生成Swift接口。你得到这个:

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

我们现在已经从故事中删除了 Objective-C 组件,并且可以在我们的测试中使用这些方法:

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") {}
}

如果我们调用f(),控制台会打印两次“2”。我没有收到任何编译错误,但似乎第一个变体无法访问,除非您省略 both 函数参数。

在 matt 的回答中,他分享了他的经验,即编译器会将两个 Swift 调用都解析为方法的第二个 Objective-C 变体(带有 action2 参数的变体)。我们应该注意到,这种行为是独一无二的,因为这两个方法的第一个参数具有不同的可空性,在第一个变体中可以为空,而在第二个变体中不可为空。

切换两个方法的第一个参数的可空性,行为发生变化,有利于第一个变体。同样,如果两个版本都使用相同的可空性,那么将再次使用第一个变体。考虑:

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

这将转换为以下 Swift 界面:

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

现在,两个 Swift 调用都将调用第一个变体,而不是第二个:

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

在您的示例中(第一个变体使第一个参数可为空,但第二个变体没有),它解析为第二个变体,因为我们传递了一个非可选字符串。

鉴于(希望)第一个变体只是第二个变体的 Objective-C 便捷方法,调用哪个可能无关紧要,但故事的寓意是解决适当的问题Objective-C 方法受到非常微妙的考虑,乍看之下可能并不明显。


冒着过早优化的风险,如果你真的关心确保 Swift 总是调用第二个 Objective-C 变体,而不管这两个中第一个参数的 nullability/optionality变体,可以使用 NS_REFINED_FOR_SWIFT 明确表达意图,如 Improving Objective-C API Declarations for Swift 中所述。例如:

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

然后手动声明你自己的显式Swift接口:

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)
    }
}

这将始终调用 Objective-C 方法的第二个变体。这消除了可能不明显的方法解析行为,使您的意图明确。