Swift umożliwia nieograniczoną rekurencję w enumach typu wartości przy użyciu słowa kluczowego indirect, które wymusza, aby konkretne przypadki przechowywały swoje powiązane wartości w kontenerach przydzielonych na stercie i zliczanych referencyjnie. Kiedy przypadek jest oznaczony jako indirect, kompilator przekształca przechowywanie danych ładunku inline w wskaźnik do kontenera przydzielonego na stercie zarządzanego przez ARC. Ta indykcja pozwala enumowi odnosić się do siebie rekurencyjnie bez nieograniczonego powiększenia rozmiaru, ponieważ kompilator musi przechowywać tylko wskaźnik, a nie pełną wartość inline.
Jednak ta transformacja znacznie wpływa na wydajność dopasowania wzorców. Każdy dostęp do przypadku indirect wymaga podążania za wskaźnikami, aby dotrzeć do ładunku, co pogarsza lokalność pamięci podręcznej CPU w porównaniu do enumów przechowywanych całkowicie na stosie. Dodatkowo, przydział na stercie wprowadza operacje zliczania i zwalniania atomowego, które zwiększają narzuty synchronizacyjne w kontekstach równoległych, nawet jeśli sam enum utrzymuje semantykę wartości na poziomie języka.
indirect enum Expression { case literal(Int) case add(Expression, Expression) case multiply(Expression, Expression) } // Dopasowanie wzorców wymaga dereferencji func evaluate(_ expr: Expression) -> Int { switch expr { case .literal(let value): return value case .add(let left, let right): return evaluate(left) + evaluate(right) case .multiply(let left, let right): return evaluate(left) * evaluate(right) } }
Opracowywaliśmy parser języka specyficznego dla danego domeny dla silnika konfiguracyjnego, który musiał przetwarzać głęboko zagnieżdżone wyrażenia logiczne. Wstępna implementacja używała rekurencyjnego enumu do reprezentowania AST wyrażenia bez adnotacji indirect, co natychmiastowo wywoływało awarie przepełnienia stosu przy przetwarzaniu plików konfiguracyjnych z głębokościami zagnieżdżenia przekraczającymi kilka tysięcy poziomów.
Pierwsze rozważane rozwiązanie polegało na całkowitym porzuceniu enumów na rzecz struktury drzewa opartej na klasach z odniesieniami do rodziców i dzieci. Podejście to zapewniałoby naturalny przydział na stercie dla relacji rekurencyjnych. Odrzuciliśmy to jednak, ponieważ poświęcało to semantykę wartości, co czyniło niemożliwym bezpieczne dzielenie się analizowanymi poddrzewami pomiędzy równoległymi wątkami kompilacji bez wdrażania skomplikowanego kopiowania defensywnego lub mechanizmów blokowania.
Wybraliśmy drugie rozwiązanie: zastosowanie indirect wyłącznie do rekurencyjnych przypadków w enumie, takich jak te zawierające dzieciece wyrażenia. To zachowało semantykę wartości, zmuszając przydział na stercie tylko tam, gdzie to konieczne dla nieograniczonej rekurencji. Kompromis był akceptowalny, ponieważ zachowaliśmy gwarancje niezmienności i bezpieczeństwa typów, chociaż musieliśmy wdrożyć niestandardowe optymalizacje kopiowania przy zapisie dla często modyfikowanych drzew wyrażeń.
Rezultatem był stabilny parser zdolny do przetwarzania dowolnie głębokiego zagnieżdżenia. Profilowanie później ujawniło, że dopasowanie wzorców do przypadków indirect zużywało około dwudziestu procent więcej cykli CPU z powodu indykcji wskaźników i ruchu ARC, co zminimalizowaliśmy poprzez spłaszczanie małych struktur o stałej głębokości do nieindirectowych enumów pomocniczych dla powszechnych przypadków.
Jak indirect współdziała z optymalizacją kopiowania przy zapisie w Swift?
Wielu kandydatów zakłada, że przypadki indirect zawsze wyzwalają głębokie kopiowanie całej struktury rekurencyjnej. W rzeczywistości Swift stosuje semantykę kopiowania przy zapisie do kontenera na stosie zawierającego ładunek pośredni. Kiedy enum z przypadkiem indirect jest przypisywany do nowej zmiennej, kompilator zachowuje odniesienie do kontenera na stercie zamiast kopiować zawartość. Ładunek jest kopiowany tylko wtedy, gdy występuje operacja modyfikacji i liczba referencji przekracza jeden. Ta optymalizacja jest kluczowa dla wydajności z dużymi strukturami rekurencyjnymi, ale wymaga starannego rozważenia podczas korzystania z bezpieczeństwa wątków, ponieważ same zliczenia referencji są atomowe, ale logika kopiowania przy zapisie wymaga synchronizacji między wątkami.
Czy można zastosować indirect do poszczególnych przypadków, a nie do całego enumu, i jakie są konsekwencje dla układu pamięci?
Kandydaci często wierzą, że indirect musi mieć zastosowanie do całej deklaracji enumu. Jednak Swift pozwala na oznaczanie indywidualnych przypadków jako indirect, co znacząco wpływa na układ pamięci. Gdy konkretne przypadki są oznaczone jako indirect, enum wykorzystuje reprezentację wskaźników oznaczonych, gdzie przypadki pośrednie zajmują wskaźnik o rozmiarze słowa do kontenera na stercie, podczas gdy przypadki nieindirectowe przechowują swoje ładunki inline w obrębie pamięci enumu. Ta mieszana reprezentacja optymalizuje zużycie pamięci dla enumów, w których tylko niektóre przypadki wymagają rekurencji. Jednak wprowadza to złożoność w dopasowaniu wzorców, ponieważ kompilator musi generować różne ścieżki dostępu do kodu dla ładunków inline i nieindirectowych, a całkowity rozmiar enumu jest określany przez największy ładunek inline oraz bity tagów, a nie rozmiary przypadków pośrednich.
Dlaczego rekurencyjne enumy z indirect mogą tworzyć cykle referencyjne, gdy są zaangażowane zamknięcia, i jak to różni się od standardowego zachowania typów wartości?
To subtelny punkt, który ujawnia głębokie zrozumienie ARC. Normalnie typy wartości, takie jak enumy, nie mogą tworzyć cykli referencyjnych, ponieważ nie mają tożsamości i zliczania referencji na poziomie wartości. Jednak gdy przypadek jest oznaczony jako indirect, ładunek jest przydzielany na stercie i zliczany referencyjnie. Jeśli powiązane wartości przypadku indirect zawierają zamknięcie, które uchwyca sam enum, a to zamknięcie zostaje zapisane z powrotem do powiązanych wartości enumu, powstaje cykl referencyjny między kontenerem na stercie a zamknięciem. Jest to różne od cykli opartych na klasach, ponieważ cykl istnieje w kontenerze przydzielonym na stercie, a nie w samej wartości enumu. Aby przerwać cykl, musisz użyć list uchwytów takich jak [weak self] lub [unowned self], ale ponieważ enumy są zazwyczaj typami wartości, programiści często zapominają, że indirect wprowadza semantykę odniesienia dla ładunku, co wymaga takiej samej czujności jak klasy podczas pracy z zamknięciami.