Newtype y typestate: estados inválidos que ni se pueden escribir
El sistema de tipos como refuerzo de la seguridad: un *newtype* validado hace que poseer el valor sea la prueba de que cumple su invariante, y el *typestate* codifica el estado de un protocolo en el tipo para que las operaciones inválidas ni siquiera existan. Hacer irrepresentables los estados ilegales convierte al compilador en el guardián de tu núcleo `unsafe`.
Hay dos maneras de garantizar que algo no pase: comprobarlo en ejecución y rezar por no olvidar un caso, o hacer que ese algo sea imposible de escribir. Rust favorece la segunda, y su herramienta es el sistema de tipos. Un newtype validado convierte el hecho de poseer un valor en la prueba de que cumple su invariante: si tienes un NoVacio, es que no está vacío, punto. Un typestate codifica el estado de un protocolo en el propio tipo, de modo que las operaciones ilegales en ese estado ni siquiera existen como métodos. Ambos comparten un lema, el más profundo del diseño en Rust: hacer irrepresentables los estados ilegales. Y ambos refuerzan tu núcleo unsafe, porque le entregan hechos ya demostrados sobre los que apoyarse sin volver a comprobar.
- Usar el patrón newtype para dar significado, coherencia e invariantes a un valor.
- Construir un newtype validado cuya mera existencia pruebe una precondición.
- Codificar un protocolo con typestate para que los estados inválidos no compilen.
- Conectar ambas técnicas con la solidez de un núcleo
unsafe.
El newtype validado: el tipo como prueba
Un newtype es una tupla de un solo campo, struct Metros(f64), sin coste en ejecución, que sirve para dar significado, para respetar la coherencia de traits, y —lo que aquí importa— para cargar una invariante. La receta: campo privado y un constructor que es la única puerta de entrada, la cual comprueba la invariante una sola vez. A partir de ahí, tener un valor del tipo es la prueba de que la invariante se cumple:
pub struct NoVacio<T>(Vec<T>); // invariante: el Vec nunca esta vacio
impl<T> NoVacio<T> {
pub fn new(v: Vec<T>) -> Option<NoVacio<T>> {
if v.is_empty() {
None // la unica puerta rechaza lo invalido
} else {
Some(NoVacio(v))
}
}
// Gracias a la invariante, esto NO devuelve Option: siempre hay un primero
pub fn primero(&self) -> &T {
// SAFETY: la invariante del tipo garantiza al menos un elemento
unsafe { self.0.get_unchecked(0) }
}
}
Mira lo que ha pasado: primero puede llamar a get_unchecked —una función unsafe— sin comprobar nada, porque la comprobación ya ocurrió, una sola vez, en new. El tipo transporta la prueba desde el punto de validación hasta cada punto de uso. Esto es el patrón central del nivel visto desde el otro lado: en vez de proteger un núcleo unsafe con un assert en cada método, lo proteges con un tipo cuya existencia ya implica la precondición.
El caso se ve aún más nítido cuando la invariante habilita un atajo unsafe. Un Ascii cuya invariante es “todos los bytes son ASCII válidos” puede saltarse la validación UTF-8 que from_utf8 haría, porque el ASCII es UTF-8 por definición:
pub struct Ascii(Vec<u8>); // invariante: todo byte es ASCII, es decir < 128
impl Ascii {
pub fn new(bytes: Vec<u8>) -> Option<Ascii> {
bytes.is_ascii().then_some(Ascii(bytes)) // valida una sola vez, en la frontera
}
pub fn como_str(&self) -> &str {
// SAFETY: la invariante garantiza ASCII, que es UTF-8 valido por definicion,
// asi que podemos omitir la revalidacion que hace from_utf8
unsafe { std::str::from_utf8_unchecked(&self.0) }
}
}
La comprobación cara ocurre una vez, en new; cada como_str posterior es gratis y no puede fallar. El tipo es el recibo de que la validación ya se pagó.
Typestate: el protocolo en el tipo
El newtype protege un valor; el typestate protege una secuencia de operaciones. Muchos recursos siguen un protocolo —abrir antes de leer, conectar antes de enviar, construir antes de finalizar— y el error clásico es llamar a una operación en el estado equivocado. El typestate hace que ese error no compile, codificando el estado como un parámetro de tipo:
use std::marker::PhantomData;
pub struct Abierta; // marcadores de estado: tipos de tamano cero
pub struct Cerrada;
pub struct Puerta<Estado> {
_estado: PhantomData<Estado>,
}
impl Puerta<Cerrada> {
pub fn new() -> Puerta<Cerrada> {
Puerta { _estado: PhantomData }
}
pub fn abrir(self) -> Puerta<Abierta> {
Puerta { _estado: PhantomData }
}
}
impl Puerta<Abierta> {
pub fn cruzar(&self) { /* solo tiene sentido con la puerta abierta */ }
pub fn cerrar(self) -> Puerta<Cerrada> {
Puerta { _estado: PhantomData }
}
}
Los métodos viven en bloques impl distintos según el estado. cruzar solo existe para Puerta<Abierta>; abrir solo para Puerta<Cerrada>. Las transiciones consumen self y devuelven el nuevo tipo, así que el estado viejo desaparece. El resultado:
let p = Puerta::new(); // Puerta<Cerrada>
let p = p.abrir(); // Puerta<Abierta>
p.cruzar(); // OK
let p = p.cerrar(); // Puerta<Cerrada>
// p.cruzar(); // ERROR: no existe cruzar para Puerta<Cerrada>
// p.cerrar(); // ERROR: no existe cerrar para Puerta<Cerrada>
Llamar a cruzar con la puerta cerrada no es un error en ejecución que capturas con un Result: es un error de compilación, porque el método literalmente no existe para ese tipo. Y como Abierta y Cerrada son tipos de tamaño cero, todo este andamiaje se evapora en el binario: coste cero.
El corazón del typestate es que cada transición toma self por valor, no por referencia. Al consumir el valor antiguo, el borrow checker garantiza que nadie conserve un Puerta<Abierta> después de haberla cerrado: ese binding ha sido movido y ya no se puede usar. Si las transiciones tomaran &mut self y mutaran un campo interno, el estado sería dinámico otra vez y volverías a necesitar comprobaciones en ejecución. La inmovilidad del valor viejo es la que traslada la verificación al compilador.
flowchart LR C[Puerta Cerrada] -->|abrir consume self| A[Puerta Abierta] A -->|cerrar consume self| C A -->|cruzar toma ref| A style C fill:#89b4fa,color:#11111b style A fill:#a6e3a1,color:#11111b
Los dos, al servicio del núcleo unsafe
La sinergia con el nivel es directa. Un bloque unsafe necesita premisas: “este índice está en rango”, “esta cadena es ASCII válido”, “esta conexión está establecida”. Si esas premisas viajan como tipos —un IndiceValido, un Ascii, un Conexion<Establecida>— el núcleo unsafe puede consumirlas sin volver a comprobarlas, y la comprobación ocurre una sola vez, en la frontera, dentro del constructor validado. Así el newtype y el typestate no son alternativas al patrón unsafe: son su refuerzo. Reducen la superficie de lo que hay que verificar en ejecución, porque el compilador ya verificó el resto en tu nombre.
Aquí toca una de las ideas más hondas de todo el lenguaje, la que Yaron Minsky bautizó como “make illegal states unrepresentable” y que Rust lleva más lejos que casi ningún otro lenguaje práctico. Hay dos filosofías para tratar con estados inválidos. La primera, dominante en casi toda la industria, los admite en el espacio de valores y luego los persigue en ejecución: aceptas un Vec que podría estar vacío y compruebas antes de cada acceso, aceptas una conexión que podría estar cerrada y validas antes de cada envío. Cada comprobación es una oportunidad de olvido, y cada olvido, un bug latente que espera al peor momento. La segunda filosofía, la de Rust, estrecha el espacio de valores hasta que lo ilegal ya no cabe en él: si un Vec vacío no puede ser un NoVacio, entonces primero no necesita defenderse de él, porque nunca lo recibirá; si una puerta cerrada no tiene método cruzar, entonces cruzar una puerta cerrada no es un caso a manejar, es una frase que no se puede pronunciar. La diferencia es temporal y es total: la primera filosofía verifica en el futuro, en cada ejecución, para siempre; la segunda verifica en el presente, una vez, en tiempo de compilación. Y es también epistemológica: un Result te dice “esto podría fallar, prepárate”; un tipo que hace irrepresentable el fallo te dice “esto no puede ocurrir, no pienses en ello”. Lo asombroso es que en Rust esta segunda vía no cuesta nada en ejecución —los marcadores de estado son tipos de tamaño cero, los newtype son transparentes— de modo que compras seguridad demostrada sin pagar un solo ciclo. El sistema de tipos deja de ser un notario que clasifica datos y se revela como lo que en el fondo es: un demostrador de teoremas al que le encargas custodiar las invariantes que tu núcleo unsafe necesita. Diseñar tipos así es programar con la certeza de que las categorías enteras de errores que otros depuran de madrugada, en tu código, no existen porque no se pueden escribir.
El newtype validado (campo privado, constructor único que comprueba) hace que poseer el valor pruebe su invariante; así un método puede llamar a unsafe sin recomprobar, porque la validación ocurrió una vez en la frontera. El typestate codifica el estado de un protocolo como parámetro de tipo, con marcadores de tamaño cero y PhantomData; las transiciones consumen self y devuelven el nuevo tipo, de modo que las operaciones inválidas no existen como métodos y no compilan. Ambos encarnan “hacer irrepresentables los estados ilegales”: trasladan la verificación de la ejecución a la compilación, con coste cero, y entregan al núcleo unsafe premisas ya demostradas.
- Implementa
NoVacio<T>connewyprimero. Explica qué invariante permite queprimerouseget_uncheckedsin comprobar y por qué no devuelveOption. - Construye un
Ascii(String)cuyo constructor valide que todos los bytes son ASCII. Escribe un método que se apoye en esa invariante para justificar un bloqueunsafe. - Modela con typestate un
Fichero<Cerrado>yFichero<Abierto>dondeleersolo exista en estado abierto. Transcribe el error al llamarleersobre uno cerrado. - Explica por qué las transiciones deben tomar
selfpor valor. ¿Qué se rompería si tomaran&mut self? - Argumenta el coste en ejecución de los marcadores
AbiertayCerraday dePhantomData, y relaciónalo con “hacer irrepresentable lo ilegal sin pagar nada”.