Swift wprowadził zorganizowane zarządzanie błędami w wersji 2.0, zastępując wskaźniki błędów w Objective-C’u natywną semantyką throw i catch. Słowo kluczowe rethrows powstało, aby rozwiązać konkretny problem, w którym ogólne funkcje wyższego rzędu, takie jak map lub filter, zmuszały wywołujących do używania try, nawet gdy przekazywano nie-wyrzucające zamknięcia, co wprowadzało niepotrzebne ceremonie obsługi błędów.
Problem koncentruje się na polimorfizmie efektów funkcji i podtypizacji. W systemie typów Swift nie-wyrzucające zamknięcie jest podtypem zamknięcia wyrzucającego, ponieważ spełnia kontrakt "może wyrzucić" poprzez nigdy nie wyrzucanie. Bez rethrows, funkcja akceptująca zamknięcie wyrzucające musi bezwarunkowo propagować błędy, zmuszając wszystkie miejsca wywołania do obsługi błędów, niezależnie od rzeczywistego zachowania argumentu.
Rozwiązaniem jest adnotacja rethrows, która ustanawia warunkowy kontrakt: funkcja wyrzuca tylko wtedy, gdy jej parametr zamknięcia wyrzuca. Kompilator Swift implementuje to, śledząc status wyrzucania argumentów zamknięcia w czasie kompilacji. Gdy przekazywane jest nie-wyrzucające zamknięcie, funkcja traktowana jest jako nie-wyrzucająca w miejscu wywołania, eliminując potrzebę try; gdy przekazywane jest wyrzucające zamknięcie, funkcja dziedziczy efekt wyrzucania.
Budowaliśmy modułowy potok transformacji danych dla aplikacji iOS, w której użytkownicy mogli łączyć operacje takie jak parsowanie JSON, zmiana rozmiaru obrazów i kryptograficzne hashowanie. Funkcja pipeline akceptowała tablicę transformacji zdefiniowanych jako (Data) throws -> Data. Początkowo używaliśmy standardowej adnotacji throws na pipeline, co zmuszało każde miejsce wywołania do owinięcia nawet prostych transformacji w bloki do-catch, pomimo że wiele operacji było funkcjami czystymi bez trybów błędu.
Nasze pierwsze podejście polegało na duplikacji całej funkcji: jedna wersja nazwana pipeline dla nie-wyrzucających transformacji i druga nazwana pipelineThrowing dla wyrzucających. Ta separacja pozwalała na czyste miejsca wywołania, ale stworzyła koszmar utrzymaniowy, gdzie każda poprawka błędów wymagała edytowania dwóch lokalizacji, a powierzchnia API podwajała się z każdą nową opcją konfiguracyjną. Dodatkowo, użytkownicy musieli znać szczegóły implementacji, aby wybrać poprawną metodę, co naruszało zasady enkapsulacji.
Drugie podejście zachowało jedną sygnaturę throws, ale zachęcało do używania try?, aby zignorować ostrzeżenia, efektywnie odrzucając informacje o błędach i uniemożliwiając debugowanie, gdy wystąpiły rzeczywiste błędy. To naruszało gwarancje bezpieczeństwa i sprawiło, że kod stał się kruchy, ponieważ programiści zapominali obsługiwać rzeczywiste przypadki błędów w mieszanych potokach zawierających zarówno bezpieczne, jak i niebezpieczne operacje.
Ostatecznie przyjęliśmy rozwiązanie rethrows, deklarując func pipeline(_ transforms: [(Data) throws -> Data]) rethrows -> Data. To pozwoliło kompilatorowi egzekwować try tylko wtedy, gdy tablica zamknięć zawierała operacje wyrzucające, umożliwiając bezpośrednie wywołania dla czystych obliczeń. Efektem była 40% redukcja kodu szablonowego, eliminacja zduplikowanych sygnatur funkcji oraz poprawa ergonomiki API, gdzie system typów dokładnie odzwierciedlał rzeczywiste obszary błędów konkretnych przypadków użycia.
Dlaczego Swift zabrania wyrzucania błędów bezpośrednio w ciele funkcji rethrows, a nie wyłącznie za pośrednictwem parametru zamknięcia?
Słowo kluczowe rethrows tworzy ścisły kontrakt przejrzystości, stwierdzający, że funkcja propaguje tylko błędy generowane przez swoje argumenty. Jeśli spróbujesz throw CustomError() bezpośrednio w ciele funkcji, kompilator Swift odrzuci to, ponieważ stanowi to bezwarunkowe wyrzucanie, naruszające gwarancję "tylko wtedy, gdy zamknięcie wyrzuca". Funkcja musi albo obsługiwać swoje własne błędy wewnętrznie, używając do-catch, albo przekształcać je w wartości zwracane, lub podnosić sygnaturę do bezwarunkowego throws, zapewniając, że wywołujący mogą bezpiecznie założyć, że żadne nowe obszary błędów nie pochodzą z samej funkcji.
Jak rethrows współdziała z wieloma parametrami zamknięcia i jakie są tego konsekwencje dla propagacji efektów?
Gdy funkcja ma wiele parametrów zamknięcia oznaczonych jako wyrzucające, a sama funkcja jest oznaczona jako rethrows, funkcja wyrzuca, jeśli jakiekolwiek z zamknięć wyrzuca, tworząc zbiór efektów. Kompilator Swift śledzi te efekty indywidualnie przez łańcuch wywołań, więc komponowanie funkcji rethrows zachowuje warunkowy charakter bez ręcznej interwencji. Jednak, jeśli przekształcisz lub owinięesz zamknięcia przed ich przekazaniem, musisz zachować sygnaturę wyrzucania w owijaczu, w przeciwnym razie kompilator potraktuje argument jako nie-wyrzucający, co spowoduje, że zewnętrzna funkcja straci swoją warunkową zdolność do wyrzucania.
Jaki jest związek między rethrows a @autoclosure, i dlaczego ten wzorzec pojawia się w API asercji?
Połączenie @autoclosure i rethrows umożliwia leniwe obliczenia z warunkową propagacją błędów, gdzie autoclosure opóźnia ocenę do czasu, gdy jest potrzebne, a funkcja wyrzuca tylko wtedy, gdy ta opóźniona ocena wyrzuca. Ten wzorzec napędza funkcje assert i precondition w Swift, umożliwiając przekazywanie wyrażeniom wyrzucającym do asercji bez oznaczania wywołania asercji jako try. Kandydaci często przeoczają to, że autoclosure musi wyraźnie zadeklarować () throws -> T, aby wziąć udział w kontrakcie rethrows, oraz że ta mechanika oddziela czas oceny (leniwy) od semantyki propagacji błędów (warunkowa), co jest kluczowe dla krytycznych ścieżek kodu, gdzie asercje są wyłączone w wersjach produkcyjnych.