Swift Digest
SE-0298 | Swift Evolution

Async/Await: Sequences

Proposal
SE-0298
Authors
Tony Parker, Philippe Hausler
Review Manager
Doug Gregor
Status
Implemented (Swift 5.5)

01 何が問題だったのか

SE-0296(Async/await)は、将来のある時点で「ひとつの値」を返す関数を書きやすくする仕組みでした。一方で、時間をかけて「複数の値」を順に返していく処理も数多くあります。たとえばファイルから一行ずつ読む処理や、ネットワークから届くイベントの列、通知の配信などが典型例です。

同期的な世界には Sequence プロトコルと for...in 構文があり、要素を順に取り出してループで処理できます。これと同じように、非同期な値の列を扱いたくなります。理想的には、次のように書けると自然です。

for try await line in myFile.lines() {
  // 各行に対する処理
}

しかし、既存の Sequence プロトコルをそのまま使って非同期な列を表現しようとするとうまくいきません。たとえば次のように書いたとします。

extension URL {
  struct Lines: Sequence { /* ... */ }
  func lines() async -> Lines
}

この形だと、lines()すべての行が揃うまで待ってから Lines を返すことになります。本当にしたいのは「各行ごとに」待つことです。Sequencenext() は同期メソッドなので、1 要素ずつ非同期に待つ動作を素直には表現できません。

したがって、同期的な Sequence とは別に、各要素の取り出しが async である新しいプロトコルと、それに対する for...in サポートが必要でした。さらに、そのプロトコルに対して map / filter / reduce のような汎用アルゴリズムが最初から用意されていないと、利用側に大量の決まり文句(ループで溜めて返す、条件に当てはまる最初の要素を探す、など)を書かせることになってしまいます。

// こうした決まり文句を毎回書きたくない
var allLines: [String] = []
do {
  for try await line in myFile.lines() {
    allLines.append(line)
  }
} catch {
  allLines = []
}

こうした背景から、次の 3 つをまとめて解決する仕組みが求められました。

  • 非同期な値の列を表す標準のプロトコルを用意する
  • そのプロトコルに対して for...in 構文を使えるようにする
  • Sequence と同様の汎用アルゴリズム(mapfilterreduce など)を標準ライブラリで提供する

02 どのように解決されるのか

標準ライブラリに AsyncSequenceAsyncIteratorProtocol の 2 つのプロトコルを追加し、for...in 構文でそれらを反復できるようコンパイラを拡張します。あわせて、Sequence に倣った汎用アルゴリズムを AsyncSequence の拡張として提供します。

AsyncSequence / AsyncIteratorProtocol の定義

2 つのプロトコルは次のように定義されます。イテレータの next()async throws になっている点が、同期版との本質的な違いです。

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

適合する側は、同期の Sequence / IteratorProtocol を実装するのとほぼ同じ感覚で書けます。以下はカウントアップするだけの例で、next() 内では実際には非同期処理を行っていませんが、プロトコル適合の最小形を示しています。

struct Counter: AsyncSequence {
  let howHigh: Int

  struct AsyncIterator: AsyncIteratorProtocol {
    let howHigh: Int
    var current = 1
    mutating func next() async -> Int? {
      guard current <= howHigh else { return nil }
      let result = current
      current += 1
      return result
    }
  }

  func makeAsyncIterator() -> AsyncIterator {
    AsyncIterator(howHigh: howHigh)
  }
}

for await...in による反復

AsyncSequence に適合する型は for await...in(エラーを送出しうる場合は for try await...in)で反復できます。

for await i in Counter(howHigh: 3) {
  print(i)
}
// 1, 2, 3 を順に出力

for await i in Counter(howHigh: 3) {
  print(i)
  if i == 2 { break }
}
// 1, 2 まで出力して終了

コンパイラはこのループを、次のような等価コードに展開します。

var it = myFile.lines().makeAsyncIterator()
while let line = try await it.next() {
  // 各行に対する処理
}

await は常に必要です(プロトコルの定義上、反復が必ず非同期だからです)。エラー処理は通常の Swift のルールに従い、do/catch で囲むか、throws な関数の中で呼び出します。

AsyncSequence 自体がエラーを送出しない場合は try が不要になる、というのが意図された挙動で、専用の rethrows 適合の仕組みによって実現されます。

反復の終端

AsyncIteratorProtocolnext() が一度 nil を返すかエラーを送出したあとは、以降の next() の呼び出しも必ず nil を返さなければなりません。イテレーションが終わったかを知る手段は next() を呼ぶことだけなので、終端状態が安定していないと利用側が混乱するためです。これは同期の IteratorProtocol の契約と揃えられています。

キャンセル

キャンセルは SE-0304(Structured Concurrency)の Task API に委ねられます。AsyncIteratorProtocol の実装側は Task のキャンセル状態を見て、CancellationError を送出するか nil を返すかを自由に選べます。クリーンアップが必要なら、キャンセルを検知したタイミングで行うか、クラス型であれば deinit で行います。独自の cancel() メソッドは用意しません。

単一の値に畳み込むアルゴリズム

AsyncSequence にはループを 1 行に置き換える畳み込み系アルゴリズムが一通り用意されます。たとえば「最初に長さが 80 を超えた行」を探すコードは、ループを書かず次のように 1 行で表現できます。

let first = try? await myFile.lines().first(where: { $0.count > 80 })

async let と組み合わせて、結果を先に予約しておき必要になった時点で待つこともできます。

async let first = myFile.lines().first(where: { $0.count > 80 })

// 別の処理をした後で
warnAboutLongLine(try? await first)

この分類には次の関数が含まれます。

関数 補足
contains(_ value: Element) async rethrows -> Bool 要素が Equatable
contains(where: (Element) async throws -> Bool) async rethrows -> Bool クロージャは同期・非同期どちらでも可
allSatisfy(_ predicate: (Element) async throws -> Bool) async rethrows -> Bool  
first(where: (Element) async throws -> Bool) async rethrows -> Element?  
min() async rethrows -> Element? 要素が Comparable
min(by: (Element, Element) async throws -> Bool) async rethrows -> Element?  
max() async rethrows -> 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 を返す変換系アルゴリズムです。map / filter / compactMap のように、標準ライブラリの Lazy 系に近い挙動で、呼んだ時点では次の要素を先読みしません。イテレーションを開始した時点で初めて仕事が進みます。

map を例にすると、シグネチャは次のようになります。各アルゴリズムは、それぞれ専用の具象型(AsyncMapSequence など)を返します。

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: AsyncIteratorProtocol {
    public mutating func next() async rethrows -> Transformed?
  }
}

この分類には次の関数が含まれます。

関数
map<T>(_ transform: (Element) async throws -> T) -> AsyncMapSequence
compactMap<T>(_ transform: (Element) async throws -> T?) -> AsyncCompactMapSequence
flatMap<SegmentOfResult: AsyncSequence>(_ transform: (Element) async throws -> SegmentOfResult) -> AsyncFlatMapSequence
drop(while: (Element) async throws -> Bool) -> AsyncDropWhileSequence
dropFirst(_ n: Int) -> AsyncDropFirstSequence
prefix(while: (Element) async throws -> Bool) -> AsyncPrefixWhileSequence
prefix(_ n: Int) -> AsyncPrefixSequence
filter(_ predicate: (Element) async throws -> Bool) -> AsyncFilterSequence

具象型を露出させているのは、型消去を挟むより最適化の余地が広いためです。将来的に some AsyncSequence where Element == ... のような構文が整備されれば、API 境界で具象型を隠せるようになる余地があります。

Future Directions

以下は今回のスコープ外で、今後の Proposal で検討される予定の項目です。実現を約束するものではありません。

  • Sequence にあるが今回含まれなかったアルゴリズムの追加。時間を引数に取る API は、エグゼキュータの設計(SE-0304)との調整が必要になります。
  • firstasync throws なプロパティとして提供する案。ただし現時点ではプロパティに async / throws を付けられないため、言語機能の拡張が前提になります。
  • 新しい型を定義せずに具体的な AsyncSequence インスタンスを組み立てられる「ビルダー」 API(Sequence に対する Array のような位置づけのもの)。