SwiftProgrammationDéveloppeur Swift

Quel contrat spécifique à double usage l'attribut @frozen de Swift établit-il concernant la stabilité de la disposition des enums et l'exhaustivité des switchs à travers les frontières de modules résilients ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Introduit avec Swift 5.0, en même temps que le support de l'évolution des bibliothèques, l'attribut @frozen a été conçu pour résoudre la tension entre l'extensibilité de l'API et la stabilité binaire. Avant ce mécanisme, tous les enums publics dans les bibliothèques résilientes étaient implicitement non gelés, forçant le compilateur à supposer que de futures versions pourraient ajouter des cas inconnus. Cette hypothèse empêchait la génération de dispositions compactes et de taille fixe et nécessitait des modèles de programmation défensive dans le code client. L'attribut fournit une garantie formelle que l'inventaire des cas de l'enum est immuable pour toujours, permettant des optimisations agressives.

Le problème se pose lorsqu'une bibliothèque publie un enum sans cet attribut. Swift doit alors traiter l'enum comme résilient, réservant de l'espace variable dans sa représentation mémoire pour accueillir de futurs discriminateurs de cas et dispositions de valeurs associées. Cela force les switchs clients à inclure un cas @unknown default, désactivant effectivement la vérification à la compilation que tous les états logiques sont traités. Sans un tel défaut, l'ajout d'un cas à la bibliothèque provoquerait un comportement indéfini dans les binaires clients précompilés qui n'ont pas le code pour traiter la nouvelle valeur de discriminateur, entraînant des pannes ou une corruption de la mémoire.

La solution réside dans l'annotation @frozen établissant un contrat permanent. En marquant un enum comme gelé, l'auteur de la bibliothèque promet que l'ensemble des cas ne changera jamais, permettant au compilateur d'attribuer des étiquettes entières fixes et d'utiliser une disposition mémoire stable et compacte. Cela permet des déclarations switch exhaustives sans cas par défaut, car le compilateur peut prouver que tous les modèles binaires possibles du discriminateur correspondent à des cas connus. La stabilité de l'ABI qui en résulte garantit que la taille et l'alignement de l'enum restent constants à travers les versions de la bibliothèque, tandis que le code client bénéficie d'optimisations de table de saut et d'une gestion obligatoire de chaque état.

// Dans une bibliothèque compilée avec -enable-library-evolution @frozen public enum LoadState { case idle case loading case loaded(Data) } // Code client dans un module séparé func updateUI(for state: LoadState) { switch state { case .idle: print("En attente") case .loading: print("Spinner") case .loaded: print("Contenu") // Le compilateur vérifie l'exhaustivité ; aucun défaut requis } }

Situation vécue

L'équipe de la plateforme d'une entreprise de logistique expédiait un package Swift pour l'optimisation des itinéraires qui exposait un enum TransportMode avec des cas pour .truck, .air, et .ship. Parce qu'ils anticipaient l'ajout de .drone et .rail dans des versions ultérieures, ils ont initialement distribué la bibliothèque sans l'attribut @frozen. Les équipes clientes ont rapidement signalé que Xcode refusait de compiler les switchs sans clauses @unknown default, dissimulant les erreurs de logique où ils avaient oublié de traiter .ship dans les calculs de coûts de fret.

L'équipe a considéré trois approches architecturales pour résoudre ce problème.

Tout d'abord, ils pouvaient maintenir le statut non gelé et investir dans un linting intensif pour s'assurer que les clients écrivaient des gestionnaires @unknown default qui journalisaient des avertissements. Cela préservait la flexibilité d'ajouter des modes de transport sans publications de nouvelles versions majeures, mais désactivait de manière permanente la vérification de l'exhaustivité à la compilation. Cela ne résolvait pas non plus le surcoût de taille binaire, car chaque instance d'enum portait des métadonnées de résilience qui alourdissaient les paquets d'itinéraires sérialisés envoyés aux appareils des chauffeurs.

Deuxièmement, ils pouvaient remplacer l'enum par une structure RawRepresentable soutenue par des constantes entières. Cela fournirait une disposition mémoire fixe et permettrait l'ajout de nouveaux modes sans casser la compatibilité binaire, mais sacrifierait entièrement les capacités de correspondance de modèle de Swift. Les développeurs seraient contraints d'utiliser de longues chaînes if-else, et le compilateur ne pourrait plus vérifier que tous les états de transport possibles étaient traités dans des algorithmes critiques de recherche de chemin.

Troisièmement, ils pouvaient appliquer @frozen à l'enum et s'engager sur les trois cas existants, créant un wrapper ExtendedTransportMode pour les expansions futures. Cela éliminerait le surcoût de résilience, permettrait la compilation de switchs exhaustifs et garantirait que chaque client traitait tous les modes actuels explicitement. Le compromis était une restriction permanente sur la modification de l'enum original et la nécessité de versionner pour toute addition fondamentale.

Ils ont choisi la troisième solution. Après avoir gelé TransportMode, ils ont immédiatement découvert deux cas de switch non traités dans leur propre tableau de bord analytique lors de la compilation. La suppression des métadonnées de résilience a réduit la taille des objets d'itinéraire transmis de 18 %, et la séparation architecturale explicite a forcé une séparation plus propre entre la logique de transport de base et les modes expérimentaux.

Ce que les candidats oublient souvent

Pourquoi l'ajout d'un cas à un enum public non gelé casse-t-il la compatibilité binaire même si le code source client se compile toujours avec succès ?

Lorsque Swift compile un module résilient, les enums non gelés utilisent une représentation à largeur variable qui réserve un espace pour de futurs discriminateurs de cas. Si la bibliothèque ajoute ensuite un cas, la disposition à l'exécution de l'enum change — par exemple, l'entier discriminateur peut passer de 8 bits à 16 bits pour accommoder la nouvelle étiquette. Les binaires clients précompilés s'attendent à l'ancienne disposition et contiennent des tables de saut ou des branches conditionnelles qui ne tiennent compte que de la plage d'étiquettes d'origine. Lorsque ces binaires rencontrent la nouvelle valeur de discriminateur, ils peuvent exécuter des chemins de code invalides ou lire de la mémoire au-delà de la limite de charge attendue, provoquant des pannes que les clauses @unknown default au niveau source ne peuvent pas empêcher.

Comment @frozen interagit-il avec les enums qui contiennent des cas indirects ou des valeurs associées de types résilients ?

@frozen garantit que l'identité et le nombre de cas restent constants, mais il ne fige pas la taille des valeurs associées. Si un cas porte une charge d'une structure non gelée ou d'une référence de classe, la stabilité de l'ABI de l'enum se réfère à l'étiquette discriminateur fixe, tandis que le stockage de la charge peut encore utiliser un dimensionnement dynamique par le biais de pointeurs ou de tables de témoins de valeur. Les candidats assument souvent à tort que @frozen fixe l'ensemble de l'empreinte mémoire y compris les tailles de charge ; en réalité, l'optimisation s'applique principalement à l'étiquette, et les valeurs associées peuvent encore nécessiter des calculs de disposition à l'exécution si leurs types sont eux-mêmes résilients ou contiennent des tailles inconnues.

Un enum gelé peut-il être déclaré dans un module non résilient, et quelles sont les implications à long terme de cela ?

Oui, @frozen peut être appliqué aux enums dans des cibles d'application ordinaires où l'évolution de la bibliothèque est désactivée. Dans ce contexte, l'attribut fonctionne comme un document d'intention, car tous les enums au sein du module sont effectivement gelés en raison de l'absence de frontières de résilience. Cependant, les candidats négligent souvent que @frozen constitue un contrat ABI permanent ; si le module est ultérieurement extrait dans un cadre de bibliothèque résiliente, l'enum ne peut pas être dégélé ou étendu sans casser la compatibilité binaire avec les clients existants. Marquer explicitement les enums comme gelés lors du développement initial protège le code contre les violations accidentelles de l'ABI lorsque l'architecture du projet évolue.