【译】SE-0298 Async/Await 序列
- Proposal: SE-0298
- Authors: Tony Parker, Philippe Hausler
- Review Manager: Doug Gregor
- Status: Implemented (Swift 5.5)
- Implementation: apple/swift#35224
- Decision Notes: Rationale
- Revision: Based on forum discussion
简介
Swift 的 async/await 特性提供了一种直观的、内建的方式来编写和使用在未来某个时间点返回一个值的函数。我们建议在这个特性的基础上,添加一种直观的、内置的方式来编写和使用在一段时间内返回多个值的函数。
本提案由以下三个部分组成:
- 增加一个表示异步序列的协议到标准库里
- 在异步序列上使用
for...in语法的编译器支持 - 对异步序列进行操作的通用函数的标准库实现
动机
我们希望在异步序列上的遍历能够像在同步序列上的遍历一样简单。一个例子是遍历文件的每一行,像这样:for try await line in myFile.lines() {
// Do something with each line
}
使用 Swift 开发者已经熟悉的 for...in 语法可以降低异步 API 的入门门槛。因此,保持与其他 Swift 类型和概念的一致性是我们最重要的目标之一。在这个循环中使用 await 关键字的可以把它与同步序列区分开来。
for/in 语法
为了实现 for in 语法,我们必须将 func lines() 的返回类型定义为可迭代的某些东西(编译器能理解的)。目前我们有 Sequence 协议。可以试着在这里使用它:extension URL {
struct Lines: Sequence { /* ... */ }
func lines() async -> Lines
}
不幸的是,这个函数实际上做的是等待,直到所有行都可用时才返回。在这种情况下,我们真正想要的是 await 每一行。虽然可以想象对 lines 进行修改,使其行为不同(例如,让结果的类型变成引用语义),但最好是定义一个新的协议,使这种迭代行为尽可能简单。extension URL {
struct Lines: AsyncSequence { /* ... */ }
func lines() async -> Lines
}
AsyncSequence 通过在其关联的迭代器类型上定义一个异步 next() 函数,允许对每个元素而不是整个结果进行等待。
新增的 AsyncSequence 函数
再进一步,让我们想象一下,在更多的地方使用我们新的 lines 函数会是什么样子。也许我们想处理每一行,直到其中一行的长度大于一定的长度。let longLine: String?
do {
for try await line in myFile.lines() {
if line.count > 80 {
longLine = line
break
}
}
} catch {
longLine = nil // file didn't exist
}
又或者,我们实际上是想在开始处理之前读取文件中的所有行:var allLines: [String] = []
do {
for try await line in myFile.lines() {
allLines.append(line)
}
} catch {
allLines = []
}
上面的代码没有错,开发者一定可以写出来。然而,对于普通的操作来说,它确实看起来会多很多模版代码。解决这个问题其中一个方式是在 URL 中增加更多的函数:extension URL {
struct Lines : AsyncSequence { }
func lines() -> Lines
func firstLongLine() async throws -> String?
func collectLines() async throws -> [String]
}
不过,可以想象我们可能会在其他地方进行类似的操作。因此,我们认为最好是将这些函数作为 AsyncSequence 本身的扩展,用一种更泛用的方式 – 就像 Sequence 一样。
解决方案
标准库将会添加以下两个协议:public protocol AsyncSequence {
associatedtype AsyncIterator: AsyncIteratorProtocol where AsyncIterator.Element == Element
associatedtype Element
__consuming func makeAsyncIterator() -> AsyncIterator
}
public protocol AsyncIteratorProtocol {
associatedtype Element
mutating func next() async throws -> Element?
}
编译器将会自动生成代码,让我们可以在符合 AsyncSequence 的任何类型上使用 for in 循环。标准库还将扩展协议以提供熟悉的通用算法。下面是一个例子,它实际上并没有在其 next 中调用 async 函数,但是展示了基本的概念:struct Counter : AsyncSequence {
let howHigh: Int
struct AsyncIterator : AsyncIteratorProtocol {
let howHigh: Int
var current = 1
mutating func next() async -> Int? {
// We could use the `Task` API to check for cancellation here and return early.
guard current <= howHigh else {
return nil
}
let result = current
current += 1
return result
}
}
func makeAsyncIterator() -> AsyncIterator {
return AsyncIterator(howHigh: howHigh)
}
}
在调用方会这么使用 counter:for await i in Counter(howHigh: 3) {
print(i)
}
/*
Prints the following, and finishes the loop:
1
2
3
*/
for await i in Counter(howHigh: 3) {
print(i)
if i == 2 { break }
}
/*
Prints the following:
1
2
*/
具体设计
回到我们之前的例子:for try await line in myFile.lines() {
// Do something with each line
}
编译器将生成类似于下面的代码:var it = myFile.lines().makeAsyncIterator()
while let line = try await it.next() {
// Do something with each line
}
所有关于错误处理的常规规则都适用。例如,这个迭代必须被 do/catch 包围,或者在 throws 函数中处理错误。所有关于 await 的常规规则也适用。例如,这个迭代必须在一个允许调用 await 的上下文中,就像一个 async 函数一样。
Cancellation
AsyncIteratorProtocol类型应该使用 structured concurrency 的一部分,Swift 的 Task API 提供的”取消”功能。正如那里面所描述的,迭代器可以选择如何响应“取消”。最常见的行为是抛出 CancellationError 或者让迭代器返回 nil。
如果一个 AsyncIteratorProtocol 类型在取消时要清理资源,它可以在这两个地方进行:
- 使用
TaskAPI 检查是否取消之后。 - 在其
deinit中(如果它是 class 类型)。
Rethrows
该提案将利用另一项提案,在协议中增加专门的 rethrows 一致性,该提案已在这里提出。根据该提案对 rethrows 的修改,当迭代一个本身不抛出的 AsyncSequence 时,将不需要使用 try。
而 await 总是需要有的,因为协议的定义里它总是异步的。
结束迭代
在 AsyncIteratorProtocol 类型的 next() 方法返回 nil 或抛出错误之后,后续所有对 next() 调用都必须返回 nil。 与 IteratorProtocol 类型的行为保持一致,这很重要,因为调用迭代器的 next() 方法是确定迭代是否完成的唯一方法。
AsyncSequence 函数
标准的 AsyncSequence 协议的存在使我们能够为任何符合该协议的类型编写通用算法。有两类函数:返回一个单一值的函数(因此被标记为 async),和返回一个新的 AsyncSequence 的函数(本身没有标记为 async)。
返回单个值的函数特别有趣,因为它们可以将一个循环改为一行 await,增加了可用性。例如 first、contains、min、max、reduce 等。返回一个新的 AsyncSequence 的函数有 filter、map、compactMap。
将 AsyncSequence 转换为一个值
将 for 循环缩减为一次调用的算法可以提高代码的可读性。它们消除了设置和迭代循环所需的模板。
例如,下面是 contains 函数:extension AsyncSequence where Element : Equatable {
public func contains(_ value: Element) async rethrows -> Bool
}
通过这个扩展,我们前面的”超过 80 个字符的第一行”的例子就可以简化成这样:let first = try? await myFile.lines().first(where: { $0.count > 80 })
或者,如果该序列应该被异步处理并且在之后才会使用:async let first = myFile.lines().first(where: { $0.count > 80 })
// later
warnAboutLongLine(try? await first)
AsyncSequence 将增加以下功能:
| 函数 | 备注 |
|---|---|
contains(_ value: Element) async rethrows -> Bool | Element 需要遵循 Equatable |
contains(where: (Element) async throws -> Bool) async rethrows -> Bool | 闭包的 async 是可选的 |
allSatisfy(_ predicate: (Element) async throws -> Bool) async rethrows -> Bool | |
first(where: (Element) async throws -> Bool) async rethrows -> Element? | |
min() async rethrows -> Element? | Element 需要遵循 Comparable |
min(by: (Element, Element) async throws -> Bool) async rethrows -> Element? | |
max() async rethrows -> Element? | Element 需要遵循 Comparable |
max(by: (Element, Element) async throws -> Bool) async rethrows -> Element? | |
reduce<T>(_ initialResult: T, _ nextPartialResult: (T, Element) async throws -> T) async rethrows -> T | |
reduce<T>(into initialResult: T, _ updateAccumulatingResult: (inout T, Element) async throws -> ()) async rethrows -> T |
将 AsyncSequence 转换为另一个 AsyncSequence
这些关于 AsyncSequence 的函数会返回一个结果,这个结果本身就是一个 AsyncSequence。由于 AsyncSequence 的异步性质,其行为在许多方面与标准库中现有的 Lazy 类型相似。调用这些函数并不急于 await 序列中的下一个值,而是由调用者决定何时开始该工作,只需在准备好时开始迭代即可。
举个例子,让我们看看 map:extension AsyncSequence {
public func map<Transformed>(
_ transform: @escaping (Element) async throws -> Transformed
) -> AsyncMapSequence<Self, Transformed>
}
public struct AsyncMapSequence<Upstream: AsyncSequence, Transformed>: AsyncSequence {
public let upstream: Upstream
public let transform: (Upstream.Element) async throws -> Transformed
public struct Iterator : AsyncIterator {
public mutating func next() async rethrows -> Transformed?
}
}
对于这种函数,我们首先定义一个符合 AsyncSequence 协议的类型,这个名字是仿照现有的标准库 Sequence 类型,如 LazyDropWhileCollection 和 LazyMapSequence。这个名字是仿照现有的标准库 Sequence 类型,如 LazyDropWhileCollection 和 LazyMapSequence。然后,我们在 AsyncSequence 上的扩展中添加一个函数,该函数创建新的类型(使用 self 作为 upstream)并返回。
| 函数 |
|---|
map<T>(_ transform: (Element) async throws -> T) -> AsyncMapSequence |
compactMap<T>(_ transform: (Element) async throws -> T?) -> AsyncCompactMapSequence |
flatMap<SegmentOfResult: AsyncSequence>(_ transform: (Element) async throws -> SegmentOfResult) async rethrows -> AsyncFlatMapSequence |
drop(while: (Element) async throws -> Bool) async rethrows -> AsyncDropWhileSequence |
dropFirst(_ n: Int) async rethrows -> AsyncDropFirstSequence |
prefix(while: (Element) async throws -> Bool) async rethrows -> AsyncPrefixWhileSequence |
prefix(_ n: Int) async rethrows -> AsyncPrefixSequence |
filter(_ predicate: (Element) async throws -> Bool) async rethrows -> AsyncFilterSequence |
未来的改进方向
以下是我们认为重要且值得在今后的提案中讨论的话题:
补充更多 AsyncSequence 函数
这个提案的目标是与最相关的 Sequence 函数保持一致。可能还有其他值得在今后的提案中添加的功能。
使用时间作为参数的 API 必须与结构化并发提案中的 Executor 一起进行讨论。
我们希望有一个 first 属性,但目前 Swift 里的属性不能标记为 async 或 throws。目前正在讨论在语言中增加一种能力,以允许对属性产生影响。如果这些功能成为了 Swift 的一部分,那么我们应该在 AsyncSequence 中添加一个 first 属性。
AsyncSequence Builder
在标准库中,我们不仅有 Sequence 和 Collection 协议,还有采用这些协议的具体类型(例如 Array)。我们需要为 AsyncSequence 提供一个类似的 API,以便在需要的时候很容易地构造一个具体的实例,而不需要声明一个新的类型和增加协议的一致性。
代码兼容性
这个新功能将与现有的 Swift 代码兼容。
对于 ABI 稳定性的影响
这里的修改是对 ABI 的补充性修改。
对于 API 兼容性的影响
这里的修改是对 API 的补充性修改。
其它方案
显式 Cancellation
该提案的早期版本包括一个明确的 cancel 功能。出于以下原因,我们决定将它删除掉:
- 减少实现
AsyncIteratorProtocol的要求,使其更容易使用和理解。关于什么时候调用cancel的规则,虽然简单明了,但对于 Swift 开发者来说,还是要多学一样东西。 - 结构化并发提案中已经包含了一个对
AsyncSequence取消的明确定义。我们应该把异步代码的取消行为整体视为一个概念。
异步 Cancellation
如果我们使用显式取消,迭代器的 cancel() 函数可以标记为 async。然而,这意味着在离开 for/in 循环时进行的隐式取消将需要一个隐式的 await – 我们认为这对开发者来说可能太过隐蔽。大多数的取消行为会像设置一个标志以便稍后检查一样简单,所以我们让它作为一个同步函数存在,鼓励采用者让取消行为可以快速执行并且不会阻塞。
Opaque 类型
每个 AsyncSequence 到 AsyncSequence 的算法都会定义自己的具体类型。我们可以尝试将这些细节隐藏在一个通用的类型擦除器后面。我们相信让类型暴露出来会给我们(和编译器)更多的优化机会。未来的一个潜在的巨大改进是让语言支持 some AsyncSequence where Element=... 风格的语法,允许在 API 里隐藏具体的 AsyncSequence 类型。
复用 Sequence
如果语言支持 reasync 概念,那么 AsyncSequence 和 Sequence API 合并起来似乎是合理的。然而,我们认为,将其视为两种不同的类型仍然是有价值的。异步代码中增加了时间维度的复杂性,这意味着一些函数需要更多的配置选项或更复杂的实现。一些在异步序列上有用的算法在同步序列上是没有意义的。在这些情况下,我们最好不要让同步集合类型的 API 复杂化。
命名
具体的 AsyncSequence 类型的名称被设计为镜像现有的标准库 API,如 LazyMapSequence。另一种选择是用一个空的枚举或其他命名机制引入一个新的模式。
我们考虑过 AsyncGenerator,但希望保留 Generator 这个名称给后续的功能实用。Stream 是Foundation 中的一个类型,因此我们没有在这里重复使用,避免混淆。
await in
我们考虑过一个更短的 await...in 的语法。然而,由于这里的行为从根本上说是一个循环,我们认为必须使用现有的 for 关键字作为对代码读者的强烈信号。虽然有很多关键字,但它们每一个关键字对代码的读者来说都有目的和意义。
添加 API 到 Iterator 里(而不是 Sequence)
我们讨论了将基本 API(map、reduce 等)应用于 AsyncIteratorProtocol 而不是 AsyncSequence。在 Sequence API 中一直存在着一个长期的(虽然是故意的)歧义 – 它到底应该是一次还是多次循环?这个新的迭代器和序列提供了一个更具体地定义这个问题的机会。
虽然很想用这个新的API来纠正过去的错误,但我们认为,与现有 Swift 概念保持一致的原则性目标更为重要。
例如,for...in 不能用于 IteratorProtocol – 只能用于 Sequence。如果我们选择让 AsyncIteratorProtocol 使用这里描述的 for...in,那我们就只能选择在 AsyncIteratorProtocol 和 IteratorProtocol 之间引入不一致的行为,或者放弃熟悉的 for...in 语法。即使我们决定在 IteratorProtocol 中加入 for...in,它仍然是不一致的,因为我们需要在现有的 Sequence 上留下 for...in 语法。
倾向于保持一致性的另一个原因是,实现 AsyncSequence 对于任何知道如何实现 Sequence 的人来说都会感到熟悉。
我们希望在 API 中广泛采用该协议,通常情况下,API 会使用 Notification、delegate 或者回调。在大部分这样的情况下,我们觉得 API 应该返回 “工厂类型”(一个 AsyncSequence),以便它可以再次迭代。调用者仍然需要知道执行该操作的任何基本成本,就像今天任何 Sequence 的迭代一样。