SwiftprogramowanieProgramista Swift

Jak Swift rozróżnia metody protokołu, które są wywoływane dynamicznie za pośrednictwem tabel świadków, a te, które są rozwiązywane statycznie w czasie kompilacji, gdy są definiowane w rozszerzeniach, oraz jakie różnice w zachowaniu występują podczas wywoływania tych metod za pośrednictwem typów egzystencjalnych?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia pytania

Swift został zaprojektowany, aby wypełnić lukę między zerowymi kosztami abstrakcji w C++ a dynamiczną elastycznością Objective-C. Wczesne wersje opierały się w dużej mierze na dziedziczeniu klas i wirtualnych tabelach metod, ale wprowadzenie programowania zorientowanego na protokoły w Swift 2.0 wymagało bardziej złożonego modelu wywoływania. Zespół kompilatora zdecydował się na hybrydowe podejście, w którym wymagania protokołu (metody zadeklarowane w ciele protokołu) wykorzystują tabele świadków do polimorfizmu w czasie wykonywania, podczas gdy metody definiowane wyłącznie w rozszerzeniach są rozwiązywane statycznie. Ta decyzja projektowa wynikała z potrzeby wsparcia modelowania retroaktywnego i typów wartościowych bez poświęcania charakterystyk wydajności statycznego wywołania.

Problem

Programiści często zakładają, że dostarczenie implementacji metody w rozszerzeniu protokołu tworzy "domyślne" zachowanie, które typy zgodne mogą polimorficznie nadpisywać. Jednak Swift wywołuje metody rozszerzeń statycznie na podstawie typu w czasie kompilacji odniesienia, a nie typu w czasie wykonania instancji. Podczas korzystania z egzystencjalnych pudełek (any Protocol), typ w czasie kompilacji to sam kontener egzystencjalny, co powoduje, że wywołania zyskują implementację rozszerzenia, niezależnie od jakichkolwiek nadpisów w konkretnej typach. To tworzy podstępne błędy, gdzie niestandardowe implementacje w podklasach lub strukturach są cicho pomijane w heterogenicznych kolekcjach.

Rozwiązanie

Aby umożliwić prawdziwy polimorfizm dynamiczny, metoda musi być zadeklarowana jako wymaganie protokołu w samej deklaracji protokołu. To zmusza kompilator do przydzielenia wpisu w tabeli świadków dla metody, co pozwala na runtime na wyszukiwanie poprawnej implementacji za pośrednictwem tabeli świadków typu. Dla algorytmów krytycznych dla wydajności, gdzie polimorfizm jest niepotrzebny, metody powinny pozostać w rozszerzeniach, aby umożliwić kompilatorowi wprowadzenie ich w linii lub wykonanie innych optymalizacji statycznych. Swift 5.6+ wprowadził składnię wyraźnego słowa kluczowego any, aby bardziej widocznie uczynić usuwanie typów egzystencjalnych, co służy jako przypomnienie, że informacje o typie zostają utracone, a statyczne wywołania domyślnie odnoszą się do rozszerzeń.

protocol Drawable { func draw() // Wymaganie: dynamiczne wywołanie przez tabelę świadków } extension Drawable { func draw() { print("Default") } func render() { print("Static render") } // Rozszerzenie: tylko statyczne wywołanie } struct Circle: Drawable { func draw() { print("Circle") } func render() { print("Circle render") } } let shape: any Drawable = Circle() shape.draw() // Wydrukuje "Circle" (dynamiczne wywołanie) shape.render() // Wydrukuje "Static render" (statyczne wywołanie - ignoruje wersję Circle!)

Sytuacja z życia

Rozwijaliśmy silnik grafiki wektorowej, w którym różne kształty były zgodne z protokołem RenderCommand. Początkowo dodaliśmy metodę generatePreview() wyłącznie w rozszerzeniu protokołu, aby zapewnić domyślny zatarczony miniaturę dla wszystkich kształtów. Konkretne typy, takie jak BezierCurve i Polygon, zaimplementowały swoje własne zoptymalizowane metody generatePreview(), które wykorzystywały ich specyficzne właściwości geometryczne dla wyraźnego renderowania. Kiedy przechowywaliśmy te kształty w tablicy [any RenderCommand], aby przetworzyć pipeline renderowania, odkryliśmy, że wywoływanie generatePreview() na każdym elemencie generowało ten sam rozmyty domyślny obraz zamiast niestandardowych, wysokiej jakości podglądów.

Rozważyliśmy trzy różne rozwiązania. Po pierwsze, mogliśmy przenieść generatePreview() do definicji protokołu RenderCommand jako formalne wymaganie. To podejście gwarantowałoby dynamiczne wywołanie za pośrednictwem tabeli świadków, zapewniając poprawne rozwiązanie metody w czasie wykonywania. Jednak wymusiłoby to, aby każdy typ kształtu musiał jawnie zadeklarować metodę w swojej zgodności, chociaż moglibyśmy złagodzić nadmiar poprzez zachowanie domyślnej implementacji w rozszerzeniu dla typów, które nie potrzebowały dostosowania.

Po drugie, moglibyśmy przerobić nasz pipeline, aby używać typów generycznych z podpisem funkcji takiego jak func process<T: RenderCommand>(commands: [T]) zamiast używać egzystencjalnych [any RenderCommand]. Zachowałoby to statyczne wywołania dla poprawnej implementacji, ponieważ Swift monomorfizuje generiki w czasie kompilacji, zachowując informacje o typie. Wadą było to, że nie mogliśmy już przechowywać heterogenicznych typów kształtów (mieszając BezierCurve i Polygon) w jednej tablicy bez implementacji opakowania usuwającego typ, co znacznie zwiększyłoby złożoność kodu.

Po trzecie, mogliśmy wdrożyć wzorzec Visitor, aby ręcznie kierować wywołania metod do odpowiedniego konkretnego typu. To uniknęłoby całkowitej modyfikacji definicji protokołu, jednocześnie osiągając polimorficzne zachowanie. Jednak to rozwiązanie wprowadziło znaczny nadmiar kodu i stworzyło problem konserwacyjny za każdym razem, gdy nowe typy kształtów były dodawane do systemu.

Ostatecznie wybraliśmy pierwsze rozwiązanie, ponieważ protokół był wewnętrzny dla naszego modułu, a klarowność zachowania polimorficznego była kluczowa dla poprawności silnika renderowania. Dodanie wymogu miało znikomy wpływ na rozmiar naszego pliku binarnego, a niewielkie opóźnienie pośredniczenia w tabeli świadków było nieodczuwalne w porównaniu do obliczeń renderowania. Po wprowadzeniu tej zmiany generowanie podglądów poprawnie wykorzystywało zoptymalizowaną implementację każdego kształtu, eliminując wizualne artefakty z UI.

Co często umyka kandydatom

Dlaczego klasa podrzędna nie może nadpisać metody, która została zdefiniowana tylko w rozszerzeniu protokołu?

Kiedy metoda jest zdefiniowana wyłącznie w rozszerzeniu protokołu i nie jest zadeklarowana w samym protokole, Swift nie przydziela wpisu w tabeli świadków dla niej. Wywołanie jest rozwiązywane statycznie w czasie kompilacji na podstawie typu odniesienia. Jeśli klasa jest zgodna z protokołem i definiuje metodę o tej samej sygnaturze, tworzy nową, niepowiązaną metodę, która zasłania metodę rozszerzenia, a nie ją nadpisuje. Oznacza to, że gdy uzyskuje się dostęp za pośrednictwem egzystencjalnego protokołu (any Protocol), implementacja rozszerzenia protokołu jest zawsze wywoływana, ignorując wersję klasy. Aby uzyskać zachowanie polimorficzne, metoda musi być zadeklarowana w deklaracji protokołu, aby stać się wymaganiem z dynamicznym wywołaniem.

Jak użycie some (typy wynikowe nieprzezroczyste) zamiast any wpływa na wywołanie metod rozszerzeń protokołu?

Z some Drawable konkretny typ jest znany w czasie kompilacji dzięki monomorfizacji generyków w Swift. Podczas wywoływania metody rozszerzenia na typie nieprzezroczystym, kompilator może statycznie wywołać implementację konkretnego typu, ponieważ informacje o typie są zachowane za kulisami, nawet jeśli są ukryte przed wywołującym. W przeciwieństwie do tego, any Drawable to egzystencjalne pudełko, które usuwa konkretny typ, zmuszając kompilator do użycia domyślnej implementacji rozszerzenia dla metod, które nie są wymaganiami. Kluczową różnicą jest to, że some zachowuje statyczny polimorfizm, umożliwiając kompilatorowi wprowadzenie w linii lub bezpośrednio połączyć się z poprawną metodą, podczas gdy any zmusza do wyszukiwania vtable w czasie wykonywania tylko dla wymagań i domyślnie odnosi się do rozszerzenia dla wszystkiego innego.

Jaki jest wpływ na rozmiar binarny i wydajność przekształcenia metody rozszerzenia w wymóg protokołu?

Przekształcenie metody rozszerzenia w wymóg protokołu dodaje wpis do tabeli świadków protokołu, zwiększając rozmiar binarny o około 8 bajtów na zgodność w architekturach 64-bitowych. Każdy zgodny typ musi teraz wypełnić ten slot w swojej tabeli świadków, co zwiększa niewielki narzut pamięci na typ. Jeśli chodzi o wydajność, wymagania ponoszą narzut pośredniego wywołania przez tabelę świadków (jedno dodatkowe dereferencjonowanie wskaźnika i skok), podczas gdy metody rozszerzeń mogą być wprowadzone w linii lub wywoływane bezpośrednio z zerowym narzutem. Jednak utrata wprowadzania w linię dla wymagań jest często rekompensowana przez predyktor gałęzi CPU, a korzyści z poprawnego zachowania polimorficznego zazwyczaj przeważają nad kosztem wywołania pośredniego na poziomie nanosekund w większości kodu aplikacji.