Modelar el dominio con enums: estados inválidos irrepresentables
Enums como herramienta de diseño, no de sintaxis: máquinas de estados donde cada estado lleva solo sus datos, y árboles de sintaxis abstracta que se recorren solos. El principio de hacer que los estados imposibles no puedan ni escribirse.
Un enum con datos no es una comodidad sintáctica: es una técnica de diseño. Cuando cada estado de tu sistema lleva exactamente los datos que ese estado necesita, y ningún otro, los estados imposibles dejan de poder escribirse. El bug no se detecta antes: sencillamente no cabe en el tipo. Este es el salto de “usar enums” a “diseñar con enums”, y es una de las ideas más poderosas que Rust toma de la programación funcional.
- Aplicar el principio de hacer los estados inválidos irrepresentables.
- Modelar una máquina de estados como un
enumcon datos por estado. - Construir y recorrer un árbol de sintaxis abstracta (AST) recursivo.
- Distinguir cuándo un
enumes diseño de dominio y no mera sintaxis.
El pecado del struct con campos opcionales
Mira cómo se modela típicamente una conexión de red en un lenguaje sin tipos suma ricos: un struct con un campo de estado y un montón de campos que a veces aplican:
// El anti-patrón: campos que solo valen en ciertos estados
struct Conexion {
estado: String, // "conectando" | "conectada" | "cerrada"
socket: Option<Socket>, // solo si conectada... ¿o conectando?
desde: Option<Instant>, // solo si conectada
motivo_cierre: Option<String>, // solo si cerrada
}
Este tipo permite escribir tonterías: una conexión "cerrada" con un socket presente, una "conectada" sin desde, un motivo_cierre en una conexión que sigue viva. Cada combinación ilegal es un bug esperando su turno, y para evitarlos te llenas el código de comprobaciones defensivas y unwrap que rezan. El tipo miente: dice que hay 2⁴ combinaciones cuando el dominio real solo tiene tres estados.
La máquina de estados como enum
Un enum dice la verdad. Cada variante es un estado, y lleva solo los datos que ese estado posee. Los demás ni siquiera existen:
struct Socket;
use std::time::Instant;
enum Conexion {
Conectando { intento: u8 },
Conectada { socket: Socket, desde: Instant },
Cerrada { motivo: String },
}
impl Conexion {
// una transicion consume el estado y devuelve el siguiente
fn confirmar(self, socket: Socket) -> Conexion {
match self {
Conexion::Conectando { .. } => Conexion::Conectada { socket, desde: Instant::now() },
// desde cualquier otro estado, confirmar no tiene sentido: no transita
otro => otro,
}
}
}
Ahora una Conectada siempre tiene socket y marca de tiempo; una Cerrada siempre tiene motivo y nunca socket. No hay Option, no hay defensivas: la ausencia de un campo en un estado se expresa por su ausencia en la variante. Y al hacer que las transiciones consuman self (lo mueven) y devuelvan el nuevo estado, el sistema de ownership garantiza que el estado viejo se destruye: es imposible seguir usando una Conexion que ya transitó.
stateDiagram-v2 [*] --> Conectando Conectando --> Conectada: confirmar con socket Conectando --> Cerrada: fallo o timeout Conectada --> Cerrada: cerrar Cerrada --> [*]
Un enum de estados garantiza que cada estado es internamente coherente, pero por sí solo no impide una transición ilegal: nada te prohíbe escribir una función que convierta Cerrada en Conectada. Para blindar también las transiciones existe el patrón typestate, que codifica cada estado como un tipo distinto (Conexion<Conectada>) de modo que un método solo existe en el estado que lo permite. Es el siguiente peldaño; el enum es el fundamento imprescindible sobre el que se construye.
El AST: un enum que se recorre solo
El caso donde los tipos suma brillan más es el árbol de sintaxis abstracta. Una expresión aritmética es recursivamente “o un número, o una suma de dos expresiones, o un producto, o una negación”. Eso se traduce a un enum casi palabra por palabra:
enum Expr {
Num(f64),
Suma(Box<Expr>, Box<Expr>),
Prod(Box<Expr>, Box<Expr>),
Neg(Box<Expr>),
}
fn eval(e: &Expr) -> f64 {
match e {
Expr::Num(n) => *n,
Expr::Suma(a, b) => eval(a) + eval(b),
Expr::Prod(a, b) => eval(a) * eval(b),
Expr::Neg(x) => -eval(x),
}
}
fn main() {
// representa -(2 + 3) * 4
let e = Expr::Prod(
Box::new(Expr::Neg(Box::new(Expr::Suma(
Box::new(Expr::Num(2.0)),
Box::new(Expr::Num(3.0)),
)))),
Box::new(Expr::Num(4.0)),
);
assert_eq!(eval(&e), -20.0);
}
El Box es obligatorio: sin él, Expr se contendría a sí mismo y tendría tamaño infinito; Box mete la subexpresión en el heap y deja en el nodo solo un puntero de tamaño fijo. Fíjate en la estructura del eval: la recursión del tipo y la recursión de la función son la misma forma. El match exhaustivo garantiza que ningún tipo de nodo queda sin evaluar, y si mañana añades Expr::Div, el compilador te enviará directo a eval y a cualquier otro recorrido (un imprime, un deriva, un optimiza) a completar el caso. Un intérprete, un compilador, un motor de plantillas, un validador de reglas: todos son, en su núcleo, un enum recursivo y un puñado de match que lo recorren.
Del enum al typestate: estados que son tipos
El enum hace irrepresentables los estados incoherentes, pero por sí solo no impide llamar a un método en el estado equivocado. El siguiente peldaño codifica el estado en el tipo, no en una variante, usando un parámetro de tipo fantasma que no ocupa memoria:
use std::marker::PhantomData;
struct Abierta;
struct Cerrada;
struct Puerta<Estado> {
_estado: PhantomData<Estado>,
}
impl Puerta<Cerrada> {
fn abrir(self) -> Puerta<Abierta> { Puerta { _estado: PhantomData } }
}
impl Puerta<Abierta> {
fn cerrar(self) -> Puerta<Cerrada> { Puerta { _estado: PhantomData } }
}
Ahora cerrar solo existe sobre una Puerta<Abierta>: intentar cerrar una puerta ya cerrada no es un error en ejecución, es código que no compila, porque el método ni siquiera está disponible en ese tipo. Donde el enum volvía irrepresentables los estados incoherentes, el typestate vuelve irrepresentables las transiciones ilegales. Son la misma filosofía —empujar el error al sistema de tipos— aplicada a dos ejes: el enum es el fundamento accesible que usarás el 90% de las veces; el typestate, el bisturí para las máquinas cuyo orden de operaciones debe garantizarse en compilación.
Hay una jerarquía silenciosa en cómo un programa trata sus errores. En el peldaño más bajo, el error ocurre y nadie lo nota: datos corruptos que se propagan. Un peldaño arriba, el error ocurre y estalla: un panic, una excepción, un test en rojo. Más arriba, el error se detecta en compilación: no llega a ejecutarse. Pero existe un peldaño superior a todos, y es donde te coloca el diseño con enums: el error no puede ni escribirse. No es que el compilador cace el estado inválido; es que no hay ninguna combinación de tecleos que lo produzca, porque el tipo no le reserva ni un hueco en memoria. Una Conexion::Cerrada no tiene un socket que pueda estar mal: no tiene socket, punto. Un AST no puede representar una suma de un solo operando: la variante Suma exige dos. Cuando modelas así, categorías enteras de bug abandonan tu universo de lo posible, y con ellas se van las comprobaciones defensivas, los assert, los comentarios de “esto nunca debería pasar” que siempre pasan. Esto explica por qué elegir tus tipos es la decisión de ingeniería más apalancada que tomas: cada estado imposible que consigues volver irrepresentable es una clase infinita de tests que nunca tendrás que escribir, porque el teorema ya está demostrado en la forma del dato. Diseñar con enums no es sintaxis; es trasladar la corrección del terreno frágil de la vigilancia en tiempo de ejecución al terreno firme de lo que la máquina, por construcción, no puede expresar.
- Toma el
struct Conexioncon cuatroOptiony reescríbelo comoenumde tres variantes; cuenta cuántas combinaciones ilegales acabas de eliminar. - Añade a la máquina de estados una transición
fallar(self) -> Conexionque lleve deConectandoaCerradacon un motivo. - Extiende
ExprconDiv(Box<Expr>, Box<Expr>), deja que el compilador te lleve aevaly decide qué hacer al dividir por cero (piensa enResult, que llega en el nivel 7). - Escribe una función
imprime(e: &Expr) -> Stringque reconstruya la expresión con paréntesis; observa cómo elmatchcalca la estructura del tipo. - Busca un
structde tu propio código con campos que solo valen “a veces” y bosqueja elenumque lo diría sin mentir.