Rust utilizza l'ottimizzazione del valore di nicchia (nota anche come riempimento di nicchia) per eliminare il deposito del discriminante per gli enum quando una variante contiene un tipo con schemi di bit non validi. Per Option<&T>, la variante None è rappresentata dal valore puntatore nullo—uno schema di bit che è non valido per &T perché i riferimenti devono sempre essere non nulli. Ciò consente al compilatore di memorizzare il discriminante implicitamente all'interno del puntatore stesso, garantendo che Option<&T> occupi esattamente una parola macchina. Questa ottimizzazione si applica a qualsiasi tipo che possieda valori di nicchia—schemi di bit non validi come 0 per NonZeroU32, valori al di fuori di 0 o 1 per bool, o valori sentinella specifici negli #[repr(C)] struct.
Durante lo sviluppo di un albero sintattico astratto (AST) ad alte prestazioni per un compilatore che elabora milioni di nodi, abbiamo affrontato una grave pressione sulla memoria a causa dei puntatori ai genitori e ai figli. Ogni nodo richiedeva riferimenti opzionali al proprio genitore, al figlio sinistro e al figlio destro, inizialmente implementati come Option<Box<Node>>.
Utilizzare Option<Box<Node>> comportava 16 byte per puntatore sui sistemi a 64 bit—8 byte per il puntatore Box e 8 byte per il discriminante e il padding. Per un albero con 10 milioni di nodi, ciò ammontava a 480 megabyte solo per i puntatori di collegamento, superando il nostro budget di memoria.
Abbiamo considerato tre approcci. In primo luogo, sostituire Option<Box<Node>> con puntatori grezzi (*mut Node) utilizzando nullo per None. Ciò ha eliminato l'onere ma richiedeva blocchi unsafe in tutto il codice, mettendo a rischio i puntatori pendenti e violando le garanzie di sicurezza di Rust. In secondo luogo, utilizzare un allocatore di arena con indici (usize) invece di puntatori. Sebbene fosse amichevole della cache, Option<usize> richiedeva comunque 16 byte a causa della mancanza di nicchie in usize, e l'aritmetica degli indici complicava l'API.
Abbiamo scelto il terzo approccio: Option<NonNull<Node>> racchiuso in un'astrazione sicura ParentPtr. NonNull<T> porta una nicchia all'indirizzo 0, consentendo a Option<NonNull<Node>> di rimanere 8 byte. Abbiamo incapsulato il dereferenziamento unsafe all'interno dei metodi wrapper, preservando la sicurezza della memoria raggiungendo al contempo un'astrazione a costo zero. Questo ha ridotto l'impronta di memoria dell'AST del 50%, rientrando nel nostro vincolo di 256 MB senza sacrificare la sicurezza.
Perché Option<Option<bool>> rimane un singolo byte mentre Option<Option<usize>> si espande a 16 byte?
bool possiede 254 valori di nicchia perché solo gli schemi di bit 0 e 1 sono validi. Il primo livello di Option consuma una nicchia (ad es., 2) per rappresentare None, lasciando 253 nicchie rimanenti. Il secondo livello di Option consuma un'altra nicchia (ad es., 3) per la sua variante None. Di conseguenza, Option<Option<bool>> si adatta ancora a un byte. Al contrario, usize non ha schemi di bit non invalidi—tutti i 2^64 valori sono indirizzi di memoria validi o dati. Senza nicchie, Option<usize> deve aggiungere un byte discriminante, risultando in 16 byte (8 per i dati, 8 per l'allineamento). I livelli di Option annidati non possono ottimizzare ulteriormente senza nicchie disponibili, quindi Option<Option<usize>> rimane 16 byte con logica di discriminante interna.
Perché il compilatore rifiuta l'ottimizzazione della nicchia per gli enum contrassegnati con #[repr(C)] anche quando il tipo del payload contiene nicchie?
L'attributo #[repr(C)] garantisce un layout di memoria compatibile con C con un ordinamento stabile dei campi e uno stoccaggio discriminante esplicito a un offset prevedibile. Lo standard del linguaggio C non supporta valori discriminanti sovrapposti con dati di payload—i discriminanti devono risiedere in posizioni di memoria dedicate per garantire la compatibilità FFI. Sebbene una struttura come NonNull<T> contenga nicchie (puntatore nullo), gli enum #[repr(C)] non possono sfruttarle per sovrapporsi con il discriminante perché il codice C esterno si aspetta di leggere un valore discriminante distinto a un offset fisso. Questa restrizione preserva l'interoperabilità a scapito dell'efficienza della memoria, garantendo che sizeof(Option<&T>) sia uguale a sizeof(&T) + sizeof(discriminant) sotto #[repr(C)], tipicamente 16 byte invece di 8.
Come funziona std::mem::discriminant per tipi come Option<&T> che mancano di stoccaggio discriminante esplicito in memoria?
std::mem::discriminant restituisce un valore opaco Discriminant<T> che identifica in modo univoco la variante dell'enum indipendentemente dalla rappresentazione della memoria sottostante. Per Option<&T>, il compilatore genera codice che deriva il discriminante ispezionando il valore del puntatore—restituendo una costante che rappresenta Some se il puntatore è non nullo, e una costante che rappresenta None se è nullo. Anche se non esiste una posizione di memoria separata che memorizza un tag di discriminante, il tipo Discriminant astrae questo calcolo, consentendo il confronto delle varianti tramite == senza esporre i dettagli di codifica della nicchia. Ciò dimostra che discriminant opera sull'identità semantica della variante piuttosto che sul layout fisico della memoria, abilitando un comportamento coerente tra rappresentazioni di enum ottimizzate e non ottimizzate.