Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol
01 何が問題だったのか
AsyncSequence と AsyncIteratorProtocol は本来、throws の有無と actor isolation の両方に対して多相(polymorphic)であることを意図していました。しかし当初のAPI設計では表現力・Sendable チェック・実行時性能のいずれにも無視できない制約が残っていました。
throws 多相が不十分
非同期イテレーションはエラーを投げるものと投げないものがあり、本来は呼び出し側で投げ得るときだけ try を書ければ十分です。これを表現するために、AsyncSequence と AsyncIteratorProtocol は「プロトコル自体の 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 どのように解決されるのか
AsyncSequence と AsyncIteratorProtocol を次の2つの方向に一般化します。
- SE-0413 の typed throws を取り込み、投げるエラー型を
Failureassociated type として明示する - SE-0420 の
isolatedパラメータを使い、呼び出し元の isolation を引き継ぐnext(isolation:)を新しい要件として追加する
さらに Element と Failure を 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
}
Failure が Never に制約されているときは、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 の採用
Element と Failure は 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 引数を書かずに済むようになります。