Swift Digest
SE-0421 | Swift Evolution

Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol

Proposal
SE-0421
Authors
Doug Gregor, Holly Borla
Review Manager
Freddy Kellison-Linn
Status
Implemented (Swift 6.0)

01 何が問題だったのか

AsyncSequenceAsyncIteratorProtocol は本来、throws の有無と actor isolation の両方に対して多相(polymorphic)であることを意図していました。しかし当初のAPI設計では表現力・Sendable チェック・実行時性能のいずれにも無視できない制約が残っていました。

throws 多相が不十分

非同期イテレーションはエラーを投げるものと投げないものがあり、本来は呼び出し側で投げ得るときだけ try を書ければ十分です。これを表現するために、AsyncSequenceAsyncIteratorProtocol は「プロトコル自体の rethrowing 挙動」を捉える実験的機能に頼っていましたが、汎用性に欠けました。

その副作用として、AsyncSequence には primary associated type を導入できませんでした。primary associated type があれば、具体型を隠したうえで要素型を制約付きで公開する、例えば以下のような API が書けるはずです。

extension AsyncSequence {
  // 'AsyncThrowingMapSequence' のような具体型を露出しない
  public func map<Transformed>(
    _ transform: @Sendable @escaping (Element) async throws -> Transformed
  ) -> some AsyncSequence<Transformed, any Error> { ... }
}

actor-isolated なコンテキストから使えない

AsyncIteratorProtocol.next()nonisolated async として定義されているため、呼び出されると常にジェネリックなエグゼキュータ上で実行されます。actor-isolated なコンテキストからの呼び出しは isolation boundary を越える扱いになり、イテレータ自身や戻り値が Sendable でないと strict concurrency checking の下では警告・エラーになります。

class NotSendable { ... }

@MainActor
func iterate(over stream: AsyncStream<NotSendable>) {
  for await element in stream {
    // warning: non-sendable type 'NotSendable?' returned by
    // implicitly asynchronous call to nonisolated function
    // cannot cross actor boundary
  }
}

そもそも多くの具体的なイテレータ型は non-Sendable で、並行にイテレートすることは想定されていません。しかし actor-isolated なコンテキストで形成したイテレータを next() で進めるだけでも、self(イテレータ)が isolation boundary を越えることになり、実用上ほぼ必ず診断に引っかかってしまう状況でした。

不要なエグゼキュータの往復

仮にすべての型が Sendable で警告が出ない場合でも、next() は一度ジェネリックなエグゼキュータに飛んでから要素生成を行うため、actor-isolated なコンテキストから呼ぶと「アクター → ジェネリックなエグゼキュータ → アクター」と毎回往復することになり、不要なサスペンドが挟まっていました。

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

AsyncSequenceAsyncIteratorProtocol を次の2つの方向に一般化します。

  • SE-0413 の typed throws を取り込み、投げるエラー型を Failure associated type として明示する
  • SE-0420 の isolated パラメータを使い、呼び出し元の isolation を引き継ぐ next(isolation:) を新しい要件として追加する

さらに ElementFailure を primary associated type に昇格させ、some AsyncSequence<Int, Never>any AsyncSequence<Element, any Error> のような制約付きの opaque / existential 型を書けるようにします。

@available(SwiftStdlib 5.1, *)
protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element

  mutating func next() async throws -> Element?

  @available(SwiftStdlib 6.0, *)
  associatedtype Failure: Error = any Error

  @available(SwiftStdlib 6.0, *)
  mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
}

@available(SwiftStdlib 5.1, *)
public protocol AsyncSequence<Element, Failure> {
  associatedtype AsyncIterator: AsyncIteratorProtocol
  associatedtype Element where AsyncIterator.Element == Element

  @available(SwiftStdlib 6.0, *)
  associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure

  func makeAsyncIterator() -> AsyncIterator
}

for-in ループはコンテキストの availability が十分であれば、既存の next() ではなく next(isolation:) を呼ぶようにコード生成が切り替わります。そのとき isolation 引数には #isolation が渡されるので、呼び出し元の isolation がそのまま引き継がれます。

typed throws の採用

Failure associated type は next(isolation:) が投げるエラー型を表します。これにより、ライブラリは throwing 版と non-throwing 版の2つの具体型を別々に公開せずとも、Failure を型パラメータにして1つの型で両方のケースを表現できます。

for try await ループのエラー型推論

Failure の値は Swift 6.0 の標準ライブラリ以降でしか witness table から取れません。そのためエラー型の推論は次のように使い分けられます。

Failure が取れる場合(具体型、または availability が揃った generic context)には、ループはその Failure を投げるものとして扱われます。

struct MyAsyncIterator: AsyncIteratorProtocol {
  typealias Failure = MyError
  // ...
}

func iterate<S: AsyncSequence>(over s: S)
where S.AsyncIterator == MyAsyncIterator {
  let closure = {
    for try await element in s {
      print(element)
    }
  }
  // closure: () async throws(MyError) -> Void
}

Failure が取れない場合(古い標準ライブラリを対象にしたコードなど)は any Error にフォールバックします。

@available(SwiftStdlib 5.1, *)
func iterate(over s: some AsyncSequence) {
  let closure = {
    for try await element in s {
      print(element)
    }
  }
  // closure: () async throws(any Error) -> Void
}

FailureNever に制約されているときは、for-in 側で try を書く必要はなくなります。

struct MyAsyncIterator: AsyncIteratorProtocol {
  typealias Failure = Never
  // ...
}

func iterate<S: AsyncSequence>(over s: S)
where S.AsyncIterator == MyAsyncIterator {
  let closure = {
    for await element in s {
      print(element)
    }
  }
  // closure: () async -> Void
}

primary associated type の採用

ElementFailure は primary associated type になり、以下のように制約付きで書けるようになります。

func readAll(_ source: some AsyncSequence<String, Never>) async -> [String] {
  var lines: [String] = []
  for await line in source {
    lines.append(line)
  }
  return lines
}

ただし primary associated type を使った制約は Swift 6.0 標準ライブラリ以降でのみ利用可能です。

isolated パラメータの採用

新しい next(isolation:) は SE-0420 の仕組みに乗って、呼び出し元の isolation を isolated (any Actor)? として受け取ります。for-in ループから暗黙に呼ばれる場合は #isolation が渡されるため、actor-isolated なコンテキストから使ったときもイテレータを isolation boundary の外に出さずに済みます。

@MainActor
func iterate(over stream: AsyncStream<NotSendable>) async {
  // イテレータは MainActor 上に留まり、警告は出ない
  for await element in stream {
    // ...
  }
}

明示的に呼び出す場合は、#isolation を渡して呼び出し元の isolation を引き継ぐか、nil を渡してジェネリックなエグゼキュータ上で実行するかを選べます。なお、SE-0414 の region based isolation により transfer できないイテレータ値については、isolation boundary を越えない呼び出しだけが許されます。

next()next(isolation:) のデフォルト実装

既存の適合型は next() しか実装していないため、標準ライブラリは next(isolation:) のデフォルト実装を用意します。ただし self を isolated な場所から nonisolated な場所へ渡す必要があるので、この実装は内部的に Sendable チェックを回避しており、deprecated 扱いとすることで「自前で next(isolation:) を実装してください」と促します。

extension AsyncIteratorProtocol {
  @available(SwiftStdlib 6.0, *)
  @available(*, deprecated, message: "Provide an implementation of 'next(isolation:)'")
  public mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? {
    nonisolated(unsafe) var unsafeIterator = self
    do {
      let element = try await unsafeIterator.next()
      self = unsafeIterator
      return element
    } catch {
      throw error as! Failure
    }
  }
}

逆向きに、next(isolation:) だけを実装した適合型のために next() のデフォルト実装も提供されます。

extension AsyncIteratorProtocol {
  @available(SwiftStdlib 6.0, *)
  public mutating func next() async throws -> Element? {
    // next() からは常にジェネリックなエグゼキュータ上で next(isolation:) を実行
    try await next(isolation: nil)
  }
}

両方のデフォルト実装が互いに呼び合う形になっているので、どちらも実装しないとプログラマのミスとなります。これを検出するため、witness checker は deprecated / obsoleted / unavailable なデフォルト witness が使われた適合を診断するようになります(deprecated は警告、obsoleted / unavailable はエラー)。結果として、next(isolation:) を実装していない既存コードはビルドは通るものの、deprecation 警告を受け取ることになります。

なお、Swift 6.0 より前の標準ライブラリを対象にする型は next() を必ず自前で実装する必要があります(next() のデフォルト実装が Swift 6.0 以降にしか無いため)。

Failure の推論

適合型が next(isolation:) を実装している場合、Failure は SE-0413 のルールに従ってその関数が投げるエラー型から推論されます(non-throwing なら Never)。next(isolation:) を実装せずデフォルトに任せる場合は、代わりに next() が投げる型から推論されます。

rethrows の取り扱い

実験的な「rethrowing conformances」機能が廃止されることで、以下のように AsyncSequence 適合をエラー源とみなしていた rethrows 関数は本来ill-formedになります。

extension AsyncSequence {
  func contains(_ value: Element) rethrows -> Bool where Element: Hashable { ... }
}

ソース互換性のため、Swift 6 以前に限り、T: AsyncSequence / T: AsyncIteratorProtocol の適合要件は T.Failure を投げ得るソースとみなす、という特別規則が入ります。これにより上記のような contains の定義も引き続き書けます(Swift 6 以降では許されません)。

採用時の見通し

Failure associated type や primary associated type の利用は Swift 6.0 標準ライブラリ以降の availability で制約されます。そのため古い OS を対象にする場合は next(isolation:) のみを実装することはできず、next() との両方を実装する必要があります。

標準ライブラリの具体的なイテレータ型(AsyncStream.Iterator など)が next(isolation:) を直接実装するようになると、actor-isolated なコンテキストからのイテレーションで、ジェネリックなエグゼキュータへの往復が減り、性能面のメリットも得られます。

Future Directions

今後の方向性として、次のような拡張も検討されています(speculative な見通しで、実現を約束するものではありません)。

  • next(isolation:) のデフォルト引数に #isolation を設定できるようにする。現在はプロトコル要件にデフォルト引数が書けませんが、この制限を緩めれば、明示的な呼び出し側もほとんどの場合で isolation 引数を書かずに済むようになります。