Rust utilise l'optimisation des valeurs de niche (également appelée remplissage de niche) pour éliminer le stockage du discriminant pour les énumérations lorsque variant contient un type avec des motifs de bits invalides. Pour Option<&T>, le variant None est représenté par la valeur de pointeur nul — un motif de bits qui est invalide pour &T car les références doivent toujours être non nulles. Cela permet au compilateur de stocker le discriminant implicitement dans le pointeur lui-même, garantissant que Option<&T> occupe exactement un mot machine. Cette optimisation s'applique à tout type possédant des valeurs de niche — motifs de bits invalides tels que 0 pour NonZeroU32, valeurs en dehors de 0 ou 1 pour bool, ou des valeurs sentinelles spécifiques dans des structures #[repr(C)].
Lors du développement d'un arbre syntaxique abstrait (AST) hautes performances pour un compilateur traitant des millions de nœuds, nous avons rencontré une forte pression mémoire provenant des pointeurs parent et enfant. Chaque nœud nécessitait des références optionnelles à son parent, enfant gauche et enfant droit, initialement mises en œuvre comme Option<Box<Node>>.
L'utilisation de Option<Box<Node>> entraînait 16 octets par pointeur sur des systèmes 64 bits — 8 octets pour le pointeur Box et 8 octets pour le discriminant et le rembourrage. Pour un arbre avec 10 millions de nœuds, cela représentait 480 mégaoctets juste pour les pointeurs de liaison, dépassant notre budget mémoire.
Nous avons considéré trois approches. D'abord, remplacer Option<Box<Node>> par des pointeurs bruts (*mut Node) en utilisant nul pour None. Cela éliminait les frais généraux mais nécessitait des blocs unsafe dans l'ensemble du code, risquant des pointeurs suspendus et violant les garanties de sécurité de Rust. Deuxièmement, utiliser un allocateur d'arène avec des indices (usize) au lieu de pointeurs. Bien que compatible avec le cache, Option<usize> nécessitait toujours 16 octets en raison de l'absence de niches dans usize, et l'arithmétique des indices compliquait l'API.
Nous avons choisi la troisième approche : Option<NonNull<Node>> encapsulée dans une abstraction sûre ParentPtr. NonNull<T> possède une niche à l'adresse 0, permettant à Option<NonNull<Node>> de rester à 8 octets. Nous avons encapsulé le déréférencement unsafe dans les méthodes d'encapsulation, préservant la sécurité mémoire tout en réalisant une abstraction sans coût. Cela a réduit l'empreinte mémoire de l'AST de 50 %, s'inscrivant dans notre contrainte de 256 Mo sans compromettre la sécurité.
Pourquoi Option<Option<bool>> reste-t-il d'un seul octet alors qu'Option<Option<usize>> s'étend à 16 octets ?
bool possède 254 valeurs de niche car seuls les motifs de bits 0 et 1 sont valides. La première couche Option consomme une niche (par exemple, 2) pour représenter None, laissant 253 niches restantes. La seconde couche Option consomme une autre niche (par exemple, 3) pour son variant None. Par conséquent, Option<Option<bool>> tient toujours dans un seul octet. En revanche, usize n'a aucun motif de bits invalide — toutes les 2^64 valeurs sont des adresses mémoire ou des données valides. Sans niches, Option<usize> doit ajouter un octet de discriminant, résultant en 16 octets (8 pour les données, 8 pour l'alignement). Les couches Option imbriquées ne peuvent pas être optimisées davantage sans niches disponibles, donc Option<Option<usize>> reste 16 octets avec une logique discriminante interne.
Pourquoi le compilateur rejette-t-il l'optimisation de niche pour les énumérations marquées avec #[repr(C)] même lorsque le type de charge utile contient des niches ?
L'attribut #[repr(C)] garantit une mise en page mémoire compatible avec C avec un ordre de champ stable et un stockage discriminant explicite à un offset prévisible. La norme du langage C ne prend pas en charge les valeurs discrimantes se chevauchant avec les données de charge utile — les discriminants doivent résider dans des emplacements mémoire dédiés pour assurer la compatibilité FFI. Bien qu'une structure comme NonNull<T> contienne des niches (pointeur nul), les énumérations #[repr(C)] ne peuvent pas exploiter cela pour se chevaucher avec le discriminant car le code C externe s'attend à lire une valeur discriminante distincte à un offset fixe. Cette restriction préserve l'interopérabilité au détriment de l'efficacité mémoire, garantissant que sizeof(Option<&T>) égal à sizeof(&T) + sizeof(discriminant) sous #[repr(C)], typiquement 16 octets plutôt que 8.
Comment la fonction std::mem::discriminant fonctionne-t-elle pour des types comme Option<&T> qui n'ont pas de stockage discriminant explicite en mémoire ?
std::mem::discriminant renvoie une valeur opaque Discriminant<T> qui identifie de manière unique le variant de l'énumération indépendamment de la représentation mémoire sous-jacente. Pour Option<&T>, le compilateur génère du code qui dérive le discriminant en inspectant la valeur du pointeur — renvoyant une constante représentant Some si le pointeur est non nul, et une constante représentant None s'il est nul. Bien qu'aucun emplacement mémoire séparé ne stocke une étiquette de discriminant, le type Discriminant abstrait ce calcul, permettant de comparer les variants via == sans exposer les détails d'encodage de niche. Cela démontre que discriminant opère sur l'identité sémantique du variant plutôt que sur la disposition physique de la mémoire, permettant un comportement cohérent à travers les représentations d'énumération optimisées et non optimisées.