Protokoły w Swift z powiązanymi typami (PAT) lub wymaganiami Self nie mogą działać jako pierwszorzędne typy egzystencjalne (np. [MyProtocol]), ponieważ kompilator nie ma wymaganych metadanych typu konkretnego potrzebnych do skonstruowania tabel świadków dla powiązanych typów w czasie kompilacji. Ograniczenie to uniemożliwia heterogenicznym kolekcjom przechowywanie instancji bezpośrednio, ponieważ układ pamięci dla powiązanych typów różni się w zależności od typów dostosowujących. Programiści rozwiązują to ograniczenie poprzez wzorce maskowania typów, wdrażając opakowania boksujące, które wykorzystują tabele świadków protokołów lub dispatch oparty na closures, aby ujednolicić dostęp do interfejsu, jednocześnie enkapsulując złożoność związanych typów.
Podczas projektowania wieloplatformowego silnika multimedialnego nasz zespół potrzebował PlaylistController, który mógłby zarządzać różnorodnymi kodekami audio - w tym MP3, AAC i FLAC, z których każdy implementował protokół Playable z powiązanym typem Buffer reprezentującym zdekodowane próbki audio. Powiązany Buffer znacznie różnił się w zależności od formatu: niekompresowane dane PCM dla FLAC kontra skompresowane pakiety dla MP3, co stworzyło niekompatybilne układy pamięci, które uniemożliwiały standardowe przechowywanie polimorficzne.
Jednym z podejść jest użycie specjalizacji generycznej za pomocą Playlist<T: Playable>, co ogranicza całą kolekcję do jednego konkretnego typu. Eliminuję dodatkowe koszty związane z dispatchingiem w czasie działania i umożliwia agresywne optymalizacje kompilatora, takie jak inlining. Jednak to podejście całkowicie poświęca polimorfizm, uniemożliwiając użytkownikom mieszanie utworów MP3 i FLAC w tej samej strukturze playlisty.
Alternatywnie, programiści mogą wykorzystać wbudowane kontenery egzystencjalne Swift za pomocą składni [any Playable] dostępnej w nowoczesnym Swifcie. Mimo że to wspiera heterogeniczne przechowywanie, uzyskanie dostępu do powiązanego typu Buffer wymaga ręcznego otwierania egzystencji w każdym miejscu wywołania, co prowadzi do werbalnego boilerplate i wymusza alokację na stercie dla dużych typów wartości. Dodatkowo, utrata informacji o typie konkretnego uniemożliwia kompilatorowi devirtualizację wywołań metod, wprowadzając wymierne opóźnienia w wąskich pętlach przetwarzania audio.
Optymalne rozwiązanie implementuje ręczne opakowanie maskujące typ nazwaną AnyPlayable, wykorzystującą oparte na closures tabele świadków do delegowania metod play() i stop(). To opakowanie przechowuje konkretną instancję w kontenerze opartym na klasie lub w buforze egzystencjalnym, ukrywając złożoność powiązanych typów, jednocześnie ujawniając jednolity interfejs. Choć to wprowadza narzut pośrednictwa porównywalny do dispatchingu wirtualnego, skutecznie abstrahuje różnice w implementacji buforów i wspiera prawdziwe heterogeniczne kolekcje bez złożoności rzutowania w czasie działania.
Wybraliśmy podejście z opakowaniem maskującym typ, ponieważ aplikacje multimedialne zasadniczo wymagają mieszania różnych kodeków w jednolitych playlistach, a narzut wynikający z dispatchingu wirtualnego pozostaje znikomy w porównaniu do opóźnienia I/O w przesyłaniu dźwięku. Implementacja umożliwiła płynne zintegrowanie proprietarnych formatów DRM z standardowymi kodekami bez modyfikacji architektury Controller. Ostatecznie to utrzymywało bezpieczeństwo typów w czasie kompilacji podczas inicjalizacji utworów, zapewniając jednocześnie elastyczność w czasie działania, niezbędną dla użytkownikowskich bibliotek treści.
Pytanie 1: Dlaczego nie możemy po prostu użyć as! any Playable, aby rzutować typy konkretne na egzystencjalne, gdy mamy do czynienia z powiązanymi typami?
Swift zabrania używania protokołów z powiązanymi typami jako nagich egzystencji, ponieważ kontener egzystencjalny wymaga magazynowania o stałej wielkości (typowo trzy słowa), podczas gdy powiązane typy mogą wymagać dowolnie dużych zasobów pamięci. Gdy powiązany typ Buffer reprezentuje 512-bajtowy zdekodowany ramkę dla FLAC, ale 4-bajtowy indeks pakietu dla MP3, egzystencjal nie może pomieścić obu wielkości w pamięci bez znajomości typu konkretnego w czasie kompilacji. W związku z tym kompilator egzekwuje maskowanie typów lub ograniczenia generyczne, aby zapewnić bezpieczeństwo pamięci, zapobiegając awariom w czasie wykonania z powodu uszkodzeń stosu lub przepełnienia bufora.
Pytanie 2: Czym różnią się nieprzezroczyste typy wynikowe (some Collection) w Swift 5.1 od opakowań maskujących w zakresie wydajności i ewolucji API?
Nieprzezroczyste typy wynikowe wykorzystują odwrócone generiki i specjalizację w czasie kompilacji, umożliwiając kompilatorowi zachowanie pełnych informacji o typie konkretnym, jednocześnie ukrywając szczegóły implementacji przed wywołującymi. Unika to kar związanych z dispatchingiem wirtualnym i kosztami alokacji na stercie charakterystycznymi dla ręcznych opakowań maskujących typ. Niemniej jednak nieprzezroczyste typy wymagają, aby typ bazowy pozostał stały w punkcie zwrotu (z wyjątkiem SE-0368 dla wielu nieprzezroczystych wyników), podczas gdy opakowania maskujące pozwalają na dynamiczną zmianę typów konkretnych w tym samym kontenerze w czasie działania, wymieniając wydajność na elastyczność polimorficzną.
Pytanie 3: Jakie zagrożenia związane z zarządzaniem pamięcią mogą pojawić się, gdy opakowania maskujące typy przechwycają protokoły z odniesieniami do siebie (np. protokoły z metodami zwracającymi Self) w środowiskach wielowątkowych?
Opakowania maskujące typy często wykorzystują oparte na klasach osłony lub przechwytywanie closures do przechowywania konkretnych instancji. Gdy protokół wymaga zwrócenia Self lub używa powiązanych typów odnoszących się do Self, opakowanie musi zachować tożsamość typów za pomocą semantyki odniesienia, co może tworzyć potencjalne cykle zatrzymywania, jeśli typ konkretny posiada odniesienie zwrotne do opakowania. W kontekście współbieżnym wiele wątków modyfikujących stan boksu może wywołać warunki wyścigu na liczniku odniesień lub wewnętrznych buforach. Programiści muszą zapewnić, że opakowanie poprawnie przestrzega zasady Sendable, zazwyczaj implementując izolację Actor lub semantykę wartości niemutowalnych w obrębie opakowania, zapobiegając wyścigom danych, jednocześnie utrzymując abstrakcję interfejsu.