Swift 5.1 wprowadził opakowania właściwości poprzez SE-0258, aby wyeliminować powtarzalny kod akcesorów. Wymóg projectedValue został zaprojektowany w celu ujawnienia drugorzędnych interfejsów API—takich jak Binding z SwiftUI lub stany walidacji—poza samą wartością opakowaną. Ta funkcja pozwala programistom uzyskiwać dostęp do metadanych lub projekcji za pomocą składni prefiksu $.
Problem polega na tym, że Swift musi przekształcać składnię deklaratywną w ważny SIL (Swift Intermediate Language) bez wprowadzania kolizji nazw ani naruszania kontroli dostępu. Kompilator musi syntetyzować przechowywanie, które utrzymuje semantykę wartości dla opakowanej właściwości, jednocześnie potencjalnie ujawniając semantykę referencyjną poprzez projekcję, wszystko to zapewniając, że identyfikator z prefiksem $ nie koliduje z członami zdefiniowanymi przez użytkownika.
Rozwiązanie polega na desugaringu z kodu źródłowego. Dla właściwości zadeklarowanej jako @Wrapper var property: T, kompilator generuje trzy odrębne człony. Po pierwsze, prywatna zmienna przechowująca _property typu Wrapper<T>. Po drugie, właściwość obliczana property, która przekazuje operacje get/set do _property.wrappedValue. Po trzecie, właściwość obliczana $property, która zwraca _property.projectedValue. Właściwość z prefiksem $ dziedziczy kontrolę dostępu z oryginalnej deklaracji, a kompilator egzekwuje, że projectedValue istnieje, gdy wykorzystuje się składnię $.
@propertyWrapper struct Validating<T> { var wrappedValue: T var projectedValue: ValidationState<T> init(wrappedValue: T) { self.wrappedValue = wrappedValue self.projectedValue = ValidationState(value: wrappedValue) } } // Desugaring: struct Form { private var _username: Validating<String> var username: String { get { _username.wrappedValue } set { _username.wrappedValue = newValue } } var $username: ValidationState<String> { get { _username.projectedValue } } }
Projektowaliśmy aplikację do wprowadzania danych medycznych, w której każde pole musiało śledzić zarówno swoją bieżącą wartość, jak i złożoną historię walidacji, w tym wcześniejsze błędy i znaczniki czasu poprawek. Wyzwanie wymagało ujawnienia dwóch odrębnych ścieżek danych z pojedynczej abstrakcji właściwości: surowego ciągu dla pola tekstowego UI oraz historii walidacji do analizy i wyświetlania błędów.
Pierwsze podejście, które rozważono, polegało na utrzymywaniu równoległego słownika mapującego nazwy właściwości na obiekty ValidationHistory. To oferowało elastyczność w przechowywaniu, ale wprowadzało API oparte na stringach, które łamały się podczas refaktoryzacji i wymagały ręcznej synchronizacji między słownikiem a rzeczywistymi wartościami właściwości. Ryzyko desynchronizacji prowadzące do przestarzałych wyświetleń błędów było nieakceptowalnie wysokie w przypadku danych medycznych.
Drugie podejście polegało na stworzeniu struktury opakowującej, która zawierała zarówno wartość, jak i historię, a następnie użycie tego skomponowanego typu jako typu właściwości. Choć bezpieczne pod względem typów, zanieczyściło to model domeny kwestiami walidacji i zmusiło każdy punkt dostępu do obsługi rozpakowywania, niszcząc cel czystego rozdzielenia architektury między UI a logiką biznesową.
Trzecie podejście wykorzystało niestandardowe opakowanie właściwości @Validated z projectedValue zwracającym typ referencyjny ValidationHistory. To wewnętrznie kapsułkowało synchronizację, jednocześnie ujawniając $fieldName do dostępu do historii. Wybraliśmy to, ponieważ utrzymywało semantykę CoW (Copy-on-Write) dla opakowanej wartości ciągu, zapewniając jednocześnie stabilną tożsamość referencyjną dla historii walidacji, co zapewniało komponentom UI możliwość obserwowania zmian bez overheadu kopiowania.
Wynik wyeliminował całą klasę błędów synchronizacji i zmniejszył kod związany z walidacją o 35%. Składnia $ zapewniła intuicyjne odkrywanie dla młodszych programistów, a egzekwowanie w czasie kompilacji zapobiegło przypadkowemu ujawnieniu szczegółów implementacji w granicach modułów.
Dlaczego mutacje wartości-typu projectedValue nie utrzymują się, gdy są dostępne za pomocą prefiksu dolara?
Kiedy opakowanie właściwości jest strukturą, getter projectedValue zwraca kopię wartości. Jeśli projectedValue zwraca strukturę (taką jak Int lub niestandardowa struktura stanu walidacji), takie instrukcje jak $property.errorCount += 1 zmieniają tymczasową kopię, która jest natychmiast odrzucana. Aby umożliwić trwałe mutacje, projectedValue musi zwracać typ referencyjny lub opakowanie musi implementować CoW z zapleczem opartym na klasie. Alternatywnie, zwróć Binding lub wskaźnik mutowalny, który zapewnia indirekcję. Początkujący często zakładają, że $property zapewnia mutowalny dostęp do wewnętrznego stanu opakowania, nie uwzględniając semantyki wartości Swift.
Jak kontrola dostępu syntetyzowanej właściwości z prefiksem dolara współdziała z poziomem dostępu oryginalnej właściwości?
Kompilator syntetyzuje właściwość z prefiksem $ z identyczną kontrolą dostępu, jak oryginalna właściwość. Jeśli zadeklarujesz public @Wrapper var name: String, zarówno name, jak i $name są public. Z drugiej strony, właściwości private generują private wartości projekcji. Kandydaci często próbują uczynić wartość opakowaną publiczną, jednocześnie utrzymując wartość projekcji jako wewnętrzną lub prywatną, co w obecnych wersjach Swift jest niemożliwe. Obejście wymaga uczynienia właściwości private i ujawnienia wartości opakowanej przez wyraźną właściwość obliczaną, podczas gdy wartość projekcji pozostaje ograniczona.
Czy jedno opakowanie właściwości może ujawniać wiele odrębnych projekcji i jakie są tego ergonomicze konsekwencje?
Swift ściśle pozwala tylko na jedną właściwość projectedValue na opakowanie. Jednak ta właściwość może zwracać krotkę, strukturę lub enum zawierający wiele wartości (np. projectedValue: (Binding<T>, ValidationError?, Bool)). Ergonomiczny kompromis polega na tym, że $property wtedy wymaga składni kropkowej do uzyskania dostępu do komponentów ($property.0, $property.isValid), co obniża czytelność. Niektórzy kandydaci próbują zadeklarować wiele właściwości projectedValue lub zastosować wiele opakowań właściwości do tej samej właściwości (ładowanie). Chociaż ładowanie jest wspierane, tworzy złożoną semantykę inicjalizacji i problemy z nieprzezroczystym wnioskowaniem typów. Zalecanym podejściem do wielu projekcji jest zwracanie dedykowanej struktury projekcji z nazwanymi właściwościami, zachowując bezpieczeństwo typów przy akceptacji nadmiaru składni.