Rust emplea la optimización de valor de nicho (también llamada llenado de nicho) para eliminar el almacenamiento del discriminante para enums cuando una variante contiene un tipo con patrones de bits inválidos. Para Option<&T>, la variante None se representa mediante el valor de puntero nulo, un patrón de bits que es inválido para &T porque las referencias deben ser siempre no nulas. Esto permite que el compilador almacene el discriminante implícitamente dentro del puntero mismo, asegurando que Option<&T> ocupe exactamente una palabra de máquina. Esta optimización se aplica a cualquier tipo que posea valores de nicho: patrones de bits inválidos como 0 para NonZeroU32, valores fuera de 0 o 1 para bool, o valores centinela específicos en estructuras #[repr(C)].
Al desarrollar un árbol de sintaxis abstracta (AST) de alto rendimiento para un compilador que procesa millones de nodos, enfrentamos una fuerte presión de memoria debido a los punteros de padre e hijo. Cada nodo requería referencias opcionales a su padre, hijo izquierdo y hijo derecho, inicialmente implementadas como Option<Box<Node>>.
El uso de Option<Box<Node>> incurrió en 16 bytes por puntero en sistemas de 64 bits: 8 bytes para el puntero Box y 8 bytes para el discriminante y el relleno. Para un árbol con 10 millones de nodos, esto ascendió a 480 megabytes solo por punteros de enlace, superando nuestro presupuesto de memoria.
Consideramos tres enfoques. Primero, reemplazar Option<Box<Node>> con punteros en crudo (*mut Node) usando nulo para None. Esto eliminó la sobrecarga pero requería bloques unsafe en todo el código, arriesgando punteros colgantes y violando las garantías de seguridad de Rust. Segundo, utilizar un asignador de arena con índices (usize) en lugar de punteros. Aunque era amigable con la caché, Option<usize> aún requería 16 bytes debido a la falta de nichos en usize, y la aritmética de índices complicaba la API.
Seleccionamos el tercer enfoque: Option<NonNull<Node>> envuelto en una abstracción segura ParentPtr. NonNull<T> lleva un nicho en la dirección 0, permitiendo que Option<NonNull<Node>> siga teniendo 8 bytes. Encapsulamos la desreferenciación unsafe dentro de los métodos de envoltura, preservando la seguridad de la memoria mientras logramos una abstracción de costo cero. Esto redujo la huella de memoria del AST en un 50%, ajustándose a nuestra restricción de 256 MB sin sacrificar la seguridad.
¿Por qué Option<Option<bool>> sigue siendo un solo byte mientras que Option<Option<usize>> se expande a 16 bytes?
bool posee 254 valores de nicho porque solo los patrones de bits 0 y 1 son válidos. La primera capa de Option consume un nicho (por ejemplo, 2) para representar None, dejando 253 nichos restantes. La segunda capa de Option consume otro nicho (por ejemplo, 3) para su variante None. En consecuencia, Option<Option<bool>> aún se ajusta dentro de un byte. Por el contrario, usize no tiene patrones de bits inválidos: todos los 2^64 valores son direcciones de memoria válidas o datos. Sin nichos, Option<usize> debe agregar un byte de discriminante, resultando en 16 bytes (8 para datos, 8 para alineación). Las capas anidadas de Option no pueden optimizarse más sin nichos disponibles, por lo que Option<Option<usize>> sigue siendo 16 bytes con lógica de discriminante interna.
¿Por qué el compilador rechaza la optimización de nicho para enums marcados con #[repr(C)] incluso cuando el tipo de carga contiene nichos?
El atributo #[repr(C)] garantiza un diseño de memoria compatible con C con un orden de campo estable y almacenamiento de discriminantes explícitos en un desplazamiento predecible. El estándar del lenguaje C no admite valores de discriminante superpuestos con datos de carga: los discriminantes deben residir en ubicaciones de memoria dedicadas para garantizar la compatibilidad con FFI. Aunque una estructura como NonNull<T> contiene nichos (puntero nulo), los enums #[repr(C)] no pueden aprovechar esto para superponerse con el discriminante porque el código C externo espera leer un valor discriminante distinto en un desplazamiento fijo. Esta restricción preserva la interoperabilidad a costa de la eficiencia de memoria, garantizando que sizeof(Option<&T>) sea igual a sizeof(&T) + sizeof(discriminant) bajo #[repr(C)], típicamente 16 bytes en lugar de 8.
¿Cómo funciona std::mem::discriminant para tipos como Option<&T> que carecen de almacenamiento explícito de discriminante en memoria?
std::mem::discriminant devuelve un valor opaco Discriminant<T> que identifica de manera única la variante del enum independientemente de la representación de memoria subyacente. Para Option<&T>, el compilador genera código que deriva el discriminante inspeccionando el valor del puntero: devuelve una constante que representa Some si el puntero no es nulo, y una constante que representa None si es nulo. Aunque no hay una ubicación de memoria separada que almacene una etiqueta de discriminante, el tipo Discriminant abstrae este cálculo, permitiendo la comparación de variantes mediante == sin exponer los detalles de codificación de nicho. Esto demuestra que discriminant opera sobre la identidad semántica de variantes en lugar de la disposición física de la memoria, lo que permite un comportamiento consistente a través de representaciones de enums optimizadas y no optimizadas.