Réponse à la question.
Objective-C s'appuyait sur des cycles de rétention/libération manuels et des pointeurs directs pour les références faibles, ce qui nécessitait un échange de méthodes au runtime ou des tables de hachage globales entraînant des pénalités de performance significatives à chaque accès aux objets. Lorsque Apple a conçu Swift, ils ont requis un modèle de gestion automatique de la mémoire qui supportait les références faibles zéroïques—devenant automatiquement nil lorsque l'objet référencé était désalloué—sans alourdir la grande majorité des objets qui n'ont jamais rencontré de références faibles. Cette nécessité a conduit au développement d'une architecture de table latérale qui externalise les métadonnées de référence faible uniquement lorsque cela est nécessaire.
Le problème central consistait à équilibrer l'efficacité mémoire et la sécurité. Si chaque en-tête d'objet contenait un stockage interne pour le suivi des références faibles (tel qu'une liste chaînée de pointeurs faibles ou un compte faible en ligne), l'empreinte mémoire de chaque instance de classe augmenterait considérablement, pénalisant le code critique en performance qui n'utilise que des références fortes. Inversement, stocker des références faibles dans une table de hachage globale indexée par l'adresse de l'objet introduit des goulets d'étranglement de synchronisation et une logique de récupération complexe lorsque des objets sont désalloués. Le défi résidait dans la création d'un mécanisme qui imposait un coût nul aux objets sans références faibles tout en garantissant un zéro atomique sûr en cas de disparition de la dernière référence forte.
Swift utilise un système de table latérale où chaque en-tête d'instance de classe contient un pointeur nullable vers une structure de table latérale allouée sur le tas. Cette table latérale stocke le compte de références faibles et un pointeur rétro vers l'objet ; les références faibles pointent en fait vers cette table latérale plutôt que directement vers l'objet. Lorsque le compte de références fortes atteint zéro, le runtime annule de manière atomique le pointeur d'objet à l'intérieur de la table latérale, entraînant toutes les références faibles existantes à observer nil lors du prochain accès, tandis que la mémoire de l'objet reste allouée jusqu'à ce que le compte de références faibles atteigne également zéro, moment auquel la mémoire de l'objet et la table latérale sont récupérées.
Situation de la vie
Imaginez développer un pipeline d'image haute résolution pour une application de médias sociaux où des instances de ViewController téléchargent et affichent des avatars d'utilisateurs. Pour éviter des requêtes réseau redondantes, vous mettez en œuvre un singleton ImageCache qui stocke les références aux objets UIImage téléchargés afin que plusieurs contrôleurs de vue affichant le même avatar puissent partager le tampon mémoire sous-jacent.
Une approche envisagée était de stocker des références fortes dans un NSCache avec des politiques d'éviction arbitraires. Cela garantissait un accès immédiat et une sécurité de type, mais causait de graves fuites de mémoire parce que le cache retenait chaque image indéfiniment, déclenchant finalement des alertes mémoire et la terminaison de l'application lors de sessions de défilement prolongées. Les avantages incluaient la simplicité et un accès rapide, mais les inconvénients de la croissance mémoire illimitée le rendaient inadapté à la production.
Une autre approche envisagée impliquait la mise en œuvre d'un modèle d'observateur manuel où les contrôleurs de vue notifiaient le cache lors de la désallocation pour supprimer des entrées spécifiques à l'aide d'un protocole de délégué. Bien que cela empêchait les fuites en théorie, cela introduisait un couplage étroit fragile entre la couche de vue et la couche de mise en cache, nécessitait un code standard extensif pour gérer des conditions de course lors de transitions de navigation rapides, et risquait des plantages si les messages de notification étaient manqués ou livrés en retard.
La solution choisie a utilisé les références faibles natives de Swift dans la mise en œuvre du cache :
class ImageCache { private var cache: [URL: WeakBox<UIImage>] = [:] func image(for url: URL) -> UIImage? { return cache[url]?.value } func setImage(_ image: UIImage, for url: URL) { cache[url] = WeakBox(value: image) } } final class WeakBox<T: AnyObject> { weak var value: T? init(value: T) { self.value = value } }
En déclarant les valeurs du dictionnaire de cache comme faibles via l'enveloppe WeakBox, le ImageCache pouvait vérifier si une image existait encore en mémoire avant de la retourner, tout en permettant une récupération automatique lorsque aucun contrôleur de vue n'affichait activement cet avatar. Cela a éliminé à la fois les fuites de mémoire et les surcharges de tenue de livres manuelles, entraînant une réduction de 40 % de l'utilisation de la mémoire minimale lors du défilement rapide des flux et empêchant la terminaison par le réveil mémoire du système.
Ce que les candidats oublient souvent
Pourquoi l'accès à une référence faible peut-il être plus lent que l'accès à une référence forte, et dans quelle condition spécifique cette différence de performance devient-elle mesurable?
Accéder à une référence faible nécessite de désenclencher le pointeur de la table latérale stocké dans l'en-tête de l'objet, puis d'effectuer un chargement atomique du pointeur d'objet à partir de cette table latérale pour vérifier s'il a été annulé. Bien que la surcharge soit minimale (typiquement une seule indirection supplémentaire), elle devient mesurable lors de l'itération sur de grandes collections (des milliers d'éléments) où chaque élément est accédé via une référence faible dans des boucles étroites, tandis que les références fortes nécessitent uniquement une chasse de pointeur unique sans garanties atomiques.
Qu'est-ce qui distingue une référence non possédée d'une référence faible au niveau de l'implémentation, et pourquoi tenter d'accéder à une référence non possédée après la désallocation de l'objet déclenche-t-il un crash d'exécution au lieu de produire nil?
Contrairement aux références faibles qui utilisent des tables latérales pour permettre le zéroïng, les références non possédées (dans le mode sûr par défaut) référencent également la table latérale mais supposent que l'objet restera alloué tant que la référence non possédée existe, provoquant un plantage si l'objet est désalloué car l'entrée de la table latérale est marquée comme détruite mais pas annulée. Les candidats oublient souvent que les références non possédées non sécurisées contournent totalement la table latérale, se comportant comme des pointeurs C pendus qui corrompent la mémoire lorsqu'elles sont accessibles après désallocation, tandis que les références non possédées sécurisées piègent au moins de manière déterministe via le bit désalloué de la table latérale.
Pourquoi la mémoire d'une instance d'objet reste-t-elle allouée dans le tas même après que son déinit soit terminé et que toutes les références fortes soient disparues, et quand cette mémoire est-elle réellement libérée?
La mémoire persiste parce que la table latérale maintient un compte de références faibles ; l'en-tête de l'objet et son stockage associé ne peuvent pas être récupérés tant que le compte faible n'atteint pas zéro, garantissant que les références faibles ne pointent jamais vers de la mémoire recyclée. Ce n'est qu'après que la dernière référence faible est détruite (décroissant le compte faible à zéro) que le runtime désalloue à la fois la table latérale et la région de mémoire de l'objet, un processus invisible pour les développeurs mais crucial pour prévenir les vulnérabilités d'utilisation après libération.