SwiftprogramowanieProgramista iOS

Co uniemożliwia poprawne przechodzenie hierarchii klas polimorficznych przez serializację JSON w wyniku konformacji Codable generowanej przez kompilator Swift?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Generowana Codable implementacja polega wyłącznie na statycznych informacjach o typie dostępnych w czasie kompilacji. Podczas kodowania mieszanej kolekcji instancji klas przez odniesienie do klasy bazowej, kompilator generuje kod encode(to:), który serializuje tylko właściwości widoczne dla typu klasy bazowej. W rezultacie właściwości specyficzne dla podklasy są pomijane w wyjściu JSON, a podczas dekodowania brakuje niezbędnych metadanych do zainicjowania poprawnej podklasy, co skutkuje przejściem do klasy bazowej i utratą danych specyficznych dla typu.

Sytuacja z życia

Tworzyliśmy panel analityczny dla finansów, który przetwarzał różnorodne typy transakcji dla zarządzania portfelem. Model domenowy wykorzystywał hierarchię klas, w której Transaction była klasą bazową, a podklasy takie jak StockTrade, DividendPayment i FeeCharge dodawały specyficzne właściwości, takie jak tickerSymbol czy dividendRate. Backend API zwracał mieszane tablice JSON tych transakcji, z których każda zawierała pole dyskryminacyjne transactionType.

Początkowo polegaliśmy na automatycznej syntezie Codable w Swift, zakładając, że obsłuży tablicę polimorficzną [Transaction]. Jednak podczas testów integracyjnych odkryliśmy, że kodowanie tablicy [StockTrade] rzutowanej na [Transaction] skutkowało JSON zawierającym tylko pola klasy bazowej, takie jak id i amount, całkowicie pomijając tickerSymbol. Natomiast dekodowanie tego JSON odtwarzało tylko instancje klasy bazowej Transaction, co powodowało awarię aplikacji podczas próby dostępu do właściwości specyficznych dla podklasy, które miały istnieć.

Rozważyliśmy trzy różne podejścia do rozwiązania tego ograniczenia. Pierwsze przeznaczone było na ręczną implementację Codable, w której jawnie dodaliśmy pole transactionType do kontenera kodującego i zaimplementowaliśmy niestandardowy init(from:), który przełączał się na podstawie tego dyskryminatora, aby zainicjować poprawną podklasę. To podejście oferowało pełne bezpieczeństwo typów i zachowało istniejącą strukturę obiektów, ale wymagało pisania i utrzymania znacznej ilości kodu wzorcowego dla każdego nowego typu transakcji, co zwiększało ryzyko błędów deweloperów przy dodawaniu funkcji.

Drugie rozwiązanie polegało na użyciu opakowania ze zduplikowanym typem AnyCodable lub podejścia zorientowanego na protokół z typami egzystencjonalnymi (any TransactionProtocol). Chociaż umożliwiło to przechowywanie heterogenicznych typów w tablicy bez dziedziczenia, poświęcało bezpieczeństwo typów w czasie kompilacji i wprowadzało przeciążenie w czasie działania z powodu opakowywania egzystencjonalnego i dynamicznego dispatchingu. Komplikowało to również umowy API, zmuszając konsumentów do obsługi artefaktów związanych z usuwaniem typu i rzutowaniem, co zmniejszało czytelność kodu.

Trzecią opcją było przekształcenie hierarchii klas w jedną wyliczenie z powiązanymi wartościami, taką jak enum Transaction { case stock(StockData), case dividend(DividendData) }. Wyliczenia naturalnie wspierają polimorficzną serializację poprzez generowaną Codable, ponieważ kompilator automatycznie generuje pole dyskryminatora. Niemniej jednak, wymagałoby to ogromnej refaktoryzacji istniejącego modelu Core Data i logiki biznesowej w całej aplikacji, niosąc nieakceptowalne ryzyko regresji dla systemu produkcyjnego.

Wybraliśmy pierwsze rozwiązanie — ręczną implementację Codable z polem dyskryminatora — ponieważ zlokalizowało to zmiany w warstwie serializacji, nie zakłócając istniejącej architektury ani schematu bazy danych. Zaimplementowaliśmy metodę fabryczną w klasie bazowej, która najpierw dekodowała identyfikator typu, a następnie delegowała do odpowiedniego inicjalizatora podklasy w oparciu o wartość ciągu.

Wynikiem była solidna infrastruktura serializacji, która poprawnie obsługiwała odpowiedzi API oparte na polimorfizmie z pełną wiernością typów. Choć wymagało to około 200 linii ręcznie napisanej logiki analizy, utrzymywało to kompatybilność ze istniejącymi funkcjami i dostarczało jasnych błędów kompilacji, gdy deweloperzy dodawali nowe typy transakcji, lecz zapominali zaktualizować logikę dekodowania, zapobiegając awariom w czasie działania.

Co kandydaci często przeoczają

Dlaczego rzutowanie [Subclass] na [BaseClass] przed kodowaniem za pomocą JSONEncoder powoduje utratę danych dla właściwości specyficznych dla podklasy?

Generowana metoda encode(to:) jest wywoływana statycznie w oparciu o typ w czasie kompilacji wartości w kolekcji. Kiedy rzucasz ją na [BaseClass], kompilator wybiera generowaną implementację BaseClass, która iteruje tylko po właściwościach zadeklarowanych w BaseClass. Właściwości podklasy są niewidoczne dla tej implementacji, ponieważ mechanizm statycznego dispatchu nie konsultuje metadanych dynamicznego typu dla generowanych metod. Aby zachować wszystkie właściwości, musisz kodować, używając konkretnego typu lub samodzielnie zaimplementować dynamiczne rozwiązywanie typów przez pole dyskryminatora.

Jak wymóg inicjalizatora o charakterze wymaganym współdziała z konformacją Decodable w hierarchiach klas i dlaczego uniemożliwia to automatyczne inicjowanie podklasy?

Decodable wymaga inicjalizatora init(from: Decoder). Dla klas musi być on oznaczony jako required w klasie bazowej, aby umożliwić podklasom dziedziczenie konformacji. Jednak generowana implementacja w klasie bazowej nie może dynamicznie określić, którą podklasę zainicjować na podstawie zewnętrznych danych, takich jak pole dyskryminatora. Gdy dekoder napotyka dane reprezentujące podklasę, wywołuje init(from:) klasy bazowej, która zna tylko sposób inicjowania części klasy bazowej. Aby obsłużyć polimorficzne dekodowanie, deweloperzy muszą przesłonić init(from:) w każdej podklasie i zaimplementować metodę fabryczną, która sprawdza kontener dekodera, aby określić konkretny typ przed inicjacją.

Jaka jest zasadnicza różnica między tym, jak generowana przez Swift implementacja Codable obsługuje wyliczenia z powiązanymi wartościami a dziedziczenie klas i dlaczego czyni to wyliczenia odpowiednimi do polimorficznej serializacji?

Swift generuje klucz dyskryminatora podczas syntezowania Codable dla wyliczeń z powiązanymi wartościami. Kodowanie zawiera nazwę przypadku jako klucz ciągu, a implementacja dekodowania przełącza się na ten klucz, aby odtworzyć odpowiedni przypadek i jego powiązane ładunki. Działa to, ponieważ wyliczenia tworzą zamkniętą, skarżoną hierarchię typów znaną wyczerpująco w czasie kompilacji, co pozwala kompilatorowi generować pełne instrukcje przełączania. W przeciwieństwie do tego, klasy tworzą otwartą hierarchię, w której nowe podklasy mogą być dodawane w różnych modułach. Kompilator nie może wygenerować wyczerpującego przełączania dla wszystkich możliwych podklas podczas syntezowania konformacji Codable dla klasy bazowej, co sprawia, że automatyczna obsługa polimorfizmu jest niemożliwa bez ręcznego uchwytu.