Erstelle einen einzigen veränderlichen Iterator und rufe für jeden aufeinanderfolgenden Wert await iterator.next() auf. Bei einer kleinen Mac-Kommandozeilenberechnung sollten Stream und Iterator außerhalb des wiederholten Lesevorgangs bleiben; behandle nil als Abschluss.

In dieser Anleitung werden die synthetischen Ganzzahlen 4, 7 und 10 verarbeitet, jede Zahl quadriert und geprüft, ob die Summe 165 beträgt. Die Berechnung verwendet ausschließlich Werte, die im Arbeitsspeicher erzeugt wurden. Sie soll in einem kleinen Programm die Lebensdauer des Iterators, die Pufferung und den Abschluss veranschaulichen.

Was wird tatsächlich neu gestartet, wenn sich Werte wiederholen?

Swift unterscheidet zwischen einer asynchronen Sequenz und ihrem Iterator. Die Sequenz stellt über makeAsyncIterator() einen Iterator bereit, der mit Aufrufen von next() fortschreitet. Jeder Aufruf gibt ein Element zurück oder nil, wenn die Iteration beendet ist. Apple dokumentiert diesen Vertrag in AsyncIteratorProtocol.

Wenn eine Anwendung wiederholt den ersten Wert empfängt, müssen zwei unterschiedliche Vorgänge betrachtet werden: das Erzeugen eines weiteren Iterators und das Erzeugen eines weiteren Streams. Sie sind nicht austauschbar. In der aktuellen Implementierung der Standardbibliothek hält ein AsyncStream-Iterator den gemeinsam genutzten Kontext des Streams, und makeAsyncIterator() übergibt diesen vorhandenen Kontext an den Iterator. Ein weiterer Iterator für denselben Stream beginnt daher nicht unabhängig wieder von vorn. Siehe die offizielle AsyncStream-Implementierung.

Ein Fabrikaufruf hingegen, der einen neuen Stream erstellt und 4, 7 und 10 ausgibt, erzeugt eine weitere Quelle mit diesen Werten. Wenn jeder Lesevorgang diese Fabrik aufruft und nur ein Element verarbeitet, kann jeder Lesevorgang 4 erhalten. Eine berechnete Eigenschaft, die in ihrem Getter einen Stream erstellt, kann denselben Effekt haben. Erstelle für diese Aufgabe die Quelle einmal und behalte für die gesamte Berechnung einen Iterator bei.

Beachte diese Unterscheidung, wenn du entscheidest, wohin du Code verschieben solltest: Verschiebe die Stream-Erstellung aus dem wiederholten Vorgang heraus, damit die Quelle erhalten bleibt, und verschiebe die Iterator-Erstellung ebenfalls aus diesem Vorgang heraus, damit ein klarer Verarbeitungspfad bestehen bleibt. Verlasse dich nicht darauf, durch wiederholtes Erzeugen von Iteratoren entweder einen Neustart oder einen Fortschritt zu erreichen.

Eine vollständige lokale Berechnung auf dem Mac ausführen

Verwende einen Mac mit einer Swift-6-Toolchain oder neuer. Prüfe in Terminal den installierten Compiler und erstelle dann in einem neuen Verzeichnis ein kleines ausführbares Paket. Swift.org beschreibt in der Anleitung Ein Kommandozeilenwerkzeug erstellen die Versionsprüfung, die Paketerstellung und den swift run-Ablauf.

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

Suche den generierten Quellcode der ausführbaren Datei unter Sources. Ersetze seinen Inhalt durch das vollständige Programm unten. Nenne die Quelldatei IteratorDemo.swift; falls die Vorlage main.swift erstellt hat, benenne sie um und entferne ihren ursprünglichen Inhalt. Verwende nur einen Einstiegspunkt für die ausführbare Datei. Swift.org dokumentiert, dass ein Einstiegspunkt mit main.swift und ein Einstiegspunkt mit @main nicht in derselben ausführbaren Datei kombiniert werden können.

Für dieses Beispiel sind keine Imports erforderlich: AsyncStream gehört zu Swift, während Foundation ein separates Framework ist. Die Berechnung benötigt keine Foundation-API. Diese Abgrenzung wird in Apples AsyncStream-Dokumentation und Foundation-Übersicht erläutert.

@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)")
    }
}

Führe die ausführbare Datei aus dem Paketverzeichnis aus:

swift run IteratorDemo

Die erwartete Ausgabe des Programms nach eventuellen Build-Meldungen lautet:

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

Die Rechenoperation ist ausdrücklich nur ein Beispiel: 4 × 4 + 7 × 7 + 10 × 10 = 165. Die Assertions prüfen die genauen Werte, ihre Reihenfolge, die Berechnung und das Endverhalten. In der Forschungsumgebung war kein Swift-Compiler verfügbar; das Programm wurde daher geprüft, aber hier nicht ausgeführt.

Warum wird dieser Iterator korrekt fortgesetzt?

Das Programm ruft makeValues() einmal und makeAsyncIterator() einmal auf. Für alle drei Lesevorgänge wird dieselbe veränderliche Variable verwendet. Apple deklariert die Methode next() des Iterators als mutierend und asynchron. Das erklärt sowohl var iterator als auch await iterator.next().

Der Producer gibt seine drei Werte beim Erstellen des Streams aus, bevor die Verarbeitung beginnt. Deshalb ist die Pufferkapazität Teil des Vertrags dieses Beispiels: .bufferingOldest(3) bietet Platz für alle drei Ganzzahlen. Indem das Ergebnis jedes yield geprüft wird, wird ein unerwarteter Fehler beim Einreihen sichtbar, statt die Berechnung mit fehlenden Werten fortzusetzen. Apple dokumentiert, dass yield(_:) versucht, einen Wert zu puffern, wenn kein Lesevorgang wartet.

Ein Aufruf von finish() nach diesen Ausgaben beendet die Produktion, lässt aber die Verarbeitung gepufferter Werte zu. Der angenommene Swift-Vorschlag besagt ausdrücklich, dass gepufferte Elemente den Consumer vor dem abschließenden nil erreichen. Deshalb sind die ersten drei Lesevorgänge erfolgreich, obwohl der Producer bereits fertig ist. Siehe AsyncStream und AsyncThrowingStream, SE-0314.

Beim vierten Lesevorgang ist der Stream abgeschlossen und erschöpft; er gibt nil zurück. Der fünfte bestätigt den Endvertrag: Sobald ein Iterator nil zurückgegeben hat, müssen auch alle folgenden Lesevorgänge nil zurückgeben. Interpretiere nil nicht als Aufforderung, es später erneut zu versuchen oder einen Iterator neu zu erstellen; AsyncIteratorProtocol definiert es als Ende der Iteration.

Explizite Lesevorgänge oder eine fortlaufende Schleife wählen

Verwende explizite Aufrufe von next(), wenn der umgebende Vorgang zwischen einzelnen Lesevorgängen Kontrolle benötigt, wie bei der nummerierten Berechnung. Behalte den Iterator in dem Gültigkeitsbereich, der die gesamte Verarbeitung steuert. Ein Hilfsprogramm, das bei jedem Aufruf seinen eigenen Iterator erstellt, verschleiert diese Zuständigkeit; ein Hilfsprogramm, das außerdem den Stream neu erstellt, verändert die Quelle selbst.

Wenn die Aufgabe lediglich darin besteht, jedes Element bis zum Abschluss zu verarbeiten, verwende eine einzige for await-Schleife. Apples AsyncStream-Übersicht zeigt diese unterstützte Verarbeitungsform. Für das Beispielprogramm ersetzt der folgende Codeausschnitt die gesamte bestehende Methode main(); die vorhandene Methode makeValues() und der umgebende Typ bleiben erhalten:

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)")
}

Diese Variante drückt eine einzige fortlaufende Verarbeitung aus. Wähle sie, wenn keine einzelnen Leseaufträge erforderlich sind. Verwende den expliziten Iterator, wenn einzelne Lesevorgänge Teil der Aufgabenstruktur sind. Beide Ansätze benötigen weiterhin eine korrekt erstellte Quelle und ein festgelegtes Ende.

Wiederholte, fehlende oder nicht abgeschlossene Lesevorgänge diagnostizieren

Mit der folgenden Abfolge lassen sich Probleme mit der Lebensdauer der Quelle von Problemen mit Pufferung und Abschluss unterscheiden. Wende sie auf das unveränderte Beispiel an, bevor du weitere Funktionen hinzufügst.

  1. Wiederholtes 4: Prüfe die Quellerstellung. Prüfe, ob jeder Vorgang erneut makeValues() aufruft oder auf einen Getter zugreift, der einen neuen Stream erstellt. Der Fabrikaufruf sollte außerhalb der wiederholten Lesevorgänge liegen.
  2. Unerwartete Werte bei mehreren Consumern: Prüfe die Zuständigkeit. Verwende für diese Berechnung einen Consumer und einen Iterator. Mehrere Iteratoren für denselben Stream liefern keine unabhängigen Kopien seiner Werte.
  3. Fehlende Werte: Prüfe die Pufferkapazität. Im Beispiel werden alle drei Ganzzahlen vor der Verarbeitung erzeugt. Ein kleinerer Puffer verändert, welche Werte verfügbar bleiben.
  4. Ein abschließender Lesevorgang, der nie beendet wird: Prüfe den Abschluss. Bei einem Stream auf Basis einer Continuation beendet ein leerer Puffer allein die Produktion nicht. Stelle sicher, dass der endliche Producer finish() aufruft.
  5. Überlappende Lesevorgänge: Verarbeite sie nacheinander. Warte einen Lesevorgang ab, bevor du einen weiteren für den Iterator beginnst.

Änderungen am Puffer haben unter den Bedingungen dieses Beispiels vorhersehbare Folgen. Bei .bufferingOldest(2) bleiben die ersten beiden Werte erhalten und der neue Wert 10 wird verworfen. Bei .bufferingNewest(2) wird der älteste gepufferte Wert 4 verworfen, sodass 7 und 10 verbleiben. Diese Schlussfolgerungen gelten für die angegebene Reihenfolge der Ausgabe und beruhen auf Apples dokumentierten Richtlinien bufferingOldest(_:) und bufferingNewest(_:).

Keine der beiden Richtlinien lässt den Producer auf die Verarbeitung warten. Apple dokumentiert, dass yield(_:) sofort zurückkehrt. Ein beibehaltener Iterator kann daher einen zu kleinen Puffer nicht ausgleichen. Für diese endliche Berechnung mit drei Elementen reicht eine Kapazität von drei aus; bei einem anderen Ausgabemuster muss die Kapazität gesondert festgelegt werden.

Greife nacheinander auf den Iterator zu. Apple erklärt, dass AsyncStream.Iterator nicht Sendable entspricht und dass konkurrierende Aufrufe von next() einen Programmierfehler darstellen. Auch eine Abbruchanforderung wirkt sich auf die Continuation aus: Wird der verarbeitende Task abgebrochen, während er auf einen Wert wartet, wird der Stream laut Apples Dokumentation zu next() beendet. Einen weiteren Iterator zu erstellen ist keine dokumentierte Möglichkeit, diesen abgebrochenen Stream fortzusetzen.

Für diese lokale Berechnung ist der vollständige Verarbeitungsvertrag überschaubar: eine Quelle erstellen, einen veränderlichen Iterator beibehalten, Lesevorgänge nacheinander abwarten, alle drei Werte im Puffer erhalten und bei nil stoppen. So wird der Fortschritt nachvollziehbar und die Endsumme überprüfbar.

Quellen