【译】SE-0297 Concurrency 与 Objective-C 的交互

原文链接:SE-0297 Concurrency Interoperability with Objective-C

简介

Swift 的并发功能包括了异步函数和 actor。虽然 Objective-C 没有相应的语言特性,但异步 API 在 Objective-C 中很常见,通过使用 completion-handler 手动实现。本提案提供了 Swift 的并发特性(如 async 函数)和 Objective-C 中基于约定的异步函数表达之间的桥接。它的目的是让现有的丰富的异步 Objective-C API 可以立即与 Swift 的并发模型一起使用。

例如,试想一下 CloudKit 中的 Objective-C API:

- (void)fetchShareParticipantWithUserRecordID:(CKRecordID *)userRecordID 
    completionHandler:(void (^)(CKShareParticipant * _Nullable, NSError * _Nullable))completionHandler;

这个 API 是异步的,它通过 completion-handler 来提供它的结果(或错误),这个 API 直接翻译到 Swift:

func fetchShareParticipant(
    withUserRecordID userRecordID: CKRecord.ID, 
    completionHandler: @escaping (CKShare.Participant?, Error?) -> Void
)

现有的 Swift 代码可以通过向 completion-handler 传入一个闭包来调用这个 API。这个提案提供了一个新的 API 翻译方案,可以将这一类 API 翻译为 async 函数,例如:

func fetchShareParticipant(
    withUserRecordID userRecordID: CKRecord.ID
) async throws -> CKShare.Participant

Swift 调用者可以使用 await 表达式来调用 fetchShareParticipant(withUserRecordID:)

guard let participant = try? await container.fetchShareParticipant(withUserRecordID: user) else {
    return nil
}

Swift-evolution thread: [Concurrency] Interoperability with Objective-C

动机

在 Apple 的平台上,Swift 与 Objective-C API 的紧密集成是开发者体验的重要组成部分之一。有这几个核心功能:

  • Objective-C 类、协议和方法可以直接在 Swift 中使用。
  • Swift 类可以继承 Objective-C 类。
  • Swift 类可以声明与 Objective-C 协议的 conformance。
  • Swift 类、协议和方法可以通过 @objc 注解提供给 Objective-C。

异步 API 在 Objective-C 代码中比比皆是:iOS 14.0 SDK 中包含了近 1000 个接收 completion-handler 的方法,其中包括可以从 Swift 中直接调用的方法,可以在 Swift 定义的子类中 override 的方法,以及 conform 协议的方法。在 Swift 的并发模型中支持这些用例,可以大大扩展这个新功能的应用范围。

解决方案

本提案提出的解决方案,尝试在几个不同的维度提供 Swift 并发结构和 Objective-C 之间的交互。它包含了这几个相互依赖的组成部分:

  • 在 Swift 中把 Objective-C 接收 completion-handler 的函数翻译成 async 方法。
  • 允许在 Swift 中定义的 async 方法被注解为 @objc,在这种情况下,它们将导出为基于 completion-handler 的方法。(供 Objective-C 调用)
  • 提供 Objective-C 注解,基于 completion-handler 的 API 转化为 async Swift函数的流程可以用它来控制。

下面的设计细节描述了具体的规则和推导方法。然而,评估整体翻译效果的最佳方式还是查看它应用在 Objective-C API 上的实际效果。这个 Pull Reqeust 展示了这个提案对苹果 iOS、macOS、tvOS 和 watchOS SDK 中Objective-C API 的 Swift 翻译的效果。

设计细节

异步 completion-handler 方法

如果一个 Objective-C 方法满足以下要求,那它就可以看作是一个异步 completion-handler 方法:

  • 该方法有一个 completion-handler 参数,它是一个 Objective-C 闭包,接收异步运算的”结果”。它必须满足以下额外的限制条件:
    • 它的返回值类型是 void
    • 它在整个实现的所有执行路径中只被调用一次。
    • 如果它可以传入 error,并且闭包包含了一个是类型为 NSError * 的参数,并且不是 _Nonnull。一个非 null 的 NSError * 值通常表示发生了 error,尽管 C 语言的 swift_async 属性可以使用其他约定(在 Objective-C 注解一节中讨论)。
  • 方法本身的返回值类型是 void,因为所有的结果都是由 completion-handler 闭包传递的。

一个可能是异步 completion-handler 方法的 Objective-C 方法将被翻译成一个 async 方法,当它被显式地注解为一个适当的 swift_async 属性(在 Objective-C 注解一节中有详细说明),或者当下面的推导成功地识别出 completion-handler 参数时,它就被隐式地推导为 async 方法:

  • 如果该方法只有一个参数,而且第一个 selector 的后缀是下列短语之一:
    • WithCompletion
    • WithCompletionHandler
    • WithCompletionBlock
    • WithReplyTo
    • WithReply
      唯一参数是 completion-handler 参数。当导入函数时,匹配的短语将从函数的名字中移除。
  • 如果方法有一个以上的参数,如果它的 selector 外参或内参名字是 completionwithCompletioncompletionHandlerwithCompletionHandlercompletionBlockwithCompletionBlockreplyTowithReplyToreplyreplyTo,则最后一个参数看作是 completion-handler 参数。
  • 如果方法有一个以上的参数,并且最后一个参数以第一个规则中的其中一个后缀结尾,则最后一个参数将被推导为 completion-handler。后缀前面的文字被附加到函数的名称里。

当推导出 completion-handler 参数时,如果 completion-handler 闭包类型中存在一个不是 _NonnullNSError * 参数,则表明翻译后的方法可以传递 error。

将一个异步的 Objective-C completion-handler 方法翻译成一个 async Swift 方法将遵循正常的翻译规则,但做了以下改动:

  • completion-handler 参数从翻译后的 Swift 方法的参数列表中删除。
  • 如果该方法可以传入一个 error,那么它除了是 async 之外,还会是 throws 的。
  • completion-handler 闭包的参数类型会被翻译成 async 方法的结果类型,但要遵守以下附加规则:
    • 如果该方法可以传入一个 error,则忽略 NSError * 参数。
    • 如果该方法可以传入一个 error,并且给定的参数具有 _Nullable_result nullability 的标注(参见下面的 Objective-C 注解一节),那么它将作为 optional 参数被导入。否则,它将被作为 non-optional 参数导入。
    • 如果有多个参数类型,它们将被合并成一个元组类型。

下面 PassKit 的 API 展示了推导规则是怎么发挥作用的:

- (void)signData:(NSData *)signData 
withSecureElementPass:(PKSecureElementPass *)secureElementPass 
      completion:(void (^)(NSData *signedData, NSData *signature, NSError *error))completion;

目前这个函数在 Swift 中被翻译成这样的 completion-handler 函数:

@objc func sign(_ signData: Data, 
    using secureElementPass: PKSecureElementPass, 
    completion: @escaping (Data?, Data?, Error?) -> Void
)

它将会被翻译成这样的 async 函数:

@objc func sign(
    _ signData: Data, 
    using secureElementPass: PKSecureElementPass
) async throws -> (Data, Data)

当编译器看到对这种方法的调用时,它会使用 withUnsafeContinuation 为函数的其余部分生成一个 continuation,然后将给定的 continuation 封装进一个闭包里。例如:

let (signedValue, signature) = try await passLibrary.sign(signData, using: pass)

将成为类似于这样的代码:

try withUnsafeContinuation { continuation in 
    passLibrary.sign(
        signData, using: pass, 
        completionHandler: { (signedValue, signature, error) in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: (signedValue!, signature!))
            }
        }
    )
}

把 Objective-C 方法名翻译成 async 函数时,在 Swift 里的名称会有额外的规则:

  • 如果方法的名称以 get 开头,则去掉 getget 后面的词改成小写。
  • 如果方法的名称以 Asynchronously 结尾,则删除该词。

如果 Objective-C 方法的 completion-handler 参数是 optional 的,且翻译后的 async 方法返回类型不是 Void,则会用 @discardableResult 注解来标记。比如说:

-(void)stopRecordingWithCompletionHandler:void(^ _Nullable)(RPPreviewViewController * _Nullable, NSError * _Nullable)handler;

会成为:

@discardableResult func stopRecording() async throws -> RPPreviewViewController

在 Swift 里定义异步 @objc 方法

许多 Swift 符号可以通过 @objc 注解暴露给 Objective-C。有了 async Swift 方法,编译器将在它创建的 Objective-C 方法中添加一个相应的 completion-handler 参数,使用的是上一节中介绍的转换规则的反向版本,这样产生的 Objective-C 方法就是一个异步的 Objective-C completion-handler 方法。例如,一个类似于这样的方法:

@objc func perform(operation: String) async -> Int { ... }

将翻译为这样的 Objective-C 方法:

- (void)performWithOperation:(NSString * _Nonnull)operation
           completionHandler:(void (^ _Nullable)(NSInteger))completionHandler;

编译器合成的 Objective-C 方法实现将创建一个独立任务,这个任务会用传入的字符串调用 async Swift 方法 perform(operation:),然后将结果转发给 completion-handler(如果 completion-handler 不是 nil 的话)。

对于一个 async throws 方法,completion-handler 扩展了一个 NSError * 参数来表示 error,任何 non-nullable 的指针类型的参数都做成 _Nullable,任何 nullable 指针类型的参数都做成 _Nullable_result。例如:

@objc func performDangerousTrick(operation: String) async throws -> String { ... }

产生的 Objective-C 方法签名会是这样的:

- (void)performDangerousTrickWithOperation:(NSString * _Nonnull)operation
    completionHandler:(void (^ _Nullable)(NSString * _Nullable, NSError * _Nullable))completionHandler;

同样,合成的 Objective-C 方法实现将创建一个独立任务,调用 async throws 方法performDangerousTrick(operation:)。如果方法正常返回,那么 String 结果将在第一个参数中传入给 completion-handler,第二个参数(NSError *)将传入 nil。如果方法是 throws 的,第一个参数将被传入 nil(这就是为什么它被做成 _Nullable,尽管在 Swift 中是 non-optional 的),第二个参数将收到 error。如果有非指针参数,它们将在非错误参数中传递初始化为零的内存,为调用者提供一致的行为。这里可以用等效的 Swift 代码来帮助理解:

// Synthesized by the compiler
@objc func performDangerousTrick(
    operation: String,
    completionHandler: ((String?, Error?) -> Void)?
) {
    runDetached {
        do {
            let value = try await performDangerousTrick(operation: operation)
            completionHandler?(value, nil)
        } catch {
            completionHandler?(nil, error)
        }
    }
}

Actor 类

Actor 类可以是 @objc 的,并将在 Objective-C 中和其他类一样可用。Actor 类要求其父类(如果有的话)也是 Actor 类。然而,这个提案稍微放宽了这个要求,允许一个 actor 类将 NSObject 作为它的父类。在理论上这是安全的,因为 NSObject 没有状态(而且它的布局实际上是固定的),并且使得 actor 类既可以是 @objc 的,也意味着它遵循le1 NSObjectProtocol,这在实现一些 Objective-C 协议时是必要的,否则在 Swift 中是无法实现的。

一个 actor 类的成员只有在它是 async 或在 actor 的隔离域之外时才能成为 @objc。在 actor 隔离域内的同步代码只能在 self 上被调用(在 Swift 中)。Objective-C 没有 actor 隔离的概念,所以这些成员是不允许暴露在 Objective-C 中的。比如说:

actor class MyActor {
    @objc func synchronous() { } // error: part of actor's isolation domain
    @objc func asynchronous() async { } // okay: asynchronous
    @objc @actorIndependent func independent() { } // okay: actor-independent
}

completion-handler 必须只调用一次

一个 Swift async 函数总是会暂停、返回或(如果它是 throws 的话)抛出一个错误。对于 completion-handler API 来说,重要的是 completion-handler 在所有路径上都被准确地调用一次,包括抛出错误时。如果不这样做,就会破坏调用者的语义,要么不能继续,要么多次执行相同的代码。虽然这是一个目前就存在的问题,但广泛使用 async 和没有正确实现的 completion-handler 可能会加剧这个问题。

幸运的是,由于编译器本身会负责生成将被传递给 completion-handler API 的闭包,它可以通过在合成的闭包中引入一个额外的标志位来检测这两个问题,表明这个闭包是否已经被调用过。如果当闭包被调用时,这个标志位有值了,那么就证明它已经被多次调用。如果这个标志位在闭包被销毁时没有被设置过,则说明它根本没有被调用过。虽然这并不能解决根本问题,但至少可以在运行时检测出问题。

新增的 Objective-C 注解

将基于 Objective-C completion-handler 的 API 转化为 async Swift API 时,会引入额外的标注(以注解的形式)来定制和优化这个过程。例如:

  • _Nullable_result:与 _Nullable 一样,表示指针可以是 null 的(或 nil)。_Nullable_result_Nullable 的不同之处只在于 completion-handler 参数。当 completion-handler 的参数被转换为 async 方法的结果类型时,相应的结果将会是 optional 的。
  • __attribute__((swift_async(...))):用于控制如何将异步 completion-handler 翻译成 async 函数的注解。它在括号内有这几种操作:
    • __attribute__((swift_async(none))):禁用翻译为 async
    • __attribute__((swift_async(not_swift_private, C))):指定该方法应该被翻译成 async 方法,使用索引 C 的参数作为 completion-handler 参数。第一个(非 self)参数的序号为 1。
    • __attribute__((swift_async(swift_private, C))):将该方法翻译成 “Swift private” 的 async 方法(仅在封装时使用),使用索引 C 的参数作为 completion-handler 参数。第一个(非 self)参数的序号为 1。
  • __attribute__((swift_attr("swift attribute"))):一个通用 Objective-C 注解,允许大家直接提供 Swift 属性。在并发的上下文中,这允许 Objective-C API 被注解为一个全局 actor(例如,@UIActor)。
  • __attribute__((swift_async_name("method(param1:param2:)")):指定翻译后的 async 函数的 Swift 名称。该名称不应包括 completion-handler 参数的参数标签。
  • __attribute__((swift_async_error(...)))。一个注解,用于控制如何将 NSError * 传递给 completion-handler 并且映射 async throws 的方法中。它有几个可用的参数:
    • __attribute__((swift_async_error(none))):不要导入为 throwsNSError * 参数将被视为正常参数。
    • __attribute__((swift_async_error(zero_argument(N))):导入为 throws。当 completion-handler 的第 n 个参数传递了一个为 0 的整数值(包括 false)时,async 方法将抛出错误。第 n 个参数会从翻译后的 async 方法的结果类型中删除。第一个参数的序号是 1
    • __attribute__((swift_async_error(nonzero_argument(N))):导入为 throws。当完成处理程序的第 n 个参数被传递了一个非 0 的整数值(包括 true)时,async 方法将抛出错误。第 n 个参数将从翻译后的 async 方法的结果类型中删除。

代码兼容性

一般来说,将 Objective-C API 翻译成 Swift 的规则修改是代码破坏性修改。为了避免破坏源码兼容性,这个提案会将 Objective-C 异步 completion-handler 方法翻译成 completion-handler 版本以及新的 async 版本。让现有的 Swift 代码库能够渐进地采用 async 形式的 API,而不是强迫(例如)整个 Swift 模块全部换成 async

同时以两种不同的方式导入相同的 Objective-C API 会导致一些问题:

  • 同步和异步 API 的重载。Objective-C 框架可能已经同时包含同一个 API 的同步和异步版本,例如:

    - (NSString *)lookupName;
    - (void)lookupNameWithCompletionHandler:(void (^)(NSString *))completion;

    会被翻译为三个不同的 Swift 方法:

    @objc func lookupName() -> String
    @objc func lookupName(withCompletionHandler: @escaping (String) -> Void)
    @objc func lookupName() async -> String

    第一个和第三个的函数签名,除了一个是同步一个是异步之外,其他都是一样的。async/await 的设计不允许在同一个 Swift 模块中编写这样的重载,但在翻译 Objective-C API 或从不同 Swift 模块中导入方法时,可能会发生这样的情况。async/await 会在同步上下文中有倾向性地选择同步函数,在异步上下文中有倾向性地选择异步函数,解决这种重载问题。这种重载应该避免破坏源码兼容性。

  • 另一个问题是当一个异步 completion-handler 方法是 Objective-C 协议的一部分。例如,NSURLSessionDataDelegate 协议就包括了这种协议要求:

    @optional
    - (void)URLSession:(NSURLSession *)session
              dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveResponse:(NSURLResponse *)response
     completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;

    现有的 Swift 代码可能会在 conformance 里实现它的 completion-handler 版本:

    @objc
    func urlSession(
        _ session: URLSession,
        dataTask: URLSessionDataTask,
        didReceive response: URLResponse,
        completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
    ) { ... }

    而为使用并发模型而设计的 Swift 代码,可能会选择在 conformance 中实现它的 async 版本:

    @objc
    func urlSession(
        _ session: URLSession,
        dataTask: URLSessionDataTask,
        didReceive response: URLResponse
    ) async -> URLSession.ResponseDisposition { ... }

    同时实现这两个版本会产生一个错误(这两个 Swift 方法有相同的 selector),但是根据正常的 Swift 规则,只实现其中一个要求也会产生错误(因为另一个需求不满足)。Swift 对协议 conformance 的检查将会被扩展,以处理多个(导入的)要求有相同的 Objective-C selector 的情况:在这种情况下,只需要实现其中的一个要求。

  • override 已被翻译成 completion-handler 和 async 版本的方法有一个类似于协议要求的问题:Swift 子类可以 override completion-handler 的版本或 async 的版本,但不能同时 override 这两个版本。Objective-C 的调用者总是会调用子类版本的方法,但 Swift 的调用者在调用另一个签名的函数时则不会,除非子类的方法被标记为 @objc dynamic。Swift可以将这类方法的 async override 隐式标记为 @objc dynamic,以避免这个问题(因为这类 async 方法是新代码)。但是,在现有的 completion-handler override 上将它推导为 @objc dynamic 会改变程序的行为,并破坏子类的子类,所以编译器最多只能对这种情况发出警告。

修订历史

  • Post-review:

    • await try becomes try await based on result of SE-0296 review
    • Added inference of @discardableResult for async methods translated from completion-handler methods with an optional completion handler.
  • Changes in the second pitch:

    • Removed mention of asynchronous handlers, which will be in a separate proposal.
    • Introduced the swift_async_error Clang attribute to separate out “throwing” behavior from the swift_async attribute.
    • Added support for “Swift private” to the swift_async attribute.
    • Tuned the naming heuristics based on feedback to add (e.g) reply, replyTo, completionBlock, and variants.
    • For the rare case where we match a parameter suffix, append the text prior to the suffix to the base name.
    • Replaced the -generateCGImagesAsynchronouslyForTimes:completionHandler: example with one from PassKit.
    • Added a “Future Directions” section about NSProgress.
  • Original pitch (document and forum thread).

改进方向

NSProgress

一些 Objective-C completion-handler 方法会返回一个 NSProgress,以便调用者评估异步操作的进度。在本提案中,这类方法不回被导入为 async 版本,因为该方法不返回 void。例如:

- (NSProgress *)doSomethingThatTakesALongTimeWithCompletionHandler:(void (^)(MyResult * _Nullable, NSError * _Nullable))completionHandler;

要支持这种方法,就需要在 NSProgress 和 Swift 的任务之间进行某种整合。例如,当调用这样的方法时,从这样的调用中返回的 NSProgress 要记录在任务中(例如,在某种 task-local 的存储中)。还有另一个方向是,Swift 定义的方法 override 一个方法时,则需要从任务中提取一个 NSProgress 来返回。这样的设计不在本提案的范围内,但可以在以后的某个阶段引入。