変更可能なイテレーターを1つ作成し、値を読み取るたびに await iterator.next() を呼び出します。Mac の小さなコマンドライン計算では、ストリームとイテレーターの両方を繰り返し読み取る処理の外で保持し、nil を完了として扱います。

このチュートリアルでは、メモリ内で作成した整数 4、7、10 を読み取り、それぞれを二乗して、合計が 165 になることを確認します。この計算で使うのは、メモリ内で作成した値だけです。イテレーターのライフタイム、バッファリング、完了を1つの小さなプログラムでわかりやすく示すことが目的です。

値を繰り返し受け取ると、実際には何が再開されるのでしょうか?

Swift では、非同期シーケンスとそのイテレーターは別のものです。シーケンスは makeAsyncIterator() を通じてイテレーターを提供し、イテレーターは next() の呼び出しによって進みます。各呼び出しは要素を返し、反復が終了している場合は nil を返します。Apple はこの契約を AsyncIteratorProtocol で説明しています。

アプリケーションが最初の値を繰り返し受け取る場合、確認すべき操作は2つあります。新しいイテレーターを作成することと、新しいストリームを作成することです。この2つは同じではありません。現在の標準ライブラリ実装では、AsyncStream のイテレーターはストリームの共有コンテキストを保持し、makeAsyncIterator() は既存のコンテキストをイテレーターに渡します。そのため、同じストリームで別のイテレーターを作成しても、先頭から独立して再生されるわけではありません。AsyncStream の公式実装を参照してください。

一方、4、7、10 を生成するファクトリーを呼び出すと、それらの値を含む別のソースが作成されます。読み取りのたびにそのファクトリーを呼び出し、要素を1つだけ読み取ると、毎回 4 を受け取る可能性があります。ゲッターでストリームを作成する計算プロパティでも同じことが起こり得ます。このタスクでは、ソースを一度だけ作成し、計算全体を通して1つのイテレーターを保持してください。

コードをどこへ移すか決めるときは、この違いを考慮してください。ソースを維持するには、ストリームの作成を繰り返し処理の外へ移します。明確な1つの読み取り経路を維持するには、イテレーターの作成もその外へ移します。進行を維持する方法や再開の仕組みとして、イテレーターを繰り返し作成することに頼らないでください。

Mac でローカル計算を最後まで実行する

Swift 6 以降のツールチェーンがインストールされた Mac を使います。Terminal でインストール済みコンパイラーを確認し、新しいディレクトリーに小さな実行可能パッケージを作成します。Swift.org の コマンドラインツールを作成するガイドでは、バージョンの確認、パッケージの作成、swift run の手順を説明しています。

swift --version
mkdir IteratorDemo
cd IteratorDemo
swift package init --name IteratorDemo --type executable

Sources の下に生成された実行可能ソースを探します。内容を次の完全なプログラムに置き換えてください。ソースファイル名は IteratorDemo.swift にします。テンプレートで main.swift が作成されている場合は、名前を変更して元の内容を削除します。実行可能エントリーポイントは1つだけにしてください。Swift.org の説明によると、同じ実行可能ファイルで main.swift エントリーポイントと @main エントリーポイントを併用することはできません。

この例ではインポートは不要です。AsyncStream は Swift に含まれますが、Foundation は別のフレームワークです。この計算では Foundation API を使いません。こうした境界は Apple の AsyncStream ドキュメントと Foundation の概要に記載されています。

@main
struct IteratorDemo {
    static func makeValues() -> AsyncStream<Int> {
        AsyncStream<Int>(bufferingPolicy: .bufferingOldest(3)) {
            continuation in
            for value in [4, 7, 10] {
                let result = continuation.yield(value)
                guard case .enqueued = result else {
                    preconditionFailure("A sample value was not enqueued")
                }
            }
            continuation.finish()
        }
    }

    static func main() async {
        let stream = makeValues()
        var iterator = stream.makeAsyncIterator()
        var received: [Int] = []
        var squareTotal = 0

        for readNumber in 1...3 {
            guard let value = await iterator.next() else {
                preconditionFailure("Stream ended before read \(readNumber)")
            }

            received.append(value)
            squareTotal += value * value
            print("Read \(readNumber): \(value)")
        }

        let end = await iterator.next()
        let stillEnded = await iterator.next()

        precondition(received == [4, 7, 10])
        precondition(squareTotal == 165)
        precondition(end == nil)
        precondition(stillEnded == nil)

        print("Square total: \(squareTotal)")
        print("Finished: \(end == nil)")
    }
}

パッケージのディレクトリーから実行ファイルを実行します。

swift run IteratorDemo

ビルドメッセージの後に、次のアプリケーション出力が表示されます。

Read 1: 4
Read 2: 7
Read 3: 10
Square total: 165
Finished: true

算術は説明用に明示されています。4 × 4 + 7 × 7 + 10 × 10 = 165。アサーションでは、値と順序、計算結果、終了時の動作を正確に確認します。調査環境では Swift コンパイラーを利用できなかったため、このプログラムは確認しましたが、ここでは実行していません。

このイテレーターが正しく進み続けるのはなぜでしょうか?

このプログラムは makeValues() を1回、makeAsyncIterator() を1回呼び出します。3回の読み取りはすべて同じ変更可能な変数を使います。Apple はイテレーターの next() メソッドを変更可能かつ非同期として宣言しています。これが var iterator と await iterator.next() を使う理由です。

生成側は、読み取りが始まる前にストリームの構築中に3つの値を生成します。そのため、バッファー容量はこの例の契約の一部です。.bufferingOldest(3) は3つの整数すべてを保持できる容量があります。すべての yield の結果を確認すると、予期しないエンキューの失敗が見えるため、値が欠けたまま計算が進むことを防げます。Apple は読み取りが待機していない場合、yield(_:) は値をバッファーに格納しようとすると説明しています。

これらの値を生成した後で finish() を呼び出すと、バッファー内の値を読み取れる状態を保ったまま、生成が終了します。採択済みの Swift 提案には、バッファー内の要素は終端の nil より前にコンシューマーへ届くと明記されています。そのため、生成側がすでに終了していても、最初の3回の読み取りは成功します。AsyncStream と AsyncThrowingStream、SE-0314を参照してください。

4回目の読み取りでは、終了済みで空になったストリームから nil が返されます。5回目の読み取りでは、終端の契約を確認します。イテレーターが一度 nil を返した後は、その後の読み取りでも nil を返さなければなりません。nil を、後でもう一度試す指示やイテレーターを作り直す指示と解釈しないでください。AsyncIteratorProtocolでは、これを反復の終了として定義しています。

明示的な読み取りと1つの連続したループを使い分ける

番号付きの計算のように、読み取りのたびに周囲の処理を制御する必要がある場合は、明示的な next() 呼び出しを使います。読み取りセッション全体を担当するスコープでイテレーターを保持してください。呼び出すたびにイテレーターを作成するヘルパーでは、その所有関係がわかりにくくなります。さらにストリームも作り直すヘルパーでは、ソース自体が変わります。

完了まで各要素を処理するだけなら、for await ループを1つ使います。Apple の AsyncStream の概要では、このサポートされた読み取り形式が示されています。サンプルプログラムでは、次のコードを既存の main() メソッド全体と置き換えます。既存の makeValues() メソッドと、それを囲む型はそのままにしてください。

static func main() async {
    let stream = makeValues()
    var received: [Int] = []
    var squareTotal = 0

    for await value in stream {
        received.append(value)
        squareTotal += value * value
    }

    precondition(received == [4, 7, 10])
    precondition(squareTotal == 165)
    print("Square total: \(squareTotal)")
}

このバージョンでは、1つの連続した読み取りセッションを表します。個別の読み取りコマンドが必要ない場合に使ってください。個別の読み取りがタスクの構成要素である場合は、明示的なイテレーターを使います。どちらの方法でも、正しく構築されたソースと明確な終了条件が必要です。

値の繰り返し、欠落、未完了の読み取りを診断する

次の手順で、ソースのライフタイムの問題とバッファリングや完了の問題を切り分けます。動作を追加する前に、修正済みのサンプルへ適用してください。

  1. 4 が繰り返される場合:ソースの作成を確認します。 各処理で再び makeValues() を呼び出していないか、または新しいストリームを作成するゲッターにアクセスしていないかを確認します。ファクトリーの呼び出しは繰り返し読み取りの外に置いてください。
  2. コンシューマーごとに予期しない値になる場合:所有関係を確認します。 この計算では、コンシューマーを1つ、イテレーターを1つ使います。同じストリームに複数のイテレーターを作っても、値の独立したコピーは得られません。
  3. 値が欠ける場合:バッファー容量を確認します。 サンプルでは、読み取りが始まる前に3つの整数を生成します。容量を小さくすると、保持される値が変わります。
  4. 最後の読み取りがいつまでも完了しない場合:完了処理を確認します。 continuation を使うストリームでは、バッファーが空になっただけでは生成は終了しません。有有限の生成側で finish() を呼び出すようにしてください。
  5. 読み取りが重なる場合:読み取りを順番に実行します。 イテレーターで次の読み取りを始める前に、前の読み取りを await します。

この例の条件では、バッファーを変更したときの結果は予測できます。.bufferingOldest(2) の場合、最初の2つの値が残り、新しい値 10 は破棄されます。.bufferingNewest(2) の場合、最も古いバッファー内の値 4 が破棄され、7 と 10 が残ります。これらは Apple が説明する bufferingOldest(_:) と bufferingNewest(_:) のポリシーから、記載された生成順序について導かれる結果です。

どちらのポリシーも、生成側が読み取り側の処理を待つようにはしません。Apple の説明によると、yield(_:) はすぐに戻るため、イテレーターを1つ保持しても、容量不足のバッファーを補うことはできません。この3要素の有限計算では、容量3で十分です。生成パターンが異なる場合は、それに応じて容量を決める必要があります。

イテレーターへのアクセスは順番に行ってください。Apple によると、AsyncStream.Iterator は Sendable に準拠せず、next() の呼び出しが同時に競合するのはプログラマーエラーです。キャンセルも continuation に影響します。値を待っている間にコンシューマーのタスクがキャンセルされると、ストリームは終了すると Apple の next() のドキュメントに記載されています。イテレーターを作り直すことは、キャンセルされたストリームを再開する方法として文書化されていません。

このローカル計算で必要な読み取り契約はシンプルです。ソースを1つ作成し、変更可能なイテレーターを1つ保持し、読み取りを await して順番に行い、バッファー内に3つの値すべてを保持し、nil で停止します。こうすることで進行が明確になり、最終合計を検証可能な意味のある値にできます。

出典