Swift Digest
SE-0388 | Swift Evolution

Convenience Async[Throwing]Stream.makeStream methods

Proposal
SE-0388
Authors
Franz Busch
Review Manager
Becca Royal-Gordon
Status
Implemented (Swift 5.9)

01 何が問題だったのか

SE-0314 で導入された AsyncStream / AsyncThrowingStream は、標準ライブラリが提供する根本的な AsyncSequence として広く使われるようになりました。しかし、実際に使ってみると、ストリームとそのcontinuationを別々の場所(プロデューサとコンシューマ)に渡したい場面が頻繁に現れます。

従来のイニシャライザはクロージャ内でcontinuationを受け取る設計のため、continuationを外に取り出すにはクロージャの外の変数に代入して脱出させる必要がありました。これには次のような使いにくさがあります。

  • continuationを外に持ち出すため、implicitly unwrapped optional の一時変数を用意して代入する、という手順を踏む必要があります。
  • Sendability警告を避けるために、代入後にもう一度 let へコピーするといった書き換えも必要になりがちです。
  • クロージャで受け取る形はcontinuationの寿命がクロージャのスコープに閉じているように読めてしまいますが、実際にはそうではなく、APIの意図が誤解されやすい状態でした。
var cont: AsyncStream<Int>.Continuation!
let stream = AsyncStream<Int> { cont = $0 }
// Sendability警告を避けるため let にコピー
let continuation = cont

await withTaskGroup(of: Void.self) { group in
  group.addTask {
    for i in 0...9 {
      continuation.yield(i)
    }
    continuation.finish()
  }

  group.addTask {
    for await i in stream {
      print(i)
    }
  }
}

ストリームとcontinuationを別々の役割に渡す、という典型的な使い方を、もっと素直に書ける入口が欠けていました。

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

AsyncStreamAsyncThrowingStream に、ストリームとcontinuationの両方をタプルで返す静的ファクトリメソッド makeStream(of:) を追加します。戻り値を分解代入することで、プロデューサとコンシューマに自然にそれぞれを渡せるようになります。

let (stream, continuation) = AsyncStream.makeStream(of: Int.self)

await withTaskGroup(of: Void.self) { group in
  group.addTask {
    for i in 0...9 {
      continuation.yield(i)
    }
    continuation.finish()
  }

  group.addTask {
    for await i in stream {
      print(i)
    }
  }
}

シグネチャ

AsyncStream 側は要素型とバッファリングポリシーを取り、AsyncThrowingStream 側はさらに失敗型を受け取ります。AsyncThrowingStreammakeStreamFailure == Error のときに限って使えます。

extension AsyncStream {
  public static func makeStream(
    of elementType: Element.Type = Element.self,
    bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
  ) -> (stream: AsyncStream<Element>, continuation: AsyncStream<Element>.Continuation)
}

extension AsyncThrowingStream {
  public static func makeStream(
    of elementType: Element.Type = Element.self,
    throwing failureType: Failure.Type = Failure.self,
    bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
  ) -> (
    stream: AsyncThrowingStream<Element, Failure>,
    continuation: AsyncThrowingStream<Element, Failure>.Continuation
  ) where Failure == Error
}

いずれも @backDeployed(before: SwiftStdlib 5.9) が付いており、Swift 5.9より前のランタイムでも利用できます。

なぜタプルで返すのか

初期のピッチでは専用の具体型を返す案もありましたが、最終的にタプルが選ばれています。タプルで返すことには次の利点があります。

  • 戻り値を分解代入することを自然に促し、continuationをプロデューサ側、streamをコンシューマ側がそれぞれ保持する、という使い方へ誘導できます。
  • 具体型を新しく追加する必要がないため、古いSwiftランタイムへのback deployが可能になります。

また、「AsyncStream.init() にあらかじめ作ったcontinuationを渡せるようにする」という案も検討されましたが、同じcontinuationを複数のストリームに渡せてしまうことや、どのストリームにも渡されないcontinuationが作れてしまうことから採用されていません。AsyncStream.Continuation は1つの AsyncStream と密に結びついた存在であり、その関係をAPI上でも表現することが重視されています。