Cette décision de conception découle de l'engagement fondamental de Swift envers les sémantiques de valeur pour les collections de la bibliothèque standard. Contrairement à NSMutableDictionary de Objective-C ou à std::unordered_map de C++, qui exposent des sémantiques de référence ou permettent des pointeurs externes vers des nœuds internes, Swift traite Dictionary et Set comme des types de valeur purs. Lorsque Swift a adopté des optimisations Copy-on-Write (COW) pour ces collections afin d'obtenir des performances de type référence tout en maintenant la sécurité de type valeur, l'équipe d'ingénierie a été confrontée à une décision critique concernant la stabilité des indices. La résolution d'invalider les indices lors des mutations a été formalisée pour éviter les références pendantes vers des espaces de stockage réalloués lors de la croissance de la table de hachage, de la résolution de collision ou de la suppression d'entrées.
Le problème central émerge de l'interaction entre les sémantiques COW et les détails d'implémentation de la table de hachage. Lorsqu'un Dictionary se muta par insertion ou suppression, cela peut déclencher un redimensionnement si le facteur de charge dépasse des seuils, allouant un nouveau tampon plus grand et rehashant toutes les entrées. Toute valeur Index existante créée avant la mutation encapsule un décalage ou un pointeur vers la mémoire physique de l'ancien tampon. Si cet index était accessible après mutation, il désreferencerait une mémoire désallouée (use-after-free) ou retournerait des données provenant de seaux incorrects. Parce que Swift ne peut pas suivre la durée de vie de chaque valeur Index à travers des copies indépendantes du Dictionary (les sémantiques de valeur permettent des copies non restreintes), il ne peut pas mettre à jour en toute sécurité tous les indices en attente. Par conséquent, le langage doit déclarer ces indices invalides pour respecter les garanties de sécurité mémoire.
Swift résout cela en intégrant un compte de génération ou un numéro de version dans l'en-tête de stockage interne du Dictionary. Chaque Index capture cet identifiant de génération au moment de sa création. Lorsque le Dictionary muta, le runtime incrémente ce compte de génération et réalloue potentiellement le tampon sous-jacent. Toute utilisation ultérieure d'un Index obsolète compare sa génération enregistrée à la génération actuelle ; un désaccord déclenche une erreur d'exécution déterministe (échec de précondition). Cette approche sacrifie la stabilité des indices à travers les mutations au profit de la sécurité mémoire et de l'intégrité des sémantiques de valeur. Pour l'optimisation COW, le runtime vérifie les comptes de référence avant mutation : s'il est référencé de manière unique, il mute sur place (invalidant les indices) ; s'il est partagé, il copie d'abord le tampon, laissant les indices de l'instance originale valides tandis que la nouvelle copie reçoit un nouveau compte de génération.
var marketData: [String: Double] = ["AAPL": 150.0, "GOOGL": 2800.0] let indexBeforeUpdate = marketData.index(forKey: "AAPL")! // Génération 0 marketData["TSLA"] = 700.0 // La mutation incrémente la génération, peut réallouer // Erreur d'exécution : tentative d'accès à un index invalide de la génération 0 // let price = marketData[indexBeforeUpdate]
Une équipe de développement construisait un tableau de bord de trading haute fréquence utilisant Swift sur iPad, utilisant un Dictionary pour mettre en cache des données de prix en direct avec des symboles ticker String comme clés. Pour optimiser les performances de rendu UI lors de mises à jour rapides, ils stockaient des indices directs de Dictionary dans leurs modèles de vue pour éviter des calculs de hachage répétés lors de la configuration des cellules de la vue de table. Cependant, lorsque des threads WebSocket en arrière-plan inséraient de nouveaux points de prix dans le dictionnaire, l'application présentait des plantages sporadiques avec EXC_BAD_ACCESS ou affichait des données corrompues provenant de régions de mémoire désallouées, car les indices mis en cache référençaient des seaux de tables de hachage qui avaient été réalloués lors des opérations de redimensionnement.
La première solution envisagée a consisté à migrer vers NSMutableDictionary de Foundation, qui fournit des sémantiques de référence et des références d'objet stables plutôt que des sémantiques de valeur. Cette approche aurait permis à l'équipe de maintenir des références persistantes aux entrées, indépendamment des mutations du dictionnaire, préservant la stabilité des indices à travers le cycle de vie de l'application. Cependant, cela introduisait des sémantiques de référence qui brisaient l'isolement de type valeur entre les modèles de vue, entraînant un partage de données non voulu et des conditions de course lors de la copie de dictionnaires entre files d'attente en arrière-plan et le fil principal. De plus, NSMutableDictionary manque de la sécurité de type générique de Swift et nécessite un overhead de pont coûteux pour les types de valeur comme les instances de struct, forçant des opérations de boxing qui dégradaient les performances.
La deuxième solution explorée a consisté à mettre en œuvre une table de hachage personnalisée à adresse ouverte utilisant UnsafeMutablePointer pour gérer manuellement des adresses de nœuds mémoire stables, contournant ainsi complètement le mécanisme d'invalidation d'index de Swift. Cela aurait fourni une stabilité des pointeurs déterministe pour les indices stockés, permettant un accès O(1) sans overhead de rehashing lors des recherches. Cependant, cette approche nécessitait une gestion manuelle de la mémoire avec malloc et free, introduisant des risques significatifs de fuites de mémoire si les nœuds n'étaient pas correctement désalloués lors de la suppression. Cela contournait également les optimisations COW de Swift, ce qui signifiait que chaque copie du dictionnaire nécessiterait une copie profonde complète du tampon alloué sur le tas, détruisant les performances pour des ensembles de données dépassant dix mille entrées.
L'équipe a finalement choisi la troisième solution : éliminer complètement le caching des indices et à la place stocker des tableaux de clés (String tickers) dans leurs modèles de vue, effectuant des recherches basées sur les clés lors de chaque cycle de configuration de cellule. Cette approche a été sélectionnée car elle maintenait les sémantiques de valeur et les garanties de sécurité mémoire de Swift tout en fournissant des performances de recherche de cas moyen O(1). Bien que cela entraînât le coût de rehashing de la clé à chaque accès, le hachage de chaîne moderne de Swift est fortement optimisé par SipHash, et les garanties de sécurité l'emportaient sur le négligeable pénalité de performance au niveau des microsecondes. Ils ont également adopté le type OrderedDictionary du package open source Swift Collections pour fournir un ordonnancement déterministe sans s'appuyer sur des indices instables.
Le résultat fut une élimination complète des plantages EXC_BAD_ACCESS durant les trois mois de suivi ultérieur. L'empreinte mémoire de l'application est restée stable même avec 50 000 entrées de prix concurrentes, et le code est devenu nettement plus maintenable sans la complexité des opérations UnsafeMutablePointer. L'équipe a établi une ligne directrice architecturale stricte interdisant le stockage d'indices de Dictionary ou de Set à travers toute mutation, documentant ce schéma dans leur wiki interne pour éviter des régressions futures.
Pourquoi le tableau Swift permet-il la réutilisation des indices après certaines mutations alors que le dictionnaire ne le permet pas, malgré le fait que les deux soient des types de valeur avec des sémantiques COW ?
Les indices du Array sont de légers Int représentant des décalages par rapport à une adresse de base dans un stockage contigu. Bien que les mutations de Array qui déclenchent une réallocation (comme l'ajout au-delà de la capacité) invalident techniquement les indices en déplaçant le tampon, les indices de Array ne portent pas de métadonnées de génération pour la validation, ce qui les rend dangereux à mettre en cache mais non explicitement vérifiés. Les indices de Dictionary, cependant, encapsulent un état interne complexe incluant des décalages de seaux dans une table de hachage éparse. Parce que les entrées de la table de hachage se déplacent de manière imprévisible lors du rehashing (déclenché par les seuils de facteur de charge ou la résolution de collision), les décalages entiers perdent leur signification sémantique. Swift pourrait théoriquement mettre en œuvre une indirection d'index logique pour Dictionary, mais cela nécessiterait une chasse de pointeur supplémentaire ralentissant chaque accès. Ainsi, Dictionary et Set valident et invalident agressivement les indices via des comptes de génération, tandis que les indices de Array reposent sur le programmeur pour assurer leur validité, reflétant les différents compromis de performance et de sécurité entre le stockage contigu et haché.
Comment le mécanisme Copy-on-Write détermine-t-il si une mutation de Dictionary nécessite une invalidation d'index sur l'instance actuelle ou la création d'une nouvelle copie avec de nouveaux indices ?
Swift utilise le comptage de référence sur le tampon interne (_NativeDictionary). Avant toute mutation, le runtime invoque isUniquelyReferencedNonObjC pour vérifier le nombre de références du tampon. Si le compte est égal à un (possession unique), la mutation se produit sur place, invalidant uniquement les indices sur cette instance spécifique en incrémentant le compte de génération. Si le compte de références dépasse un (propriété partagée), Swift alloue un nouveau tampon, copie tous les éléments et effectue la mutation sur la nouvelle copie. L'instance originale reste inchangée avec des indices valides, tandis que la nouvelle copie commence avec un nouveau compte de génération (effectivement index zéro). Cette distinction est cruciale pour les sémantiques de valeur : après une affectation de valeur, les deux variables partagent le stockage jusqu'à ce que l'une se muta, déclenchant la copie paresseuse. Le point de mutation est là où se produit la séparation logique, garantissant que l'instance à muter possède une propriété unique avant la modification.
L'invalidation d'index de Dictionary de Swift peut-elle être contournée en utilisant withUnsafeMutablePointer ou Unmanaged pour accéder aux stocks bruts, et quels risques catastrophiques cela introduit-il ?
Techniquement, UnsafeMutablePointer et Unmanaged peuvent fournir un accès direct au stockage sous-jacent d'un Dictionary via withUnsafeMutablePointer vers le stockage interne ou en castant le Dictionary en octets bruts. Cependant, cela constitue un comportement indéfini. La disposition interne du Dictionary est opaque et sujette à changement entre les versions de Swift (résilience). La manipulation directe de pointeurs contourne les vérifications de compte de génération, permettant l'accès à de la mémoire désallouée si une réallocation s'est produite lors d'un redimensionnement. De plus, les tables de hachage maintiennent des invariants complexes concernant les bitmaps d'occupation et les marqueurs de tombstone pour les entrées supprimées. La manipulation manuelle de pointeurs peut corrompre ces invariants, conduisant à des boucles infinies pendant les séquences de sonde, à une corruption silencieuse des données ou à des plantages lors des opérations ultérieures de Dictionary. Le modèle de sécurité de Swift interdit explicitement cela ; le seul mécanisme sûr pour maintenir des références stables consiste à utiliser des clés (qui sont rehashées à chaque accès) ou à copier des valeurs hors de la collection dans un tableau séparé.