Zaawansowana współbieżność w Swift z HoneyBee

Opublikowany: 2022-03-11

Projektowanie, testowanie i utrzymywanie współbieżnych algorytmów w Swift jest trudne, a uzyskanie odpowiednich szczegółów ma kluczowe znaczenie dla sukcesu Twojej aplikacji. Algorytm współbieżny (zwany także programowaniem równoległym) to algorytm, który jest przeznaczony do wykonywania wielu (być może wielu) operacji jednocześnie, aby wykorzystać więcej zasobów sprzętowych i skrócić całkowity czas wykonywania.

Na platformach Apple tradycyjnym sposobem pisania współbieżnych algorytmów jest NSOperation. Projekt NSOperation zachęca programistę do podzielenia współbieżnego algorytmu na indywidualne, długotrwałe, asynchroniczne zadania. Każde zadanie zostałoby zdefiniowane we własnej podklasie NSOperation, a instancje tych klas zostałyby połączone za pomocą obiektywnego interfejsu API, aby utworzyć częściową kolejność zadań w czasie wykonywania. Ta metoda projektowania współbieżnych algorytmów była najnowocześniejszą metodą na platformach Apple przez siedem lat.

W 2014 roku Apple wprowadził Grand Central Dispatch (GCD) jako dramatyczny krok naprzód w wyrażaniu równoczesnych operacji. GCD, wraz z nowymi blokami funkcji językowych, które mu towarzyszyły i zasilały, zapewniły sposób na zwięzłe opisanie procedury obsługi odpowiedzi asynchronicznej natychmiast po zainicjowaniu żądania asynchronicznego. Programiści nie byli już zachęcani do rozpowszechniania definicji współbieżnych zadań w wielu plikach w wielu podklasach NSOperation. Teraz cały współbieżny algorytm można by napisać w ramach jednej metody. Ten wzrost wyrazistości i bezpieczeństwa typu był znaczącym przesunięciem koncepcyjnym do przodu. Algorytm typowy dla tego sposobu pisania może wyglądać tak:

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

Rozłóżmy trochę ten algorytm. Funkcja processImageData jest funkcją asynchroniczną, która wykonuje cztery własne wywołania asynchroniczne w celu zakończenia pracy. Cztery wywołania asynchroniczne są zagnieżdżone jedno w drugim w sposób najbardziej naturalny dla obsługi asynchronicznej opartej na blokach. Każdy blok wyników ma opcjonalny parametr Error, a wszystkie oprócz jednego zawierają dodatkowy opcjonalny parametr oznaczający wynik operacji aysnc.

Kształt powyższego bloku kodu prawdopodobnie wydaje się znajomy większości programistów Swift. Ale co jest złego w tym podejściu? Poniższa lista punktów bólu będzie prawdopodobnie równie znajoma.

  • Ten kształt „piramidy zagłady” zagnieżdżonych bloków kodu może szybko stać się nieporęczny. Co się stanie, jeśli dodamy jeszcze dwie operacje asynchroniczne? Cztery? A co z operacjami warunkowymi? Co powiesz na zachowanie ponownej próby lub ochronę limitów zasobów? Prawdziwy kod nigdy nie jest tak przejrzysty i prosty jak przykłady w postach na blogu. Efekt „piramidy zagłady” może łatwo spowodować, że kod będzie trudny do odczytania, trudny w utrzymaniu i podatny na błędy.
  • Próba obsługi błędów w powyższym przykładzie, choć Swifty, jest w rzeczywistości niekompletna. Programista założył, że dwuparametrowe bloki asynchronicznego wywołania zwrotnego w stylu Cel-C zawsze będą dostarczać jeden z dwóch parametrów; nigdy oboje nie będą zerem w tym samym czasie. To nie jest bezpieczne założenie. Algorytmy współbieżne są znane z tego, że są trudne do napisania i debugowania, a nieuzasadnione założenia są częścią tego powodu. Kompletna i poprawna obsługa błędów jest nieuniknioną koniecznością każdego współbieżnego algorytmu, który zamierza działać w świecie rzeczywistym.
  • Idąc dalej, być może programista, który napisał wywoływane funkcje asynchroniczne, nie był tak pryncypialny jak ty. Co zrobić, jeśli istnieją warunki, w których wywoływane funkcje nie oddzwonią? Lub oddzwaniać więcej niż raz? Co dzieje się z poprawnością processImageData w tych okolicznościach? Profesjonaliści nie ryzykują. Funkcje o znaczeniu krytycznym muszą być prawidłowe, nawet jeśli opierają się na funkcjach napisanych przez osoby trzecie.
  • Być może najbardziej przekonujący, rozważany algorytm asynchroniczny jest skonstruowany nieoptymalnie. Pierwsze dwie operacje asynchroniczne to pobieranie zasobów zdalnych. Mimo że nie są one współzależne, powyższy algorytm wykonuje pobieranie sekwencyjnie, a nie równolegle. Powody tego są oczywiste; składnia zagnieżdżonych bloków zachęca do takiego marnotrawstwa. Rynki konkurencyjne nie tolerują niepotrzebnych opóźnień. Jeśli Twoja aplikacja nie wykonuje operacji asynchronicznych tak szybko, jak to możliwe, zrobi to inna aplikacja.

Jak możemy zrobić lepiej? HoneyBee to biblioteka futures/promises, która sprawia, że ​​współbieżne programowanie Swift jest łatwe, ekspresyjne i bezpieczne. Przepiszmy powyższy algorytm asynchroniczny za pomocą HoneyBee i sprawdźmy wynik:

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

Pierwsza linia, którą rozpoczyna ta implementacja, to nowa receptura HoneyBee. Druga linia określa domyślną procedurę obsługi błędów. Obsługa błędów nie jest opcjonalna w przepisach HoneyBee. Jeśli coś może pójść nie tak, algorytm musi sobie z tym poradzić. Trzecia linia otwiera gałąź, która pozwala na równoległe wykonywanie. Dwa łańcuchy loadWebResource będą wykonywane równolegle, a ich wyniki zostaną połączone (wiersz 5). Połączone wartości dwóch załadowanych zasobów są przekazywane do decodeImage i tak dalej w dół łańcucha, aż do wywołania zakończenia.

Przejrzyjmy powyższą listę problemów i zobaczmy, jak HoneyBee ulepszyło ten kod. Utrzymanie tej funkcji jest teraz znacznie łatwiejsze. Przepis HoneyBee wygląda jak algorytm, który wyraża. Kod jest czytelny, zrozumiały i szybko modyfikowalny. Projekt HoneyBee zapewnia, że ​​każda nieprawidłowa kolejność instrukcji skutkuje błędem w czasie kompilacji, a nie błędem w czasie wykonywania. Funkcja jest teraz znacznie mniej podatna na błędy i błędy ludzkie.

Wszystkie możliwe błędy uruchomieniowe zostały w pełni rozwiązane. Każda sygnatura funkcji obsługiwana przez HoneyBee (jest ich 38) gwarantuje pełną obsługę. W naszym przykładzie dwuparametrowe wywołanie zwrotne w stylu Cel-C wygeneruje błąd inny niż zero, który zostanie skierowany do programu obsługi błędów, albo wygeneruje wartość inną niż zero, która będzie postępowała w dół łańcucha, lub jeśli obie wartości są nil HoneyBee wygeneruje błąd wyjaśniający, że funkcja callback nie spełnia swojego kontraktu.

HoneyBee obsługuje również poprawność kontraktową dotyczącą liczby wywołań funkcji zwrotnych. Jeśli funkcja nie może wywołać swojego wywołania zwrotnego, HoneyBee generuje opisowy błąd. Jeśli funkcja wywoła swoje wywołanie zwrotne więcej niż jeden raz, HoneyBee pominie wywołania pomocnicze i ostrzeżenia dziennika. Obie te reakcje na błędy (i inne) można dostosować do indywidualnych potrzeb programisty.

Miejmy nadzieję, że powinno już być oczywiste, że ta forma processImageData prawidłowo zrównolegla pobieranie zasobów, aby zapewnić optymalną wydajność. Jednym z najsilniejszych celów projektowych HoneyBee jest to, aby przepis wyglądał jak algorytm, który wyraża.

Dużo lepiej. Prawidłowy? Ale HoneyBee ma znacznie więcej do zaoferowania.

Ostrzegam: następne studium przypadku nie jest przeznaczone dla osób o słabym sercu. Rozważ następujący opis problemu: Twoja aplikacja mobilna używa CoreData do zachowania swojego stanu. Masz model NSManagedObject o nazwie Media, który reprezentuje zasób multimedialny przesłany na serwer zaplecza. Użytkownik ma mieć możliwość jednoczesnego wybierania dziesiątek elementów multimedialnych i przesyłania ich zbiorczo do systemu zaplecza. Media są najpierw reprezentowane przez odnośnik String, który należy przekonwertować na obiekt Media. Na szczęście Twoja aplikacja zawiera już metodę pomocniczą, która właśnie to robi:

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

Po przekonwertowaniu odniesienia do multimediów na obiekt Media musisz przesłać element multimedialny do zaplecza. Znowu masz funkcję pomocniczą gotową do obsługi sieci.

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

Ponieważ użytkownik może wybrać dziesiątki elementów multimedialnych naraz, projektant UX określił dość solidną ilość informacji zwrotnych na temat postępu przesyłania. Wymagania zostały podzielone na następujące cztery funkcje:

 /// 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 }

Ponieważ jednak Twoja aplikacja pozyskuje odniesienia do multimediów, które czasami wygasły, menedżerowie biznesowi zdecydowali się wysłać użytkownikowi wiadomość o „sukcesie”, jeśli co najmniej połowa przesłanych plików się powiedzie. To znaczy, że współbieżny proces powinien ogłosić zwycięstwo — i wywołać totalProcessSuccess jeśli mniej niż połowa prób przesyłania nie powiedzie się. To jest specyfikacja przekazana Tobie jako deweloperowi. Ale jako doświadczony programista zdajesz sobie sprawę, że jest więcej wymagań, które należy zastosować.

Oczywiście firma chce, aby przesyłanie wsadowe odbywało się tak szybko, jak to możliwe, więc przesyłanie seryjne nie wchodzi w rachubę. Przesyłanie musi odbywać się równolegle.

Ale nie za dużo. Jeśli po prostu bezkrytycznie async całą partię, dziesiątki równoczesnych operacji przesyłania zaleją mobilną kartę sieciową (kartę interfejsu sieciowego), a przesyłanie będzie w rzeczywistości przebiegać wolniej niż szeregowo, a nie szybciej.

Połączenia z siecią komórkową nie są uważane za stabilne. Nawet krótkie transakcje mogą się nie powieść z powodu zmian w łączności sieciowej. Aby naprawdę zadeklarować, że przesyłanie nie powiodło się, musimy ponowić próbę przesyłania co najmniej raz.

Zasady ponawiania nie powinny obejmować operacji eksportowania, ponieważ nie podlegają one błędom przejściowym.

Proces eksportu jest powiązany z obliczeniami i dlatego musi być wykonywany poza głównym wątkiem.

Ponieważ eksport jest powiązany z obliczeniami, powinien mieć mniejszą liczbę współbieżnych wystąpień niż reszta procesu przesyłania, aby uniknąć zaśmiecania procesora.

Wszystkie cztery opisane powyżej funkcje wywołania zwrotnego aktualizują interfejs użytkownika, dlatego wszystkie muszą być wywoływane w głównym wątku.

Media to NSManagedObject , który pochodzi z NSManagedObjectContext i ma własne wymagania dotyczące wątków, które muszą być przestrzegane.

Czy ta specyfikacja problemu wydaje się nieco niejasna? Nie zdziw się, jeśli napotkasz takie problemy w przyszłości. Z takim spotkałem się we własnej pracy. Spróbujmy najpierw rozwiązać ten problem za pomocą tradycyjnych narzędzi. Zapnij pasy, to nie będzie ładne.

 /// 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() } } } }

Och! Bez komentarzy to około 75 linijek. Czy przez cały czas podążałeś za rozumowaniem? Jak byś się czuł, gdybyś w pierwszym tygodniu nowej pracy spotkał tego potwora? Czy czułbyś się gotowy, aby go utrzymać lub zmodyfikować? Czy wiesz, czy zawiera błędy? Czy zawiera błędy?

Teraz rozważ alternatywę HoneyBee:

 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)

Jak uderza cię ta forma? Przejdźmy przez to kawałek po kawałku. W pierwszym wierszu zaczynamy przepis HoneyBee, zaczynając od głównego wątku. Rozpoczynając od głównego wątku zapewniamy, że wszystkie błędy zostaną przekazane do errorHandler (linia 2) w głównym wątku. Linia 3 wstawia tablicę mediaReferences do łańcucha procesów. Następnie przełączamy się na globalną kolejkę w tle, przygotowując się na pewną równoległość. W linii 5 rozpoczynamy równoległą iterację nad każdym z elementów mediaReferences . Ograniczamy tę równoległość do maksymalnie 4 równoczesnych operacji. Deklarujemy również, że pełna iteracja zostanie uznana za udaną, jeśli co najmniej połowa podłańcuchów się powiedzie (nie popełniaj błędu). Linia 6 deklaruje finally łącze, które będzie wywoływane niezależnie od tego, czy podłańcuch poniżej się powiedzie, czy nie. Na finally łączu przełączamy się na wątek główny (linia 7) i wywołujemy singleUploadCompletion (linia 8). W wierszu 10 ustawiamy maksymalną równoległość na 1 (pojedyncze wykonanie) wokół operacji eksportu (wiersz 11). Linia 13 przełącza do kolejki prywatnej, której właścicielem jest nasza instancja managedObjectContext . Wiersz 14 deklaruje pojedynczą próbę ponowienia operacji przesyłania (wiersz 15). Linia 17 ponownie przełącza do głównego wątku i 18 wywołuje singleUploadSuccess . Do czasu, gdy linia 20 zostałaby wykonana, wszystkie równoległe iteracje zostały zakończone. Jeśli mniej niż połowa iteracji nie powiodła się, wiersz 20 po raz ostatni przełącza się na główną kolejkę (przypomnijmy, że każda z nich była uruchomiona w kolejce w tle), 21 odrzuca wartość przychodzącą (nadal mediaReferences ), a 22 wywołuje totalProcessSuccess .

Formularz HoneyBee jest wyraźniejszy, czystszy i łatwiejszy do odczytania, nie wspominając już o łatwiejszym utrzymaniu. Co by się stało z długą formą tego algorytmu, gdyby pętla była wymagana do ponownego zintegrowania obiektów Media w tablicę, jak funkcja mapy? Po dokonaniu zmiany, jak bardzo możesz być pewien, że wszystkie wymagania algorytmu są nadal spełniane? W formie HoneyBee ta zmiana polegałaby na zastąpieniu każdego z map, aby zastosować funkcję mapy równoległej. (Tak, to też się zmniejszyło.)

HoneyBee to potężna biblioteka przyszłości dla Swift, która sprawia, że ​​pisanie asynchronicznych i współbieżnych algorytmów jest łatwiejsze, bezpieczniejsze i bardziej ekspresyjne. W tym artykule zobaczyliśmy, jak HoneyBee może sprawić, że Twoje algorytmy będą łatwiejsze w utrzymaniu, bardziej poprawne i szybsze. HoneyBee obsługuje również inne kluczowe paradygmaty asynchroniczne, takie jak obsługa ponawiania prób, obsługa wielu błędów, ochrona zasobów i przetwarzanie kolekcji (asynchroniczne formy mapowania, filtrowania i zmniejszania). Pełna lista funkcji znajduje się na stronie internetowej. Aby dowiedzieć się więcej lub zadać pytania, odwiedź zupełnie nowe fora społeczności.

Załącznik: Zapewnienie kontraktowej poprawności funkcji asynchronicznych

Zapewnienie kontraktowej poprawności funkcji jest podstawową zasadą informatyki. Do tego stopnia, że ​​praktycznie wszystkie współczesne kompilatory mają kontrole zapewniające, że funkcja, która deklaruje zwrócenie wartości, zwraca dokładnie raz. Zwracanie mniej lub więcej niż raz jest traktowane jako błąd i odpowiednio uniemożliwia pełną kompilację.

Ale ta pomoc kompilatora zwykle nie dotyczy funkcji asynchronicznych. Rozważ następujący (zabawny) przykład:

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

Funkcja generateIcecream akceptuje Int i asynchronicznie zwraca String. Szybki kompilator z radością przyjmuje powyższy formularz jako poprawny, mimo że zawiera pewne oczywiste problemy. Biorąc pod uwagę pewne dane wejściowe, ta funkcja może wywołać dokończenie zero, jeden lub dwa razy. Programiści, którzy pracowali z funkcjami asynchronicznymi, często przywołują przykłady tego problemu we własnej pracy. Co możemy zrobić? Z pewnością moglibyśmy przerobić kod, aby był ładniejszy (przydałby się tutaj przełącznik z przypadkami zasięgu). Ale czasami trudno jest zredukować złożoność funkcjonalną. Czy nie byłoby lepiej, gdyby kompilator pomagał nam w weryfikacji poprawności, tak jak robi to z regularnie zwracającymi się funkcjami?

Okazuje się, że istnieje sposób. Zwróć uwagę na następującą inkantację Swifty:

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

Cztery wiersze wstawione na początku tej funkcji zmuszają kompilator do sprawdzenia, czy wywołanie zwrotne zakończenia jest wywoływane dokładnie raz, co oznacza, że ​​ta funkcja już się nie kompiluje. Co się dzieje? W pierwszym wierszu deklarujemy, ale nie inicjujemy wyniku, który ostatecznie chcemy uzyskać dzięki tej funkcji. Pozostawiając go niezdefiniowanym, zapewniamy, że musi być przypisany raz, zanim będzie można go użyć, a deklarując go, zapewniamy, że nigdy nie będzie można go przypisać dwa razy. Druga linia to odroczenie, które zostanie wykonane jako ostateczna akcja tej funkcji. Wywołuje blok uzupełniania z finalResult - po przypisaniu go przez resztę funkcji. Linia 3 tworzy nową stałą zwaną uzupełnieniem, która zasłania parametr call-back. Nowe uzupełnienie jest typu Void, który deklaruje brak publicznego interfejsu API. Ten wiersz zapewnia, że ​​każde użycie uzupełniania po tym wierszu będzie błędem kompilatora. Odroczenie w wierszu 2 jest jedynym dozwolonym użyciem bloku uzupełniania. Wiersz 4 usuwa ostrzeżenie kompilatora, które w innym przypadku byłoby obecne o nieużywaniu nowej stałej uzupełniania.

Dlatego pomyślnie wymusiliśmy na szybkim kompilatorze zgłoszenie, że ta asynchroniczna funkcja nie spełnia swojego kontraktu. Przejdźmy przez kroki, aby to naprawić. Najpierw zastąpmy cały bezpośredni dostęp do wywołania zwrotnego przypisaniem do 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" } }

Teraz kompilator zgłasza dwa problemy:

 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"

Zgodnie z oczekiwaniami funkcja ma ścieżkę, w której finalResult jest przypisywany zero razy, a także ścieżkę, w której jest przypisywany więcej niż raz. Te problemy rozwiązujemy w następujący sposób:

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

„Pistachio” zostało przeniesione do odpowiedniej klauzuli else i zdajemy sobie sprawę, że nie omówiliśmy ogólnego przypadku – który oczywiście jest „neapolitański”.

Opisane właśnie wzorce można łatwo dostosować, aby zwracały opcjonalne wartości, opcjonalne błędy lub złożone typy, takie jak wspólne wyliczenie wyników. Zmuszając kompilator do sprawdzenia, czy wywołania zwrotne są wywoływane dokładnie raz, możemy zapewnić poprawność i kompletność funkcji asynchronicznych.