wandres.dev
ENUMS Y PATTERN MATCHING · el superpoder de Rust

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.

⏱ 16 min

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.

🎯 Al terminar esta lección sabrás
  • Entender el enum como 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 Message y Shape.
  • 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:

  • UnidadQuit. No transporta nada; solo importa que sea ese caso.
  • TuplaWrite(String), ChangeColor(u8, u8, u8). Datos posicionales, como una tupla anónima pegada a la variante.
  • StructMove { 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.

ℹ️
Un enum es un tipo, sus variantes no

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 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.

💡
Un enum sin datos no es un int con nombres

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.

El enum es la mitad del modelado que otros lenguajes te niegan

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.

⚔️ Diseña un enum que calque un dominio
  1. Escribe el enum Message de arriba y una función fn procesa(m: Message) que imprima algo distinto para cada variante extrayendo sus datos.
  2. Añade a Shape una variante Square { side: f64 } y comprueba qué te obliga a cambiar el compilador (te lo dirá area).
  3. Con std::mem::size_of mide Shape y explica por qué vale lo que vale a partir de su variante mayor.
  4. Compara size_of::<Option<u32>>() con size_of::<Option<std::num::NonZeroU32>>() y razona qué nicho aprovecha el segundo.
  5. Escribe en un comentario por qué una union de C no podría dar la misma garantía que este enum.