Swift maintient la stabilité de l'ABI pour les structures résilientes en stockant les décalages de champ dans les métadonnées d'exécution plutôt qu'en les codant en tant que valeurs de déplacement immédiates dans les binaires des clients. Lorsqu'un module exporte une structure non gelée, le compilateur génère du code qui accède aux propriétés stockées via une Table des décalages des champs intégrée aux métadonnées du type. Cette indirection permet aux auteurs de bibliothèques d'ajouter de nouvelles propriétés stockées dans les futures versions sans invalider les binaires existants compilés contre les anciennes structures. En revanche, les structures @frozen utilisent des calculs de décalage directs, ce qui entraîne un accès mémoire plus rapide mais fige définitivement la disposition. Le compromis est un léger coût de performance dû à la charge mémoire supplémentaire de la table des décalages par rapport à l'adressage immédiat.
Imaginez architecturer un SDK d'Analytics distribué en tant que framework dynamique à des centaines d'applications clientes. Le SDK définit une structure Config avec deux champs initialement : apiKey et environment. Six mois après sa sortie, les exigences du produit nécessitent l'ajout des champs retryPolicy et timeoutInterval à cette structure.
// Dans AnalyticsSDK (Module A) - Compilé initialement public struct Config { public let apiKey: String public let environment: String // Nouveaux champs ajoutés dans v2.0 sans @frozen : // public let retryPolicy: RetryPolicy }
Si la structure était @frozen, ce changement ferait planter les applications clientes existantes car elles avaient codé en dur la taille de la structure et les décalages des champs lors de la compilation. Nous avons envisagé trois approches pour résoudre ce problème d'évolution. La première approche consistait à convertir la structure en classe, profitant de l'allocation sur le tas et de la stabilité des pointeurs ; cela préservait la compatibilité ABI mais introduisait un surcoût de comptage de références indésirable et des sémantiques de référence qui brisaient les garanties d'immuabilité des types valeur. La deuxième approche suggérait d'expédier une structure parallèle ConfigV2 tout en dépréciant l'original ; cela maintenait la compatibilité mais fracturait la surface API et forçait les développeurs à migrer explicitement. La troisième approche adoptait des structures résilientes en supprimant l'attribut @frozen, permettant au compilateur d'émettre un accès indirect aux champs à travers des recherches de métadonnées.
Nous avons choisi la troisième solution car elle équilibrerait performance et flexibilité future. Les binaires clients continuaient à fonctionner sans recompilation car ils interrogeaient dynamiquement les décalages de champs à partir des métadonnées du SDK à l'exécution. Le résultat était une évolution sans heurt de la structure de configuration à travers les versions du SDK, bien que nous ayons documenté que les champs de configuration fréquemment accédés devraient être mis en cache localement pour atténuer le coût d'indirection supplémentaire.
Comment Swift détermine-t-il la taille et l'alignement d'une structure résiliente lors de la compilation du code client qui importe le module définissant ?
Lors de la compilation contre une structure résiliente, Swift ne peut pas connaître la taille ou l'alignement concrets de manière statique car de nouveaux champs pourraient être ajoutés plus tard. Au lieu de cela, le compilateur génère du code qui consulte la Table des témoins de valeur (VWT) associée aux métadonnées du type à l'exécution. La VWT fournit des fonctions pour la taille, l'alignement, le pas et la destruction, permettant au client d'allouer la bonne quantité d'espace de pile ou de mémoire sur le tas sans connaissance préalable de la disposition de la structure.
Pourquoi le passage d'un énumérateur résilient nécessite-t-il une clause par défaut @unknown, et que se passe-t-il en coulisse lorsqu'un nouveau cas est ajouté ?
Les énumérateurs résilients n'exposent pas leur liste complète de cas aux modules d'importation, empêchant le passage exhaustif sans clause par défaut. Lorsque l'auteur de la bibliothèque ajoute un nouveau cas, les métadonnées de l'énumérateur se mettent à jour pour inclure la nouvelle valeur d'étiquette. Le code client compilé avec @unknown default peut gérer cette étiquette inconnue à l'exécution en tombant dans la branche par défaut, tandis que les énumérateurs gelés déclencheraient une exception sur les étiquettes non reconnues car l'instruction switch a été compilée comme une table de sauts sans retour.
Quelle optimisation spécifique l'attribut @inlinable fournit-il à travers les frontières des modules, et pourquoi cela brise-t-il la résilience ?
@inlinable expose le corps d'une fonction ou d'une méthode au compilateur du module d'importation, permettant l'inlining inter-modules et l'élimination du code mort. Cela brise la résilience car le compilateur client intègre les détails d'implémentation directement dans le binaire client. Si l'auteur de la bibliothèque modifie ultérieurement l'implémentation, le client continue d'utiliser l'ancien code en ligne, ce qui peut entraîner des divergences comportementales subtiles ou des plantages si les structures de données internes ont changé.