wandres.dev
MÁQUINAS EN OTROS LENGUAJES · Swift, Kotlin, Rust

Rust y el type-state: la transición ilegal no llega a compilar

Rust ofrece dos niveles distintos de garantía sobre la misma máquina y confundirlos es el error más caro de este nivel. El enum con match exhaustivo reproduce lo que ya vimos en Swift y Kotlin y comprueba la legalidad en ejecución. El type-state pattern hace algo cualitativamente distinto: codifica el estado en el parámetro de tipo, de modo que llamar a un método fuera de su estado no es un error que ocurra sino una frase que el compilador rechaza. Esta lección construye ambos, explica por qué la semántica de movimiento es la pieza que lo hace posible y delimita con honestidad el precio: todo lo que se decide en ejecución queda fuera.

⏱ 20 min

Hasta aquí el patrón ha consistido siempre en lo mismo: representar el estado con un tipo suma y comprobar en ejecución si el evento que llega tiene transición declarada. Rust puede hacer eso exactamente igual que Swift o Kotlin, y en la mayoría de los casos es lo que conviene. Pero Rust puede también hacer algo que ningún lenguaje de los anteriores hace con comodidad, y que cambia la naturaleza de la garantía en lugar de mejorarla en grado: mover el estado del valor al tipo. Cuando el estado vive en el tipo, los métodos que no son legales en ese estado sencillamente no existen para el compilador, y una transición prohibida deja de ser un caso que hay que manejar para pasar a ser un programa que no llega a existir. El nombre de esa técnica es type-state, y su fundamento no es el sistema de tipos genérico sino algo mucho más específico de Rust: que un valor movido no se puede volver a usar.

🎯 Al terminar esta lección sabrás
  • Separar los dos niveles de garantía en Rust: legalidad comprobada en ejecución frente a legalidad comprobada en compilación.
  • Construir un tipo con el estado en un parámetro fantasma y transiciones que consumen el valor por movimiento.
  • Sellar el conjunto de estados con un rasgo privado para que nadie de fuera invente uno nuevo.
  • Delimitar el precio del type-state y saber combinarlo con un enum cuando la transición se decide en ejecución.

Dos garantías distintas sobre la misma máquina

El primer nivel no tiene misterio porque ya lo conoces con otra sintaxis. Un enum de Rust es un tipo suma con carga, match es exhaustivo por defecto y el compilador rechaza el código que olvida una variante. La transición se escribe como función sobre el par estado y evento, exactamente igual que en las dos lecciones anteriores, y la legalidad se comprueba cuando el programa corre: si llega un evento sin transición declarada, la rama comodín lo descarta o devuelve un error.

El segundo nivel responde a una pregunta distinta. No es qué hago cuando llega un evento ilegal, sino cómo hago para que ese evento no se pueda escribir. La respuesta es que el estado deje de ser un valor que se inspecciona y pase a ser un parámetro del tipo que se comprueba estáticamente. El truco necesita dos piezas: un marcador sin datos que ocupe ese parámetro y un campo fantasma que lo mantenga vivo para el sistema de tipos sin ocupar ni un byte en memoria.

use std::marker::PhantomData;
use std::net::TcpStream;

pub struct Cerrada;
pub struct Abierta;
pub struct Autenticada;

pub struct Sesion<E> {
    socket: Option<TcpStream>,
    reintentos: u8,
    _estado: PhantomData<E>,
}

impl Sesion<Cerrada> {
    pub fn nueva() -> Self { Sesion { socket: None, reintentos: 0, _estado: PhantomData } }

    pub fn conectar(self, destino: &str) -> std::io::Result<Sesion<Abierta>> {
        let socket = TcpStream::connect(destino)?;
        Ok(Sesion { socket: Some(socket), reintentos: 0, _estado: PhantomData })
    }
}

PhantomData es un tipo de tamaño cero: existe para el compilador y desaparece en el binario, de modo que la máquina de estados completa se paga con cero bytes de memoria y cero instrucciones en ejecución. Esa es la primera propiedad que distingue al type-state de cualquier otra técnica de este nivel: no es que la comprobación sea más temprana, es que la comprobación no deja rastro alguno en el programa que se ejecuta.

ℹ️
El estado no se comprueba porque no está ahí

Conviene resistir la lectura ingenua de que el compilador comprueba el estado más pronto. No hay comprobación: el estado no existe en tiempo de ejecución. Un valor de tipo Sesion<Abierta> y otro de tipo Sesion<Autenticada> tienen exactamente la misma representación en memoria y el mismo código máquina; lo único que los distingue es qué métodos aceptan, y eso se resuelve al compilar. Por eso el patrón se usa tanto en sistemas embebidos, donde el mismo registro de un periférico se expone como un tipo distinto según esté configurado como entrada o como salida, y la seguridad completa cuesta cero.

El movimiento es lo que cierra la puerta

Un parámetro de tipo por sí solo no bastaría, porque nada impediría conservar el valor viejo y seguir usándolo después de la transición. Lo que hace correcto el patrón es que la transición recibe self por valor y no por referencia: consume el objeto original. Tras llamar a conectar, la sesión cerrada ha sido movida y el compilador rechaza cualquier intento posterior de usarla. La máquina no solo prohíbe los métodos ilegales del estado nuevo, sino que borra el estado anterior de la lista de cosas que existen.

impl Sesion<Abierta> {
    pub fn autenticar(self, token: &str) -> Result<Sesion<Autenticada>, (Sesion<Abierta>, ErrorAuth)> {
        match negociar(self.socket.as_ref().unwrap(), token) {
            Ok(()) => Ok(Sesion { socket: self.socket, reintentos: 0, _estado: PhantomData }),
            Err(e) if self.reintentos < 3 => Err((self.subir_reintento(), e)),
            Err(e) => Err((self, e)),
        }
    }

    pub fn cerrar(self) -> Sesion<Cerrada> {
        Sesion { socket: None, reintentos: 0, _estado: PhantomData }
    }
}

impl Sesion<Autenticada> {
    pub fn enviar(&mut self, datos: &[u8]) -> std::io::Result<()> {
        use std::io::Write;
        self.socket.as_mut().unwrap().write_all(datos)
    }

    pub fn cerrar(self) -> Sesion<Cerrada> {
        Sesion { socket: None, reintentos: 0, _estado: PhantomData }
    }
}

Fíjate en la firma de autenticar, que es la parte que casi nadie escribe bien a la primera. Una transición que puede fallar consume el valor igualmente, de modo que si se limitara a devolver un error el objeto quedaría destruido y la sesión abierta se perdería sin motivo. La solución idiomática es devolver el valor original dentro de la variante de error, y eso obliga a formular una pregunta de diseño que en otros lenguajes ni siquiera se plantea: cuando esta transición falla, ¿en qué estado queda el sistema? El tipo te fuerza a responderla por escrito.

mod sellado {
    pub trait Sellado {}
}

pub trait EstadoDeSesion: sellado::Sellado {}

impl sellado::Sellado for Cerrada {}
impl sellado::Sellado for Abierta {}
impl sellado::Sellado for Autenticada {}
impl EstadoDeSesion for Cerrada {}
impl EstadoDeSesion for Abierta {}
impl EstadoDeSesion for Autenticada {}

Ese rasgo con supertrait privado es el equivalente exacto de sealed en Kotlin: como el rasgo interno no es accesible desde fuera del módulo, ningún consumidor puede implementarlo y por tanto ningún tipo ajeno puede colarse como estado. Acotando el parámetro con Sesion<E: EstadoDeSesion> el conjunto de estados queda cerrado y documentado, y los métodos genéricos comunes a todos los estados —una función de diagnóstico, por ejemplo— se escriben una sola vez.

stateDiagram-v2
[*] --> Cerrada
Cerrada --> Abierta: conectar consume el valor
Abierta --> Autenticada: autenticar ok
Abierta --> Abierta: autenticar falla y devuelve el valor
Autenticada --> Autenticada: enviar por referencia mutable
Autenticada --> Cerrada: cerrar
Abierta --> Cerrada: cerrar

El precio: lo que se decide en ejecución queda fuera

El type-state es una garantía real y no una comodidad, pero su alcance está acotado por una condición muy concreta que conviene enunciar sin rodeos: solo funciona cuando la secuencia de estados es conocida al escribir el código. En el momento en que el estado siguiente depende de un dato que llega en ejecución, el tipo del valor tendría que depender de ese dato, y eso es precisamente lo que un lenguaje sin tipos dependientes no puede hacer.

Dimensión enum con match Type-state con parámetro
Cuándo se detecta la transición ilegal al ejecutar al compilar
Coste en memoria y en ciclos el discriminante ninguno
Estado decidido por datos de ejecución natural imposible sin envoltorio
Guardar varios estados en una colección directo exige envoltorio o indirección
Recorrer o inspeccionar el grafo posible el grafo solo existe en las firmas
Coste de escribir un estado nuevo una variante y sus ramas un bloque de implementación entero

La cuarta fila es la que suele descubrirse tarde y duele más. Una lista de sesiones en estados distintos no es un tipo válido, porque cada estado es un tipo diferente; hay que envolverlos en un enum que los reúna y, en ese momento, para volver a operar sobre uno hay que hacer match y recuperar el valor tipado. Lejos de invalidar el patrón, ese envoltorio es la forma canónica de combinar ambos niveles, y es la arquitectura que usan las bibliotecas serias.

pub enum SesionCualquiera {
    Cerrada(Sesion<Cerrada>),
    Abierta(Sesion<Abierta>),
    Autenticada(Sesion<Autenticada>),
}

impl SesionCualquiera {
    pub fn atender(self, evento: Evento) -> SesionCualquiera {
        match (self, evento) {
            (SesionCualquiera::Cerrada(s), Evento::Conectar(d)) => match s.conectar(&d) {
                Ok(abierta) => SesionCualquiera::Abierta(abierta),
                Err(_) => SesionCualquiera::Cerrada(Sesion::nueva()),
            },
            (otro, _) => otro,
        }
    }
}

El reparto que emerge es limpio y merece quedarse como regla. El type-state gobierna la interfaz pública que ofreces a quien usa tu biblioteca, donde el orden de las llamadas es una propiedad del protocolo y quieres que un uso incorrecto sea un error de compilación con un mensaje del estilo de que ese método no existe para ese tipo. El enum gobierna el bucle interno que reacciona a eventos impredecibles, donde el estado depende de lo que llegue por la red y hace falta guardar sesiones en tablas, recorrerlas y serializarlas. Ni siquiera compiten: el enum es el intérprete y los tipos son la gramática que ese intérprete respeta.

⚠️
Dos costes que no aparecen en los ejemplos de blog

El primero es la ergonomía de los mensajes de error. Cuando un consumidor llama a un método fuera de su estado, el compilador no dice que la transición sea ilegal: dice que el método no está en el tipo, y con genéricos anidados esos diagnósticos se alargan bastante. Documentar el grafo en la propia documentación del tipo deja de ser opcional. El segundo es el crecimiento cuadrático de la escritura: cada estado nuevo es un bloque de implementación completo y hay que decidir a mano qué métodos comunes se repiten, lo que empuja a extraerlos a rasgos con implementación por defecto. Un protocolo de tres o cuatro estados se disfruta; uno de doce se convierte en una tarea de mantenimiento.

💡
Dónde vive esto en el ecosistema

El patrón no es una curiosidad académica: es la forma habitual de exponer periféricos en el mundo embebido, donde un mismo pin del microcontrolador se representa como un tipo distinto según esté configurado como entrada, como salida o sin configurar, y escribir en un pin de entrada no compila. Aparece igualmente en constructores por fases, donde el método de construcción final solo existe cuando los campos obligatorios ya se han rellenado, y en formatos y protocolos que exigen escribir cabecera, cuerpo y cierre en ese orden. Si reconoces alguno de esos tres perfiles en tu código, la técnica te está esperando ahí.

Del documento que describe el grafo a la prueba de que se respeta

Toda máquina de estados es una afirmación sobre lo que es legal hacer después de qué, y la diferencia decisiva entre las herramientas de este nivel es qué categoría de objeto es esa afirmación. En un diagrama dibujado en una herramienta visual, la afirmación es documentación: alguien la escribió, quizá está desactualizada y nada obliga al código a obedecerla. En un intérprete de statecharts, la afirmación es un dato que un motor consulta en ejecución: el grafo existe, es introspectable, se puede dibujar y simular, y si el evento no encaja el motor lo descarta cuando el programa ya está corriendo. En el type-state, la afirmación se convierte en algo de otra especie: un teorema que el verificador de tipos comprueba antes de que exista el binario. La correspondencia de Curry y Howard describe esa mutación con precisión —los tipos son proposiciones y los programas son demostraciones—, y la firma que consume Sesion<Abierta> y devuelve Sesion<Autenticada> es literalmente la proposición de que existe un camino del estado abierto al autenticado, que solo se puede recorrer entero y que no deja el punto de partida disponible. El precio de ese ascenso es exactamente el que paga toda demostración estática: solo puede hablar de lo que se sabe al escribirla. Por eso el type-state brilla en protocolos, en constructores por fases y en registros de periféricos, donde la secuencia legal es una propiedad fija del mundo, y se vuelve inaplicable en una interfaz de usuario, donde el estado siguiente lo decide una persona impredecible dentro de un bucle de eventos. La lección que hay que llevarse no es que Rust tenga máquinas mejores, sino que ofrece dos categorías distintas de garantía y que elegir mal cuesta caro en ambas direcciones: usar tipos donde hacía falta un intérprete produce un diseño que no se puede guardar en una lista, y usar un intérprete donde bastaban los tipos paga en ejecución algo que era gratis al compilar.

⚔️ Sube un protocolo del valor al tipo
  1. Busca en tu código una interfaz con orden obligatorio de llamadas documentado en un comentario y cuenta cuántos estados implícitos tiene.
  2. Reescríbela con un parámetro de tipo, un marcador por estado y transiciones que consuman el valor por movimiento.
  3. Sella el conjunto de estados con un rasgo cuyo supertrait sea privado del módulo y comprueba que desde fuera no se puede añadir uno.
  4. Diseña la firma de la transición que puede fallar y decide de forma explícita en qué estado queda el sistema al fallar.
  5. Intenta guardar dos instancias en estados distintos dentro de la misma colección y observa dónde se rompe: ahí es donde nace el envoltorio.
  6. Escribe el envoltorio con enum y una función que atienda eventos, y anota qué comprobaciones vuelven a la ejecución y cuáles siguen siendo estáticas.