Erweiterte Parallelität in Swift mit HoneyBee

Veröffentlicht: 2022-03-11

Das Entwerfen, Testen und Verwalten gleichzeitiger Algorithmen in Swift ist schwierig, und die richtigen Details sind entscheidend für den Erfolg Ihrer App. Ein gleichzeitiger Algorithmus (auch als parallele Programmierung bezeichnet) ist ein Algorithmus, der darauf ausgelegt ist, mehrere (möglicherweise viele) Operationen gleichzeitig auszuführen, um mehr Hardwareressourcen zu nutzen und die Gesamtausführungszeit zu reduzieren.

Auf Apples Plattformen ist NSOperation die traditionelle Art, gleichzeitige Algorithmen zu schreiben. Das Design von NSOperation lädt den Programmierer dazu ein, einen nebenläufigen Algorithmus in einzelne langlaufende, asynchrone Aufgaben zu unterteilen. Jede Aufgabe würde in ihrer eigenen Unterklasse von NSOperation definiert, und Instanzen dieser Klassen würden über eine objektive API kombiniert, um zur Laufzeit eine Teilreihenfolge von Aufgaben zu erstellen. Diese Methode zum Entwerfen nebenläufiger Algorithmen war sieben Jahre lang Stand der Technik auf Apples Plattformen.

Im Jahr 2014 führte Apple Grand Central Dispatch (GCD) als einen dramatischen Schritt nach vorn in Bezug auf gleichzeitige Operationen ein. GCD bot zusammen mit den neuen Sprachfunktionsblöcken, die es begleiteten und unterstützten, eine Möglichkeit, einen Handler für asynchrone Antworten unmittelbar nach der initiierenden asynchronen Anforderung kompakt zu beschreiben. Programmierer wurden nicht länger ermutigt, die Definition gleichzeitiger Aufgaben auf mehrere Dateien in zahlreichen NSOperation-Unterklassen zu verteilen. Jetzt könnte ein vollständiger gleichzeitiger Algorithmus innerhalb einer einzigen Methode geschrieben werden. Diese Steigerung der Ausdruckskraft und Typensicherheit war eine bedeutende konzeptionelle Weiterentwicklung. Ein für diese Schreibweise typischer Algorithmus könnte wie folgt aussehen:

 func processImageData(completion: (result: Image?, error: Error?) -> Void) { loadWebResource("dataprofile.txt") { (dataResource, error) in guard let dataResource = dataResource else { completion(nil, error) return } loadWebResource("imagedata.dat") { (imageResource, error) in guard let imageResource = imageResource else { completion(nil, error) return } decodeImage(dataResource, imageResource) { (imageTmp, error) in guard let imageTmp = imageTmp else { completion(nil, error) return } dewarpAndCleanupImage(imageTmp) { imageResult in guard let imageResult = imageResult else { completion(nil, error) return } completion(imageResult, nil) } } } } }

Lassen Sie uns diesen Algorithmus ein wenig aufschlüsseln. Die Funktion processImageData ist eine asynchrone Funktion, die vier eigene asynchrone Aufrufe durchführt, um ihre Arbeit abzuschließen. Die vier asynchronen Aufrufe sind so ineinander verschachtelt, wie es für die blockbasierte asynchrone Behandlung am natürlichsten ist. Die Ergebnisblöcke haben jeweils einen optionalen Error-Parameter und alle bis auf einen enthalten einen zusätzlichen optionalen Parameter, der das Ergebnis der aysnc-Operation angibt.

Die Form des obigen Codeblocks kommt den meisten Swift-Entwicklern wahrscheinlich bekannt vor. Aber was ist falsch an diesem Ansatz? Die folgende Liste von Schmerzpunkten wird Ihnen wahrscheinlich ebenso vertraut sein.

  • Diese „Pyramid of Doom“-Form aus verschachtelten Codeblöcken kann schnell unhandlich werden. Was passiert, wenn wir zwei weitere asynchrone Operationen hinzufügen? Vier? Was ist mit bedingten Operationen? Wie sieht es mit dem Wiederholungsverhalten oder dem Schutz vor Ressourcenbeschränkungen aus? Echter Code ist nie so sauber und einfach wie Beispiele in Blogbeiträgen. Der „Pyramid of Doom“-Effekt kann leicht zu schwer lesbarem, schwer zu wartendem und fehleranfälligem Code führen.
  • Der Versuch der Fehlerbehandlung im obigen Beispiel ist zwar schnell, aber tatsächlich unvollständig. Der Programmierer hat angenommen, dass die asynchronen Callback-Blöcke im Objective-C-Stil mit zwei Parametern immer einen der beiden Parameter bereitstellen; sie werden niemals beide gleichzeitig null sein. Dies ist keine sichere Annahme. Nebenläufige Algorithmen sind dafür bekannt, dass sie schwer zu schreiben und zu debuggen sind, und unbegründete Annahmen sind ein Grund dafür. Eine vollständige und korrekte Fehlerbehandlung ist eine unausweichliche Notwendigkeit für jeden nebenläufigen Algorithmus, der beabsichtigt, in der realen Welt zu arbeiten.
  • Wenn man diesen Gedanken noch weiterführt, war vielleicht der Programmierer, der die aufgerufenen asynchronen Funktionen geschrieben hat, nicht so prinzipientreu wie Sie. Was passiert, wenn es Bedingungen gibt, unter denen die aufgerufenen Funktionen nicht zurückrufen? Oder mehrmals zurückrufen? Was passiert unter diesen Umständen mit der Korrektheit von processImageData? Profis gehen kein Risiko ein. Unternehmenskritische Funktionen müssen korrekt sein, auch wenn sie auf Funktionen angewiesen sind, die von Dritten geschrieben wurden.
  • Am überzeugendsten ist vielleicht, dass der betrachtete asynchrone Algorithmus suboptimal konstruiert ist. Die ersten beiden asynchronen Vorgänge sind beide Downloads von Remoteressourcen. Obwohl sie keine gegenseitige Abhängigkeit haben, führt der obige Algorithmus die Downloads sequentiell und nicht parallel aus. Die Gründe dafür liegen auf der Hand; Die verschachtelte Blocksyntax fördert eine solche Verschwendung. Wettbewerbsmärkte tolerieren keine unnötige Verzögerung. Wenn Ihre App ihre asynchronen Vorgänge nicht so schnell wie möglich ausführt, wird eine andere App dies tun.

Wie können wir es besser machen? HoneyBee ist eine Futures/Promises-Bibliothek, die die simultane Programmierung von Swift einfach, ausdrucksstark und sicher macht. Lassen Sie uns den obigen asynchronen Algorithmus mit HoneyBee umschreiben und das Ergebnis untersuchen:

 func processImageData(completion: (result: Image?, error: Error?) -> Void) { HoneyBee.start() .setErrorHandler { completion(nil, $0) } .branch { stem in stem.chain(loadWebResource =<< "dataprofile.txt") + stem.chain(loadWebResource =<< "imagedata.dat") } .chain(decodeImage) .chain(dewarpAndCleanupImage) .chain { completion($0, nil) } }

Die erste Zeile, die diese Implementierung startet, ist ein neues HoneyBee-Rezept. Die zweite Zeile legt den Standardfehlerbehandler fest. Die Fehlerbehandlung ist in HoneyBee-Rezepten nicht optional. Wenn etwas schief gehen kann, muss der Algorithmus damit umgehen. Die dritte Zeile öffnet einen Zweig, der eine parallele Ausführung ermöglicht. Die beiden Ketten von loadWebResource werden parallel ausgeführt und ihre Ergebnisse werden kombiniert (Zeile 5). Die kombinierten Werte der beiden geladenen Ressourcen werden an decodeImage weitergeleitet und so weiter entlang der Kette, bis die Vervollständigung aufgerufen wird.

Lassen Sie uns die obige Liste der Schmerzpunkte durchgehen und sehen, wie HoneyBee diesen Code verbessert hat. Die Pflege dieser Funktion ist jetzt deutlich einfacher. Das HoneyBee-Rezept sieht aus wie der Algorithmus, den es ausdrückt. Der Code ist lesbar, verständlich und schnell änderbar. Das Design von HoneyBee stellt sicher, dass jede falsche Anordnung von Anweisungen zu einem Kompilierzeitfehler und nicht zu einem Laufzeitfehler führt. Die Funktion ist nun viel weniger anfällig für Bugs und menschliches Versagen.

Alle möglichen Laufzeitfehler wurden vollständig behandelt. Jede von HoneyBee unterstützte Funktionssignatur (es gibt 38 davon) wird garantiert vollständig verarbeitet. In unserem Beispiel erzeugt der Zwei-Parameter-Callback im Objective-C-Stil entweder einen Nicht-Null-Fehler, der an die Fehlerbehandlungsroutine weitergeleitet wird, oder einen Nicht-Null-Wert, der in der Kette fortschreitet, oder beides Werte sind Null HoneyBee generiert einen Fehler, der erklärt, dass der Funktions-Callback seinen Vertrag nicht erfüllt.

HoneyBee handhabt auch die vertragliche Korrektheit für die Anzahl der Aufrufe von Funktionsrückrufen. Wenn eine Funktion ihren Rückruf nicht aufrufen kann, erzeugt HoneyBee einen beschreibenden Fehler. Wenn die Funktion ihren Rückruf mehr als einmal aufruft, unterdrückt HoneyBee die zusätzlichen Aufrufe und protokolliert Warnungen. Diese beiden Fehlerreaktionen (und andere) können an die individuellen Bedürfnisse des Programmierers angepasst werden.

Hoffentlich sollte bereits ersichtlich sein, dass diese Form von processImageData die Ressourcen-Downloads ordnungsgemäß parallelisiert, um eine optimale Leistung bereitzustellen. Eines der wichtigsten Designziele von HoneyBee ist, dass das Rezept wie der Algorithmus aussehen sollte, den es ausdrückt.

Viel besser. Rechts? Aber HoneyBee hat noch viel mehr zu bieten.

Seien Sie gewarnt: Die nächste Fallstudie ist nichts für schwache Nerven. Betrachten Sie die folgende Problembeschreibung: Ihre mobile App verwendet CoreData , um ihren Zustand beizubehalten. Sie haben ein NSManagedObject -Modell namens Media, das ein auf Ihren Back-End-Server hochgeladenes Medienobjekt darstellt. Der Nutzer soll Dutzende von Medienartikeln auf einmal auswählen und stapelweise in das Backend-System hochladen können. Die Medien werden zunächst über einen Referenz-String repräsentiert, der in ein Media-Objekt umgewandelt werden muss. Glücklicherweise enthält Ihre App bereits eine Hilfsmethode, die genau das tut:

 func export(_ mediaRef: String, completion: @escaping (Media?, Error?) -> Void) { // transcoding stuff completion(Media(context: managedObjectContext), nil) }

Nachdem die Medienreferenz in ein Medienobjekt konvertiert wurde, müssen Sie das Medienelement in das Back-End hochladen. Auch hier haben Sie eine Hilfsfunktion, die bereit ist, den Netzwerkkram zu erledigen.

 func upload(_ media: Media, completion: @escaping (Error?) -> Void) { // network stuff completion(nil) }

Da der Benutzer Dutzende von Medienelementen gleichzeitig auswählen darf, hat der UX-Designer eine ziemlich robuste Menge an Feedback zum Upload-Fortschritt angegeben. Die Anforderungen wurden in die folgenden vier Funktionen destilliert:

 /// Called if anything goes wrong in the upload func errorHandler(_ error: Error) { // do the right thing } /// Called once per mediaRef, after either a successful or unsuccessful upload func singleUploadCompletion(_ mediaRef: String) { // update a progress indicator } /// Called once per successful upload func singleUploadSuccess(_ media: Media) { // do celebratory things } /// Called if the entire batch was considered to be uploaded successfully. func totalProcessSuccess() { // declare victory }

Da Ihre App jedoch Medienreferenzen bezieht, die manchmal abgelaufen sind, haben sich die Geschäftsführer entschieden, dem Benutzer eine „Erfolgs“-Nachricht zu senden, wenn mindestens die Hälfte der Uploads erfolgreich sind. Das heißt, dass der gleichzeitige Prozess den Sieg erklären und totalProcessSuccess aufrufen sollte, wenn weniger als die Hälfte der versuchten Uploads fehlschlagen. Dies ist die Spezifikation, die Ihnen als Entwickler übergeben wird. Als erfahrener Programmierer erkennen Sie jedoch, dass noch mehr Anforderungen erfüllt werden müssen.

Natürlich möchte Business, dass der Batch-Upload so schnell wie möglich erfolgt, daher kommt ein serieller Upload nicht in Frage. Die Uploads müssen parallel erfolgen.

Aber nicht zu viel. Wenn Sie den gesamten Stapel einfach wahllos async , werden die Dutzende gleichzeitiger Uploads die mobile NIC (Netzwerkschnittstellenkarte) überfluten, und die Uploads werden tatsächlich langsamer als seriell und nicht schneller fortgesetzt.

Mobilfunkverbindungen gelten als nicht stabil. Selbst kurze Transaktionen können nur aufgrund von Änderungen in der Netzwerkkonnektivität fehlschlagen. Um wirklich zu erklären, dass ein Upload fehlgeschlagen ist, müssen wir den Upload mindestens einmal wiederholen.

Die Wiederholungsrichtlinie sollte den Exportvorgang nicht enthalten, da er keinen vorübergehenden Fehlern unterliegt.

Der Exportprozess ist rechengebunden und muss daher außerhalb des Hauptthreads ausgeführt werden.

Da der Export rechengebunden ist, sollte er eine geringere Anzahl gleichzeitiger Instanzen haben als der restliche Upload-Prozess, um eine Überlastung des Prozessors zu vermeiden.

Die vier oben beschriebenen Rückruffunktionen aktualisieren alle die Benutzeroberfläche und müssen daher alle im Hauptthread aufgerufen werden.

Media ist ein NSManagedObject , das aus einem NSManagedObjectContext stammt und über eigene Threading-Anforderungen verfügt, die beachtet werden müssen.

Erscheint Ihnen diese Problemspezifikation etwas obskur? Seien Sie nicht überrascht, wenn solche Probleme in Ihrer Zukunft lauern. Ich bin in meiner eigenen Arbeit auf eine solche gestoßen. Versuchen wir zunächst, dieses Problem mit herkömmlichen Tools zu lösen. Schnall dich an, das wird nicht schön.

 /// An enum describing specific problems that the algorithm might encounter. enum UploadingError : Error { case invalidResponse case tooManyFailures } /// A semaphore to prevent flooding the NIC let outerLimit = DispatchSemaphore(value: 4) /// A semaphore to prevent thrashing the processor let exportLimit = DispatchSemaphore(value: 1) /// The number of times to retry the upload if it fails let uploadRetries = 1 /// Dispatch group to keep track of when the entire process is finished let fullProcessDispatchGroup = DispatchGroup() /// How many of the uploads fully completed. var uploadSuccesses = 0 // this notify block is called when the full process has completed. fullProcessDispatchGroup.notify(queue: DispatchQueue.main) { let successRate = Float(uploadSuccesses) / Float(mediaReferences.count) if successRate > 0.5 { totalProcessSuccess() } else { errorHandler(UploadingError.tooManyFailures) } } // start in the background DispatchQueue.global().async { for mediaRef in mediaReferences { // alert the group that we're starting a process fullProcessDispatchGroup.enter() // wait until it's safe to start uploading outerLimit.wait() /// common cleanup operations needed later func finalizeMediaRef() { singleUploadCompletion(mediaRef) fullProcessDispatchGroup.leave() outerLimit.signal() } // wait until it's safe to start exporting exportLimit.wait() export(mediaRef) { (media, error) in // allow another export to begin exportLimit.signal() if let error = error { DispatchQueue.main.async { errorHandler(error) finalizeMediaRef() } } else { guard let media = media else { DispatchQueue.main.async { errorHandler(UploadingError.invalidResponse) finalizeMediaRef() } return } // the export was successful var uploadAttempts = 0 /// define the upload process and its retry behavior func doUpload() { // respect Media's threading requirements managedObjectContext.perform { upload(media) { error in if let error = error { if uploadAttempts < uploadRetries { uploadAttempts += 1 doUpload() // retry } else { DispatchQueue.main.async { // too many upload failures errorHandler(error) finalizeMediaRef() } } } else { DispatchQueue.main.async { uploadSuccesses += 1 singleUploadSuccess(media) finalizeMediaRef() } } } } } // kick off the first upload doUpload() } } } }

Woah! Ohne Kommentare sind das etwa 75 Zeilen. Sind Sie der Argumentation bis zum Ende gefolgt? Wie würden Sie sich fühlen, wenn Sie diesem Monster in Ihrer ersten Woche in einem neuen Job begegnen würden? Würden Sie sich bereit fühlen, es beizubehalten oder zu modifizieren? Würden Sie wissen, ob es Fehler enthält? Enthält es Fehler?

Betrachten Sie nun die HoneyBee-Alternative:

 HoneyBee.start(on: DispatchQueue.main) .setErrorHandler(errorHandler) .insert(mediaReferences) .setBlockPerformer(DispatchQueue.global()) .each(limit: 4, acceptableFailure: .ratio(0.5)) { elem in elem.finally { link in link.setBlockPerformer(DispatchQueue.main) .chain(singleUploadCompletion) } .limit(1) { link in link.chain(export) } .setBlockPerformer(managedObjectContext) .retry(1) { link in link.chain(upload) // subject to transient failure } .setBlockPerformer(DispatchQueue.main) .chain(singleUploadSuccess) } .setBlockPerformer(DispatchQueue.main) .drop() .chain(totalProcessSuccess)

Wie wirkt diese Form auf Sie? Arbeiten wir uns Stück für Stück durch. In der ersten Zeile beginnen wir mit dem HoneyBee-Rezept, beginnend mit dem Hauptthread. Indem wir im Hauptthread beginnen, stellen wir sicher, dass alle Fehler an errorHandler (Zeile 2) im Hauptthread weitergeleitet werden. Zeile 3 fügt das Array mediaReferences in die Prozesskette ein. Als nächstes wechseln wir zur Vorbereitung auf etwas Parallelität zur globalen Hintergrundwarteschlange. In Zeile 5 beginnen wir mit einer parallelen Iteration über jede der mediaReferences . Wir beschränken diese Parallelität auf maximal 4 gleichzeitige Operationen. Wir erklären auch, dass die vollständige Iteration als erfolgreich angesehen wird, wenn mindestens die Hälfte der Teilketten erfolgreich ist (kein Fehler). Zeile 6 deklariert einen finally -Link, der aufgerufen wird, unabhängig davon, ob die folgende Unterkette erfolgreich ist oder fehlschlägt. Auf dem finally -Link wechseln wir zum Haupt-Thread (Zeile 7) und rufen singleUploadCompletion (Zeile 8) auf. In Zeile 10 setzen wir eine maximale Parallelisierung von 1 (einmalige Ausführung) um die Exportoperation (Zeile 11). Zeile 13 wechselt zur privaten Warteschlange, die unserer managedObjectContext Instanz gehört. Zeile 14 deklariert einen einzelnen Wiederholungsversuch für die Upload-Operation (Zeile 15). Zeile 17 wechselt wieder zum Haupt-Thread und 18 ruft singleUploadSuccess auf. Zu dem Zeitpunkt, an dem Linie 20 ausgeführt würde, sind alle parallelen Iterationen abgeschlossen. Wenn weniger als die Hälfte der Iterationen fehlgeschlagen ist, wechselt Zeile 20 ein letztes Mal zur Hauptwarteschlange (denken Sie daran, dass jede in der Hintergrundwarteschlange ausgeführt wurde), 21 löscht den eingehenden Wert (immer noch mediaReferences ) und 22 ruft totalProcessSuccess auf.

Das HoneyBee-Formular ist klarer, sauberer und einfacher zu lesen, ganz zu schweigen von der einfacheren Pflege. Was würde mit der langen Form dieses Algorithmus passieren, wenn die Schleife die Media-Objekte wie eine Kartenfunktion wieder in ein Array integrieren müsste? Wie sicher wären Sie, nachdem Sie die Änderung vorgenommen haben, dass alle Anforderungen des Algorithmus noch erfüllt sind? In der HoneyBee-Form würde diese Änderung darin bestehen, jede durch map zu ersetzen, um eine parallele Map-Funktion zu verwenden. (Ja, es hat auch reduziert.)

HoneyBee ist eine leistungsstarke Futures-Bibliothek für Swift, die das Schreiben asynchroner und gleichzeitiger Algorithmen einfacher, sicherer und ausdrucksstärker macht. In diesem Artikel haben wir gesehen, wie HoneyBee Ihre Algorithmen wartungsfreundlicher, korrekter und schneller machen kann. HoneyBee bietet auch Unterstützung für andere wichtige asynchrone Paradigmen wie Wiederholungsunterstützung, mehrere Fehlerhandler, Ressourcenschutz und Sammlungsverarbeitung (asynchrone Formen von Map, Filter und Reduce). Eine vollständige Liste der Funktionen finden Sie auf der Website. Um mehr zu erfahren oder Fragen zu stellen, besuchen Sie die brandneuen Community-Foren.

Anhang: Gewährleistung der vertraglichen Korrektheit von Async-Funktionen

Die Sicherstellung der vertraglichen Korrektheit von Funktionen ist ein grundlegender Grundsatz der Informatik. So sehr, dass praktisch alle modernen Compiler Prüfungen haben, um sicherzustellen, dass eine Funktion, die deklariert, einen Wert zurückzugeben, genau einmal zurückgibt. Weniger oder mehr als einmal zurückzugeben wird als Fehler behandelt und verhindert entsprechend eine vollständige Kompilierung.

Aber diese Compilerunterstützung gilt normalerweise nicht für asynchrone Funktionen. Betrachten Sie das folgende (spielerische) Beispiel:

 func generateIcecream(from int: Int, completion: (String) -> Void) { if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } completion("Pistachio") } else if int < 2 { completion("Vanilla") } }

Die generateIcecream -Funktion akzeptiert ein Int und gibt asynchron einen String zurück. Der schnelle Compiler akzeptiert die obige Form gerne als korrekt, obwohl sie einige offensichtliche Probleme enthält. Bei bestimmten Eingaben kann diese Funktion die Vervollständigung null, eins oder zweimal aufrufen. Programmierer, die mit asynchronen Funktionen gearbeitet haben, werden sich oft an Beispiele dieses Problems aus ihrer eigenen Arbeit erinnern. Was können wir tun? Natürlich könnten wir den Code umgestalten, um ihn sauberer zu machen (ein Schalter mit Bereichsfällen würde hier funktionieren). Aber manchmal ist die funktionale Komplexität schwer zu reduzieren. Wäre es nicht besser, wenn der Compiler uns dabei helfen könnte, die Korrektheit zu überprüfen, so wie es bei regelmäßig zurückkehrenden Funktionen der Fall ist?

Es stellt sich heraus, dass es einen Weg gibt. Beachten Sie die folgende Swifty-Beschwörung:

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } // else completion("Pistachio") } else if int < 2 { completion("Vanilla") } }

Die vier am Anfang dieser Funktion eingefügten Zeilen zwingen den Compiler, zu überprüfen, ob der Completion-Callback genau einmal aufgerufen wird, was bedeutet, dass diese Funktion nicht mehr kompiliert wird. Was ist los? In der ersten Zeile deklarieren wir das Ergebnis, das diese Funktion letztendlich erzeugen soll, initialisieren es aber nicht. Indem wir es undefiniert lassen, stellen wir sicher, dass es einmal zugewiesen werden muss, bevor es verwendet werden kann, und indem wir es deklarieren, stellen wir sicher, dass es niemals zweimal zugewiesen werden kann. Die zweite Zeile ist eine Verzögerung, die als letzte Aktion dieser Funktion ausgeführt wird. Es ruft den Vervollständigungsblock mit finalResult - nachdem es vom Rest der Funktion zugewiesen wurde. Zeile 3 erstellt eine neue Konstante namens „completion“, die den Callback-Parameter verdeckt. Die neue Vervollständigung ist vom Typ Void, der keine öffentliche API deklariert. Diese Zeile stellt sicher, dass jede Verwendung der Vervollständigung nach dieser Zeile ein Compilerfehler ist. Das Zurückstellen in Zeile 2 ist die einzige zulässige Verwendung des Vervollständigungsblocks. Zeile 4 entfernt eine Compiler-Warnung, die sonst vorhanden wäre, dass die neue Vervollständigungskonstante nicht verwendet wird.

Wir haben also den schnellen Compiler erfolgreich gezwungen, zu melden, dass diese asynchrone Funktion ihren Vertrag nicht erfüllt. Gehen wir die Schritte durch, um es richtig zu machen. Lassen Sie uns zunächst den gesamten direkten Zugriff auf Callback durch eine Zuweisung an finalResult .

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } // else finalResult = "Pistachio" } else if int < 2 { finalResult = "Vanilla" } }

Jetzt meldet der Compiler zwei Probleme:

 error: AsyncCorrectness.playground:1:8: error: constant 'finalResult' used before being initialized defer { completion(finalResult) } ^ error: AsyncCorrectness.playground:11:3: error: immutable value 'finalResult' may only be initialized once finalResult = "Pistachio"

Wie erwartet hat die Funktion einen Pfad, dem finalResult null Mal zugewiesen wird, und einen Pfad, dem sie mehr als einmal zugewiesen wird. Wir lösen diese Probleme wie folgt:

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } else { finalResult = "Pistachio" } } else if int < 2 { finalResult = "Vanilla" } else { finalResult = "Neapolitan" } }

Die „Pistazie“ wurde in eine richtige Else-Klausel verschoben, und wir stellen fest, dass wir den allgemeinen Fall nicht abgedeckt haben – der natürlich „neapolitanisch“ ist.

Die gerade beschriebenen Muster können leicht angepasst werden, um optionale Werte, optionale Fehler oder komplexe Typen wie die allgemeine Ergebnisaufzählung zurückzugeben. Indem wir den Compiler zwingen, zu überprüfen, ob Callbacks genau einmal aufgerufen werden, können wir die Korrektheit und Vollständigkeit asynchroner Funktionen bestätigen.