Enums con y sin datos: la unión etiquetada segura
El tipo suma de Rust: un valor que es exactamente una de varias variantes, y donde cada variante puede llevar su propia carga de datos. De los enums-nombre de C a la unión etiquetada que el compilador vigila por ti.
Un struct agrupa datos que van juntos: un punto tiene una x y una y. Un enum modela lo contrario: un valor que es una de varias cosas posibles, nunca dos a la vez. El salto que separa a Rust de casi todos los lenguajes de sistemas es que cada variante puede llevar su propia carga de datos, con forma distinta, mientras el compilador garantiza que jamás leerás la carga equivocada.
- Entender el
enumcomo tipo suma: un valor que es exactamente una variante. - Adjuntar datos a las variantes en sus tres formas: unidad, tupla y struct.
- Modelar dominios reales con
MessageyShape. - Ver por dentro la etiqueta más carga y por qué es una unión segura.
De un nombre a una posibilidad con datos
En C, un enum es azúcar sobre enteros: un puñado de nombres para constantes. En Rust ese caso existe, pero es el más pobre de una idea mucho mayor. La variante puede llevar datos, y cada una puede llevarlos con una forma distinta:
enum Message {
Quit, // variante unidad: no lleva datos
Move { x: i32, y: i32 }, // variante struct: campos con nombre
Write(String), // variante tupla: un dato posicional
ChangeColor(u8, u8, u8), // variante tupla: tres datos
}
Un valor de tipo Message es exactamente uno de esos cuatro casos: o es Quit, o es un Move con sus coordenadas, o un Write con su texto, o un ChangeColor con su terna. Nunca dos a la vez, nunca ninguno. Esto es lo que en teoría de tipos se llama un tipo suma: el número de valores posibles del enum es la suma de los valores de sus variantes, igual que un struct es un tipo producto (su cardinalidad es el producto de la de sus campos). De ahí el nombre tipos algebraicos de datos, y de ahí que un enum y un struct sean las dos mitades duales del modelado de datos.
flowchart TD M[enum Message] --> Q[Quit: sin datos] M --> Mo[Move: x mas y] M --> W[Write: un String] M --> C[ChangeColor: r g b] style M fill:#cba6f7,color:#11111b style Q fill:#89b4fa,color:#11111b style Mo fill:#89b4fa,color:#11111b style W fill:#89b4fa,color:#11111b style C fill:#89b4fa,color:#11111b
Las tres formas de una variante
Cada variante elige su forma según lo que necesite representar, y puedes mezclarlas en el mismo enum:
- Unidad —
Quit. No transporta nada; solo importa que sea ese caso. - Tupla —
Write(String),ChangeColor(u8, u8, u8). Datos posicionales, como una tupla anónima pegada a la variante. - Struct —
Move { x: i32, y: i32 }. Campos con nombre, cuando la claridad importa.
Un enum bien elegido hace que su impl sea limpio. Modelemos figuras geométricas y su área, donde cada forma necesita distintos datos:
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
}
Fíjate en lo que no aparece: ni un radius colgando en un Rectangle, ni un campo kind que haya que mantener a mano, ni un if comprobando qué es esto. Los datos de cada figura viven dentro de su variante y solo son accesibles cuando has confirmado la variante. La x de un Move sencillamente no existe fuera de un Move.
Shape es un tipo; Shape::Circle es un constructor de ese tipo, no un tipo por sí mismo. No puedes declarar una función que reciba Shape::Circle: recibe Shape y desambigua dentro con match. Esta es una diferencia deliberada con las jerarquías de clases, donde cada subclase sí es un tipo. Rust lo cierra a propósito, y en la lección 6.5 verás por qué esa clausura es una fortaleza y no una carencia.
Por dentro: etiqueta más carga
En memoria, un enum es una unión etiquetada: un discriminante (la etiqueta, que dice qué variante es) seguido de espacio suficiente para la carga de la variante más grande. El tamaño total es el de la variante mayor más el discriminante, con el ajuste de alineación:
use std::mem::size_of;
enum Small { A, B, C } // solo etiqueta: 1 byte
enum Payload { Nothing, Num(u64) } // etiqueta + hueco para u64
fn main() {
assert_eq!(size_of::<Small>(), 1);
// el u64 exige alineacion de 8, asi que el total sube a 16
assert_eq!(size_of::<Payload>(), 16);
}
Compáralo con la union de C, que superpone sus campos sin etiqueta: el programador debe recordar por su cuenta cuál campo es válido, y leer el equivocado es comportamiento indefinido. El enum de Rust carga la etiqueta automáticamente y match la comprueba; por eso es una unión segura. Pagas un discriminante a cambio de que corromper la memoria por confundir variantes sea, literalmente, imposible.
Y cuando ese discriminante es innecesario, el compilador lo elimina con la optimización de nicho: si una variante ya tiene un patrón de bits imposible, ese hueco codifica otra variante gratis. El caso emblemático es Option:
use std::mem::size_of;
fn main() {
assert_eq!(size_of::<Option<u64>>(), 16); // sin nicho: 8 de dato + etiqueta
assert_eq!(size_of::<Option<&u64>>(), 8); // una referencia nunca es null: null = None
assert_eq!(size_of::<Option<Box<u64>>>(), 8); // igual: el puntero no nulo deja hueco
}
Option<&u64> ocupa lo mismo que un puntero desnudo porque el patrón nulo, que una & jamás toma, se reserva para None. Es la seguridad de Option sin coste de tamaño ni de velocidad: el famoso zero-cost abstraction aplicado al tipo que sustituye al null.
Discriminantes explícitos y representación
Cuando el enum no lleva datos, puedes fijar el valor entero de cada variante, imprescindible para protocolos binarios y para hablar con C:
#[repr(u8)]
enum Estado {
Inactivo = 0,
Activo = 1,
Error = 255,
}
fn main() {
let code = Estado::Error as u8; // 255: casteo directo a entero
assert_eq!(code, 255);
}
El atributo #[repr(u8)] fija el tipo y el tamaño del discriminante, algo que necesitas para serializar un byte en la red o encajar en una estructura de C. Sin datos, un enum es intercambiable con un entero acotado; con datos, #[repr] controla además cómo se dispone la etiqueta frente a la carga, dándote una representación en memoria exacta sin renunciar a la seguridad del match en el lado de Rust.
Aunque Estado compile a un u8, tratarlo como enum y no como entero desnudo te regala la exhaustividad del match y la imposibilidad de construir un valor fuera de rango. Un u8 admite 256 valores; Estado admite tres, y el compilador lo sabe y lo aprovecha. Nunca tendrás un Estado con el valor 7, mientras que un u8 = 7 disfrazado de estado es el bug clásico de C que este tipo vuelve imposible.
Interioriza esta simetría: struct es y, enum es o. Casi todos los lenguajes de sistemas te dan el y de primera clase (structs, clases, tuplas) pero te dejan huérfano en el o: para expresar “esto es A o B o C” acabas con una etiqueta manual y una union, o con jerarquías de herencia, o con punteros que podrían ser null. Todas esas soluciones comparten un defecto: el compilador no sabe que son excluyentes, así que no puede protegerte. Rust eleva el o al mismo rango que el y, y de esa decisión cuelga media identidad del lenguaje. Option deja de ser un puntero que reza por no ser null y pasa a ser “hay valor o no lo hay”, verificado. Result es “éxito o error”, verificado. Un AST es “esto es o un literal o una suma o una llamada”, verificado. Cuando piensas en o con la misma naturalidad con la que piensas en y, empiezas a diseñar tipos cuya forma calca la forma de la realidad, y los estados imposibles dejan de poder escribirse. Eso no es una característica del lenguaje: es una manera distinta de pensar los datos.
- Escribe el
enum Messagede arriba y una funciónfn procesa(m: Message)que imprima algo distinto para cada variante extrayendo sus datos. - Añade a
Shapeuna varianteSquare { side: f64 }y comprueba qué te obliga a cambiar el compilador (te lo diráarea). - Con
std::mem::size_ofmideShapey explica por qué vale lo que vale a partir de su variante mayor. - Compara
size_of::<Option<u32>>()consize_of::<Option<std::num::NonZeroU32>>()y razona qué nicho aprovecha el segundo. - Escribe en un comentario por qué una
unionde C no podría dar la misma garantía que esteenum.