Review von KI-Code: Ich prüfe die Absicht, nicht den Diff
Generierter Code ist selten schlampig, er ist zu aufwendig. Das fällt im Diff kaum auf, deshalb liegt der Prüfpunkt bei mir vor der ersten Zeile Code.
· 8 min
Andrey Gershengoren · · 9 min
Die Frage kommt in jedem Bestandsprojekt irgendwann: Migrieren wir auf async/await? Und sie wird fast immer falsch gestellt, nämlich als Ja-Nein-Frage über das ganze Projekt. Meine Antwort ist inzwischen eine Gegenfrage: An welcher Stelle genau, und wird dort ein Wert geliefert oder ein Strom?
Denn async/await ersetzt Combine und RxSwift nicht. Es ersetzt Callbacks. Wer das verwechselt, schreibt reaktive Ketten in AsyncSequences um, gewinnt nichts und verliert Operatoren, die er vorher hatte.
Bestandscode in dieser Größenordnung ist nie einheitlich. In den Projekten, in denen ich damit gearbeitet habe, lagen drei Generationen nebeneinander: Callback-basierte Netzwerkschichten aus der Objective-C-Zeit, eine reaktive Mittelschicht mit RxSwift oder Combine, und darüber neuere Features, die schon mit async/await geschrieben wurden. Dazu PromiseKit in Ecken, die nie jemand angefasst hat.
Das ist kein Versäumnis, sondern das normale Ergebnis von Jahren Produktentwicklung. Und es heißt: Es gibt keinen Zustand, in dem "die Migration fertig" ist. Es gibt nur Grenzen, die man bewusst zieht, und Grenzen, die zufällig entstehen.
Der Grund, warum das nicht beliebig ist: Sobald ein Modul mit Swift 6-Prüfungen kompiliert, verlangt der Compiler Angaben über Isolation und Sendable-Konformität von allem, was diese Grenze überquert. Das zieht Arbeit nach sich, die im ursprünglichen Ticket nicht stand.
Der Fall, in dem die Migration sich immer lohnt, ist der klassische Cache oder Service, den mehrere Stellen gleichzeitig lesen und schreiben. Vorher stand dort eine Queue und ein Kommentar, der erklärte, welche Methode auf welchem Thread aufgerufen werden darf.
final class TokenStore {
private let queue = DispatchQueue(label: "token")
private var token: Token?
// Nur auf `queue` aufrufen! (Kommentar aus 2019)
func current() -> Token? { queue.sync { token } }
func update(_ new: Token) { queue.async { self.token = new } }
}
Der Kommentar ist die eigentliche Diagnose: Die Regel existiert, aber niemand prüft sie. Genau das übernimmt ein actor.
actor TokenStore {
private var token: Token?
func current() -> Token? { token }
func update(_ new: Token) { token = new }
}
Der Gewinn ist nicht die kürzere Schreibweise. Es ist, dass die Regel jetzt der Compiler durchsetzt statt eines Kommentars. Jeder Zugriff von außen ist await und damit im Code sichtbar — falsche Aufrufe werden zum Übersetzungsfehler statt zu einem Absturz alle paar Wochen.
Was dabei auffällt: Die meisten dieser Klassen brauchen gar keine eigene Isolation, sondern gehören auf den Main Actor, weil sie ohnehin nur UI-nah benutzt werden. @MainActor an der Klasse ist dann die ehrlichere und billigere Antwort als ein eigener Actor.
Hier verläuft die Grenze. Ein Actor liefert einen Wert auf Anfrage. Ein reaktiver Strom liefert weiter, wenn sich etwas ändert — Sucheingaben, Verbindungszustand, Positionsdaten, ein Modell, an dem drei Bildschirme hängen.
searchField.textPublisher
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.flatMapLatest { try await api.search($0) } // gibt es so nicht
Diese Kette in AsyncSequences nachzubauen bedeutet, debounce, removeDuplicates und vor allem das Abbrechen der vorherigen Anfrage selbst zu schreiben. Man tauscht erprobte Operatoren gegen eigenen Code, der dieselben Fehler noch einmal machen darf. Ich habe das einmal angefangen und wieder verworfen.
Meine Regel: Reaktive Ketten bleiben, wo sie sind. Migriert wird das, was an ihren Enden hängt — der einzelne Netzwerkaufruf, die einzelne Datenbankabfrage.
Damit beide Seiten koexistieren, braucht es genau zwei Übergänge, und die gehören an eine Stelle, nicht verstreut.
Von reaktiv nach async, um am Ende einer Kette einen einzelnen Wert abzuholen:
extension Publisher where Failure: Error {
func firstValue() async throws -> Output {
for try await value in values { return value }
throw CancellationError()
}
}
Und in die Gegenrichtung, um einen alten Callback-API in async-Code zu benutzen:
func loadProfile() async throws -> Profile {
try await withCheckedThrowingContinuation { continuation in
legacyClient.fetchProfile { result in
continuation.resume(with: result)
}
}
}
Bei der zweiten Brücke liegt die Falle, die uns tatsächlich Zeit gekostet hat: Eine Continuation muss genau einmal fortgesetzt werden. Alte Callback-APIs halten sich nicht immer daran — manche rufen bei einem Wiederholungsversuch zweimal auf, manche gar nicht. Der erste Fall stürzt zur Laufzeit ab, der zweite lässt die Task für immer hängen. Vor jedem withCheckedContinuation gehört deshalb ein Blick in die Implementierung des Callbacks, nicht nur in seine Signatur.
Ich migriere von den Blättern nach innen: zuerst Stellen ohne Abhängige, dann aufwärts. Der umgekehrte Weg — oben mit einem @MainActor am ViewModel anfangen — sieht schneller aus und erzeugt eine Kaskade von Isolationsfehlern durch alle darunterliegenden Schichten.
Praktisch heißt das: ein Modul pro Schritt, Strict Concurrency in diesem Modul einschalten, Fehler auflösen, ausliefern. Nicht projektweit umstellen und dann monatelang gegen Warnungen kämpfen.
@preconcurrency beim Import ist dabei ein legitimes Werkzeug für fremde Abhängigkeiten, die noch keine Angaben machen. Für eigenen Code ist es eine Notiz, keine Lösung — es unterdrückt die Frage, statt sie zu beantworten.
Der ehrliche Preis: Ein migrierendes Projekt hat für lange Zeit zwei Nebenläufigkeitsmodelle gleichzeitig, und das ist schwerer zu lesen als jedes der beiden allein. Neue Teammitglieder müssen beide verstehen, und bei jedem Feature stellt sich die Frage, in welcher Welt es geschrieben wird. Diese Übergangszeit dauert länger, als man plant.
Der zweite Preis ist konkreter: Die Umstellung erzeugt Änderungen in Dateien, die vorher stabil waren, ohne dass ein Nutzer davon etwas hat. Das ist Regressionsrisiko ohne sichtbaren Gegenwert — vertretbar nur dort, wo vorher tatsächlich Fehler entstanden sind.
Bei einer App, die stabil läuft, selten geändert wird und keine Nebenläufigkeitsfehler zeigt, ist die Migration Selbstzweck. Der Compiler zwingt niemanden, solange man beim bestehenden Sprachmodus bleibt.
Ebenso bei einem Team ohne Erfahrung mit dem Modell und ohne Zeit, sie aufzubauen: Halb verstandene Actor-Isolation erzeugt Umgehungen — Zustand wird in nonisolated Ecken geschoben, Task { } wird zur Fluchttür —, und am Ende steht dieselbe Unordnung wie vorher, nur mit neuer Syntax.
Und bei einer App, die ohnehin in zwölf Monaten neu gebaut oder auf geteilte Logik umgestellt wird, gehört die Investition dorthin, nicht in den Umbau des Alten.
Die nützlichste Frage vor jeder dieser Migrationen ist nicht "ist async/await besser", sondern: Gibt es an dieser Stelle geteilten, veränderlichen Zustand, den heute nur ein Kommentar schützt? Wo die Antwort ja lautet, zahlt sich der Umbau aus, weil aus einer Konvention eine Compilerprüfung wird.
Wo nur ein Strom von Ereignissen fließt, hat man schon die richtige Abstraktion. Die auszutauschen kostet Wochen und liefert dieselbe Funktion.
Generierter Code ist selten schlampig, er ist zu aufwendig. Das fällt im Diff kaum auf, deshalb liegt der Prüfpunkt bei mir vor der ersten Zeile Code.
· 8 min
Wer sich in eine fremde Codebasis einliest, sollte sie nicht lesen, sondern befragen. Die Reihenfolge der Fragen entscheidet, ob man nach einem Tag etwas weiß oder nach einer Woche alles gesehen hat.
· 9 min
Sie beschreiben die Lage, ich sage, ob und wie ich helfen kann. Ohne Folien, ohne Verkaufsgespräch.