Mantener las invariantes: el contrato que hace correcto al unsafe
Un núcleo `unsafe` solo es correcto si ciertas afirmaciones sobre el estado interno son siempre ciertas. Identificar esas invariantes y garantizarlas en cada camino —constructor, métodos y `Drop`— es la esencia de una abstracción segura. Y hay un camino que todos olvidan: el del pánico a mitad de una mutación.
Un núcleo unsafe no es correcto en abstracto: es correcto bajo unas suposiciones. ptr::read es seguro si esos bytes están inicializados; get_unchecked(i) lo es si i cae en rango. Esas suposiciones —el puntero no es nulo, len <= cap, los primeros len elementos están vivos— son las invariantes del tipo, y tu trabajo como autor de la abstracción es sostenerlas en todo momento en que el control esté fuera de tus métodos. Sostenerlas tiene tres frentes obligatorios: el constructor las establece, cada método las preserva, y Drop las honra. Y hay un cuarto, traicionero, que casi todos olvidan: un pánico a mitad de una mutación no puede dejar el objeto en un estado que luego provoque UB.
- Formular las invariantes de un tipo como proposiciones sobre su estado interno.
- Garantizarlas en los tres caminos: constructor, métodos y
Drop. - Ordenar las mutaciones para que un pánico nunca deje un estado corrupto (panic safety).
- Evitar la doble liberación al mover valores fuera de memoria cruda.
La invariante como proposición
Una invariante es una afirmación sobre el estado interno que debe ser cierta siempre que el control salga de tus métodos: entre una llamada y la siguiente, un observador externo jamás debe poder atrapar al objeto en un estado que viole esa afirmación. Construyamos una pila de capacidad fija sobre memoria sin inicializar para verlo en carne viva:
use std::mem::MaybeUninit;
pub struct Pila<T> {
datos: Box<[MaybeUninit<T>]>, // capacidad = datos.len()
len: usize,
}
Sus invariantes, escritas como un contrato con uno mismo:
len <= datos.len()(nunca desbordamos la capacidad).- Las posiciones
datos[0..len]están inicializadas;datos[len..]no.
Todo el unsafe de este tipo será correcto si y solo si esas dos frases se mantienen. La disciplina consiste en no dejarlas caer en ningún camino.
Establecer y preservar
El constructor establece la invariante partiendo de un estado trivialmente válido: capacidad reservada, cero elementos vivos.
impl<T> Pila<T> {
pub fn con_capacidad(cap: usize) -> Self {
let datos = (0..cap).map(|_| MaybeUninit::uninit()).collect();
Pila { datos, len: 0 } // len = 0: el rango inicializado esta vacio, cierto
}
}
Cada método debe preservar la invariante: si es cierta al entrar, ha de ser cierta al salir. Y aquí aparece la sutileza del orden. En push escribimos el valor antes de incrementar len; en pop decrementamos len antes de leer el valor:
impl<T> Pila<T> {
pub fn push(&mut self, v: T) -> Result<(), T> {
if self.len == self.datos.len() {
return Err(v); // preserva la invariante rechazando el desbordamiento
}
self.datos[self.len].write(v);
self.len += 1; // solo tras escribir: len nunca cuenta un hueco sin inicializar
Ok(())
}
pub fn pop(&mut self) -> Option<T> {
if self.len == 0 {
return None;
}
self.len -= 1; // primero: el elemento deja de estar "vivo" para la invariante
// SAFETY: datos[len] estaba inicializado y ya nadie mas lo leera
Some(unsafe { self.datos[self.len].assume_init_read() })
}
}
El orden no es estética: es corrección. Si en push incrementáramos len antes de escribir, existiría un instante en que la invariante “los primeros len están inicializados” sería falsa, con un hueco de basura contando como vivo. Y si algo entre medias fallara, ese estado corrupto sobreviviría.
El camino que todos olvidan: el pánico
Cuando algo entra en pánico, Rust desenrolla la pila y ejecuta el Drop de cada valor vivo por el camino, incluido el de tu tipo a medio mutar. Si tu método dejó la invariante rota en el momento del pánico, tu propio Drop la leerá y actuará sobre un estado imposible: leerá memoria sin inicializar, liberará dos veces, o algo peor. La panic safety consiste en ordenar cada método para que, en cualquier punto donde pueda desatarse un pánico, la invariante siga en pie. La regla práctica: muta el contador que define “qué está vivo” en el instante seguro, nunca antes de tiempo.
Por eso pop decrementa len antes de mover el valor hacia fuera: en el momento en que ese elemento sale de la pila, deja de contar como inicializado, y aunque el propio dato entrara en pánico al ser movido —imposible aquí, pero razónalo en general—, Drop ya no lo tocaría. El principio se generaliza: si vas a extraer o reescribir varios elementos, ajusta primero el contador y opera después, de modo que ningún estado intermedio observable viole la invariante.
Honrar la invariante en Drop
Drop es el tercer camino, y depende por completo de que la invariante sea cierta al entrar. Debe destruir exactamente los elementos inicializados —los primeros len— y ni uno más, o incurrirá en doble liberación:
impl<T> Drop for Pila<T> {
fn drop(&mut self) {
for hueco in &mut self.datos[..self.len] {
// SAFETY: datos[0..len] estan inicializados (invariante) y se
// destruyen una sola vez, porque len marca justo esa frontera
unsafe { hueco.assume_init_drop(); }
}
}
}
Fíjate en la simbiosis: Drop puede confiar ciegamente en len porque cada método lo mantuvo honesto. La invariante es un contrato transitivo. Y el peligro simétrico está en mover un valor fuera de memoria cruda sin ajustar el contador: si assume_init_read copia un T hacia fuera pero len sigue contándolo, Drop lo destruirá una segunda vez. Por eso mover-fuera y decrementar son un solo acto indivisible.
Cuando necesites sacar un valor de detrás de un &mut self sin dejar un hueco inválido, no lo leas con un puntero crudo y reces: usa std::mem::replace para intercambiarlo por un valor válido, std::mem::take si el tipo es Default, o envuelve un campo en ManuallyDrop para asumir tú el control de su destrucción. Y si transfieres la propiedad de un recurso hacia fuera, std::mem::forget (o ManuallyDrop) evita que tu Drop lo libere después. Estas herramientas existen precisamente para que preservar invariantes al mover no dependa de tu pulso con los punteros.
flowchart TD C[Constructor establece la invariante] --> M[Metodos la preservan en cada camino] M --> M M --> P[Un panico desenrolla y dispara Drop] P --> D[Drop honra la invariante y destruye solo lo vivo] M --> D style C fill:#a6e3a1,color:#11111b style P fill:#f38ba8,color:#11111b style D fill:#cba6f7,color:#11111b
Detente en la estructura lógica de lo que acabas de hacer, porque es exactamente una demostración por inducción y por eso funciona. El constructor es el caso base: crea un estado en el que la invariante es trivialmente cierta —cero elementos, nada que pueda estar mal—. Cada método es el paso inductivo: bajo la hipótesis de que la invariante era cierta al entrar, demuestra que sigue siéndolo al salir. Y como todo estado alcanzable del objeto se obtiene aplicando el constructor y luego una cadena finita de métodos, la inducción garantiza que la invariante es cierta en todo estado observable, sin excepción, para siempre. Ese es el teorema que sostiene cada bloque unsafe del tipo: assume_init_read es correcto porque, por inducción, esa posición está inicializada; Drop es correcto porque, por inducción, len cuenta exactamente los elementos vivos. Aquí es donde la panic safety revela su profundidad: el desenrollado por pánico es un camino de ejecución que atraviesa tus métodos por un punto arbitrario y salta a Drop. Si tu paso inductivo solo era válido “de principio a fin del método” pero se rompía a la mitad, la inducción tiene un agujero, y el pánico es la aguja que lo encuentra. Por eso el orden de las mutaciones no es un detalle de estilo, sino la diferencia entre una prueba con o sin lagunas: mover el contador en el instante justo es lo que hace que la invariante se mantenga en cada punto intermedio, no solo en los extremos. Programar el núcleo unsafe es, literalmente, escribir la prueba; y Drop, el borrow checker de C++ que nunca tuviste, es el consumidor final que confía en que la prueba no tenga huecos. Cuando interiorizas que cada método es un lema y el tipo entero un teorema, dejas de “tener cuidado con el unsafe” y empiezas a demostrarlo.
Una invariante es una proposición sobre el estado interno que debe ser cierta siempre que el control esté fuera de tus métodos. Se sostiene en tres caminos: el constructor la establece (caso base), cada método la preserva (paso inductivo) y Drop la honra destruyendo solo lo vivo. El cuarto camino es el pánico: ordena las mutaciones para que ningún estado intermedio observable viole la invariante, mutando el contador que define “qué está vivo” en el instante seguro. Al mover fuera de memoria cruda, empareja siempre la extracción con el ajuste del contador para no liberar dos veces; apóyate en mem::replace, ManuallyDrop y mem::forget.
- Escribe las dos invariantes de
Pila<T>como proposiciones y señala, para cada bloqueunsafe, cuál de ellas lo hace correcto. - Reordena
pushpara incrementarlenantes dewrite. Describe el estado corrupto que aparece y por quéDropprovocaría UB si mediara un pánico. - Explica por qué
popdecrementalenantes de leer. Relaciónalo con la doble liberación y conmem::forget. - Implementa
Droppara que destruya de más (por ejemplo..=self.len). Argumenta qué invariante viola y qué fallo concreto produce. - Añade un método
truncar(&mut self, n: usize)que destruya los elementos por encima den. Garantiza que preserva la invariante y que es seguro frente a un pánico delDropde unT.