Odpowiedź na pytanie
Swift implementuje instrukcję defer za pomocą stosu thunków zamknięcia generowanego przez kompilator, który jest powiązany z każdym zakresem leksykalnym. Gdy kompilator napotyka blok defer, wyodrębnia kod do zamknięcia i rejestruje go w aktualnym rekordzie czyszczenia zakresu. Po opuszczeniu zakresu—czy to poprzez normalny przepływ, return, throw czy break—środowisko uruchomieniowe wykonuje te zamknięcia w kolejności Last-In-First-Out (LIFO). Ta dyscyplina stosu zapewnia, że zasoby nabyte później są zwalniane jako pierwsze, zachowując łańcuchy zależności bez ręcznego prowadzenia księgowości.
Historia pytania
Czyszczenie zasobów historycznie opierało się na określonych destruktorach lub rozbudowanej obsłudze wyjątków. C++ łączy czyszczenie z czasem życia obiektów za pomocą RAII, podczas gdy Java i C# wymagają jawnych bloków try-finally, które oddzielają logikę czyszczenia od kodu nabycia. Go wprowadził instrukcję defer, aby zapewnić czyszczenie oparte na zakresie bez narzutów obiektowych, co wpłynęło na projekt Swift. Swift przyjął defer w wersji 2.0, aby uzupełnić swój model obsługi błędów, oferując deklaratywną alternatywę dla finally, która integruje się z instrukcjami guard i wczesnymi zwrotami.
Problem
Złożone funkcje z wieloma ścieżkami wyjścia—takimi jak operacje na plikach z autoryzacją, rejestrowaniem i przesyłaniem danych—wymagają starannego zarządzania zasobami. Programiści muszą upewnić się, że każde miejsce return lub throw zwalnia wszystkie wcześniej nabyte zasoby, od deskryptorów plików po zakładki o zasięgu bezpieczeństwa. Przeoczenie pojedynczego punktu czyszczenia prowadzi do wycieków lub zakleszczeń, podczas gdy niewłaściwa kolejność (zamknięcie bazy danych przed opróżnieniem dziennika transakcji) powoduje uszkodzenie danych. Ręczne czyszczenie staje się niemożliwe do utrzymania w miarę wzrostu złożoności funkcji, co tworzy potrzebę automatycznego, deterministycznego i uporządkowanego usuwania zasobów związanych z granicami zakresu.
Rozwiązanie
Kompilator Swift przekształca instrukcje defer w stos wskaźników do funkcji przechowywanych w rekordzie aktywacji otaczającego zakresu. Każdy defer umieszcza swój thunk na tym zarządzanym przez kompilator stosie podczas wykonania. Gdy przepływ kontrolny dotrze do zamykającego nawiasu klamrowego zakresu lub napotka instrukcję wyjścia, wstrzyknięty kod epilogu iteruje przez stos w odwrotnej kolejności, wykonując każdy thunk. Ten mechanizm integruje się z obsługą błędów w Swift, gwarantując, że wszystkie oczekujące bloki defer wykonują się przed propagowaniem błędu do wyższego zakresu catch, zapewniając czyszczenie niezależnie od ścieżki wyjścia.
Sytuacja z życia
Rozważ aplikację iOS eksportującą zaszyfrowane dane użytkownika. Proces nabywa zasób URL o zasięgu bezpieczeństwa, otwiera FileHandle, zapisuje zaszyfrowane bajty i przesyła wynik. Każdy krok może się nie powieść i wymaga ścisłego czyszczenia, aby uniknąć wycieków deskryptorów plików lub trwałych zakładek zasobów.
Rozwiązanie 1: Ręczne czyszczenie w każdym punkcie wyjścia.
Programiści mogliby powielić fileHandle.close() i url.stopAccessingSecurityScopedResource() przed każdym return lub throw. To podejście jest wrażliwe; dodanie nowego sprawdzenia błędów wymaga aktualizacji wielu miejsc, a recenzenci muszą weryfikować, że kolejność czyszczenia odzwierciedla kolejność nabycia. Ryzyko wycieków wzrasta z każdym nowym punktem wyjścia dodanym podczas konserwacji.
Rozwiązanie 2: Obiekty opakowujące z deinit.
Stworzenie klasy ScopeManager, która wykonuje czyszczenie w swoim deinit, polega na korzystaniu z ARC. Jednak ARC nie gwarantuje natychmiastowej deallokacji przy opuszczaniu zakresu; obiekty mogą utrzymywać się, aż pula autorelease zostanie opróżniona lub zmienna zostanie nadpisana. W długoterminowych pętlach opóźnia to zwolnienie zasobów, powodując błędy systemu „za dużo otwartych plików”, które są trudne do odtworzenia.
Rozwiązanie 3: Bloki defer.
Zespół zadeklarował bloki defer tuż po nabyciu każdego zasobu:
func exportData() throws { let url = try acquireResource() defer { url.stopAccessingSecurityScopedResource() } let fileHandle = try FileHandle(forWritingTo: url) defer { fileHandle.close() } let encrypted = try encrypt(data) try fileHandle.write(encrypted) try upload(fileHandle) }
Gdy błąd szyfrowania wywołał throw, środowisko uruchomieniowe automatycznie zamknęło uchwyt pliku, a następnie przestało uzyskiwać dostęp do zasobu, zachowując poprawną odwrotną kolejność. To rozwiązanie zostało wybrane ze względu na swoją deterministyczność i lokalność—kod czyszczenia pojawia się obok kodu nabycia.
Wynik:
Funkcja eksportu przeszła test obciążeniowy z 10,000 równoczesnymi operacjami bez wycieków deskryptorów plików. Przegląd kodu ujawnił zero pominiętych ścieżek czyszczenia, a profilowanie pokazało natychmiastowe zwolnienie zasobów w porównaniu do podejścia deinit.
Co często umyka kandydatom
Pytanie 1: Czy blok defer wykonuje się, jeśli funkcja kończy się przez fatalError lub nieskończoną pętlę?
Nie. defer wykonuje się tylko wtedy, gdy przepływ kontrolny osiąga koniec swojego otaczającego zakresu. Jeśli wywołany jest fatalError, proces kończy się natychmiast bez rozwijania zakresów lub wykonywania bloków czyszczenia. Podobnie, nieskończona pętla while uniemożliwia wyjście z zakresu; bloki defer wewnątrz ciała pętli wykonują się tylko wtedy, gdy iteracja się kończy, ale pętla while true na poziomie funkcji nigdy nie uruchamia bloków defer na poziomie funkcji.
Pytanie 2: Jak defer obsługuje przechwytywanie zmiennych, gdy zmienna jest modyfikowana po zadeklarowaniu defer?
defer domyślnie przechwytuje zmienne przez referencję, a nie przez wartość. Na przykład:
var count = 0 defer { print("Deferred: \(count)") } count = 5 // Wydrukuje 5, a nie 0
Aby przechwycić wartość w momencie zadeklarowania, programiści muszą użyć jawnej listy przechwytywania: defer { [value = currentValue] in ... }. Kandydaci często zakładają, że defer przechwytuje zrzut na etapie deklaracji, co prowadzi do błędów logicznych w pętlach lub algorytmach mutujących.
Pytanie 3: Jaka jest kolejność wykonania, gdy bloki defer są zagnieżdżone wewnątrz gałęzi warunkowych w porównaniu do zakresu nadrzędnego?
Bloki defer są powiązane z zakresem leksykalnym, w którym się pojawiają, a nie z zakresem funkcji. defer wewnątrz bloku if jest wykonywany, gdy ten blok if się kończy, a nie gdy funkcja zwraca. Jeśli w różnych poziomach zagnieżdżenia znajdują się wiele bloków defer, najgłębszy zakres defer wykonuje się jako pierwszy po opuszczeniu tego konkretnego bloku. To prowadzi do nieintuicyjnego porządku, gdy programiści oczekują, że wszystkie bloki defer zostaną uruchomione przy wyjściu z funkcji, zwłaszcza gdy są przeplatane z instrukcjami guard, które tworzą wczesne wyjścia z podzakresów.