SwiftProgrammationDéveloppeur iOS

Qu'est-ce qui empêche la conformité Codable synthétisée par le compilateur de Swift de correctement gérer les hiérarchies de classes polymorphiques via la sérialisation JSON ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

L'implémentation synthétisée de Codable repose exclusivement sur les informations de type statiques disponibles au moment de la compilation. Lors de la sérialisation d'une collection hétérogène d'instances de classes par le biais d'une référence de classe de base, le compilateur génère un code encode(to:) qui ne sérialise que les propriétés visibles pour le type de classe de base. En conséquence, les propriétés spécifiques aux sous-classes sont omises de la sortie JSON, et lors de la désérialisation, le runtime manque des métadonnées nécessaires pour instancier la bonne sous-classe, se contentant de la classe de base et perdant des données spécifiques au type.

Situation de la vie réelle

Nous construisions un tableau de bord d'analytique financière qui traitait divers types de transactions pour la gestion de portefeuille. Le modèle de domaine utilisait une hiérarchie de classes où Transaction était la classe de base, avec des sous-classes comme StockTrade, DividendPayment, et FeeCharge ajoutant des propriétés spécifiques telles que tickerSymbol ou dividendRate. L'API backend renvoyait un tableau JSON mixte de ces transactions, chacune contenant un champ discriminateur transactionType.

Nous nous étions initialement appuyés sur la synthèse automatique Codable de Swift, supposant qu'elle gérerait le tableau polymorphique [Transaction]. Cependant, lors des tests d'intégration, nous avons découvert que l'encodage d'un tableau [StockTrade] converti en [Transaction] entraînait un JSON contenant uniquement des champs de classe de base comme id et amount, omettant complètement tickerSymbol. Inversement, la désérialisation de ce JSON recréait uniquement des instances de base Transaction, causant un plantage de l'application lorsqu'elle tentait d'accéder à des propriétés spécifiques à la sous-classe qui étaient censées exister.

Nous avons envisagé trois approches distinctes pour résoudre cette limitation. La première consistait en une implémentation manuelle de Codable où nous ajoutions explicitement le champ transactionType au conteneur d'encodage et mettions en œuvre un init(from:) personnalisé qui se basait sur ce discriminateur pour instancier la bonne sous-classe. Cette approche offrait une sécurité de type complète et préservait le graphique d'objets existant, mais nécessitait d’écrire et de maintenir un code de boilerplate considérable pour chaque nouveau type de transaction, augmentant le risque d'erreur de développeur lors de l'ajout de fonctionnalités.

La seconde solution explorait l'utilisation d'un wrapper de type effacé AnyCodable ou d'une approche orientée protocole avec des types existentiels (any TransactionProtocol). Bien que cela ait permis de stocker des types hétérogènes dans un tableau sans héritage, cela a sacrifié la sécurité de type à la compilation et introduit un surcoût d'exécution dû à l'encapsulation existentielle et au dispatch dynamique. Cela a également compliqué le contrat API en obligeant les consommateurs à gérer les artefacts de l'effacement de type et le casting, réduisant la clarté du code.

La troisième option consistait à refactoriser la hiérarchie de classes en une seule énumération avec des valeurs associées, telle que enum Transaction { case stock(StockData), case dividend(DividendData) }. Les énumérations prennent naturellement en charge la sérialisation polymorphique via la synthèse Codable, car le compilateur génère automatiquement un champ discriminateur. Cependant, cela aurait nécessité un refactor massif du modèle Core Data existant et de la logique métier dans toute l'application, entraînant un risque de régression inacceptable pour un système de production.

Nous avons choisi la première solution : l'implémentation manuelle de Codable avec un champ discriminateur, car elle localisait les changements au niveau de la sérialisation sans perturber l'architecture existante ou le schéma de base de données. Nous avons mis en place une méthode de fabrique dans la classe de base qui décodait d'abord l'identifiant de type, puis déléguait à l'initialiseur de sous-classe approprié en fonction de la valeur de chaîne.

Le résultat a été un pipeline de sérialisation robuste qui gérait correctement les réponses API polymorphiques avec une fidélité de type complète. Bien que cela ait nécessité environ 200 lignes de code de parsing manuel, cela maintenait la compatibilité avec les fonctionnalités existantes et fournissait des erreurs claires à la compilation lorsque les développeurs ajoutaient de nouveaux types de transaction mais oubliaient de mettre à jour la logique de décodage, prévenant les échecs d'exécution.

Ce que les candidats oublient souvent

Pourquoi le fait de caster un [Subclass] en [BaseClass] avant l'encodage avec JSONEncoder provoque-t-il une perte de données pour les propriétés spécifiques à la sous-classe ?

La méthode synthétisée encode(to:) est dispatchée statiquement en fonction du type à la compilation de la valeur dans la collection. Lorsque vous castiez en [BaseClass], le compilateur sélectionne l'implémentation synthétisée de BaseClass, qui n'itère que sur les propriétés déclarées dans BaseClass. Les propriétés des sous-classes sont invisibles pour cette implémentation car le mécanisme de dispatch statique ne consulte pas les métadonnées du type dynamique pour les méthodes synthétisées. Pour préserver toutes les propriétés, vous devez soit encoder en utilisant le type concret, soit mettre en œuvre la résolution de type dynamique manuellement via un champ discriminateur.

Comment l'exigence d'un initialiseur requis interagit-elle avec la conformité Decodable dans les hierarchies de classes, et pourquoi cela empêche-t-il l'instanciation automatique de sous-classes ?

Decodable nécessite un initialiseur init(from: Decoder). Pour les classes, cela doit être marqué required dans la classe de base pour permettre aux sous-classes d'hériter de la conformité. Cependant, l'implémentation synthétisée dans la classe de base ne peut pas déterminer dynamiquement quelle sous-classe instancier en fonction de données externes comme un champ discriminateur. Lorsque le décodeur rencontre des données représentant une sous-classe, il appelle le init(from:) de la classe de base, qui ne sait comment initialiser que la portion de classe de base. Pour prendre en charge le décodage polymorphique, les développeurs doivent remplacer init(from:) dans chaque sous-classe et mettre en œuvre une méthode de fabrique qui inspecte le conteneur du décodeur pour déterminer le type concret avant l'instanciation.

Quelle est la différence fondamentale entre la façon dont le Codable synthétisé de Swift gère les énumérations avec des valeurs associées par rapport à l'héritage de classe, et pourquoi cela rend-il les énumérations adaptées à la sérialisation polymorphique ?

Swift génère une clé discriminateur lors de la synthèse de Codable pour les énumérations avec des valeurs associées. L'encodage inclut le nom du cas en tant que clé de chaîne, et l'implémentation de décodage se base sur cette clé pour reconstruire le cas correct et sa charge associée. Cela fonctionne parce que les énumérations forment une hiérarchie de types fermée et scellée connue de manière exhaustive à la compilation, permettant au compilateur de générer une déclaration switch complète. En revanche, les classes forment une hiérarchie ouverte où de nouvelles sous-classes peuvent être ajoutées dans différents modules. Le compilateur ne peut pas générer un switch exhaustif pour toutes les sous-classes possibles lors de la synthèse de la conformité Codable de la classe de base, rendant impossible la gestion automatique de la polymorphie sans intervention manuelle.