Cuándo usar unsafe, cuándo no, y Miri
unsafe es una moneda escasa: se gasta en FFI, en estructuras que el borrow checker no expresa y en optimizaciones medidas, nunca para silenciar un error que no entendiste ni por un rendimiento que no cronometraste. La disciplina es minimizar y encapsular la superficie. Y Miri es la conciencia que ejecuta tu unsafe bajo un microscopio y detecta el UB.
Llegamos al juicio práctico: ¿cuándo se justifica unsafe y cuándo es una señal de rendición? Hay motivos legítimos y son pocos y reconocibles: interoperar con C u otro lenguaje (FFI), construir estructuras de datos que el borrow checker genuinamente no sabe expresar —listas intrusivas, grafos, arenas, tipos autorreferenciales—, y aplicar optimizaciones que has medido y demostrado necesarias. Y hay un motivo ilegítimo que causa la mayoría del unsafe malo del mundo: usarlo para callar un error del compilador que no entendiste, o para un rendimiento que jamás cronometraste. La disciplina que separa a un ingeniero de sistemas de un accidente andante es la parsimonia: gastar unsafe como moneda escasa —lo mínimo, lo más encapsulado, lo más documentado y lo más probado posible—. Y para lo último existe una herramienta casi mágica: Miri, un intérprete que ejecuta tu código bajo un microscopio y detecta el comportamiento indefinido que ningún compilador de producción ve.
- Reconocer los tres motivos legítimos de
unsafe: FFI, estructuras que el borrow checker no expresa, y optimizaciones medidas. - Rechazar los motivos ilegítimos: silenciar un error incomprendido o perseguir rendimiento no cronometrado.
- Aplicar la disciplina de minimizar y encapsular la superficie
unsafetras una API sólida. - Usar
Miripara detectar UB en las rutas que tus pruebas ejercitan.
Cuándo sí, y cuándo no
La decisión se resuelve casi siempre con una tabla mental honesta:
Sí · FFI
Llamar a C, a una syscall o a una librería del sistema. No hay alternativa segura: cruzar la frontera del lenguaje es intrínsecamente una promesa.
Sí · Lo inexpresable
Listas doblemente enlazadas, grafos con ciclos, arenas, tipos autorreferenciales. El borrow checker no modela esos aliasing; los construyes tú y garantizas su seguridad.
Sí · Optimización medida
Saltarte una comprobación de rango en un bucle caliente después de perfilar y demostrar que es el cuello de botella. La palabra clave es después.
No · Rendición o pereza
Silenciar un borrow checker que no entendiste, evitar aprender ownership, u optimizar por corazonada sin cronómetro. Aquí unsafe es una deuda, no una herramienta.
La asimetría importa: los tres “sí” comparten que la alternativa segura no existe o está medidamente fuera de presupuesto; el “no” siempre esconde una alternativa segura que el autor no quiso encontrar. Antes de escribir unsafe, la pregunta obligatoria es: “¿puedo expresar esto de forma segura?”. Si la respuesta honesta es sí, no hay unsafe que valga.
Un ejemplo del uso ilegítimo, tan común que conviene reconocerlo de un vistazo:
// MAL: unsafe para callar un error que no se entendio.
fn mal(v: &mut Vec<i32>) {
let a = &mut v[0];
let b = unsafe { &mut *(a as *mut i32) }; // dos &mut al mismo i32
*a += 1;
*b += 1; // UB latente por aliasing
}
Aquí unsafe no resolvió nada: fabricó dos referencias mutables al mismo entero saltándose el puntero crudo, que es precisamente la invariante que jamás debe romperse. Compila, pero es comportamiento indefinido. La señal de alarma es que el unsafe aparece para ganar un aliasing, no para interoperar ni para una estructura que el lenguaje no exprese: casi siempre que unsafe es la respuesta a “el borrow checker no me deja”, la pregunta estaba mal planteada.
La disciplina: minimizar y encapsular
Cuando unsafe está justificado, el oficio es reducir su coste para el lector y el revisor, porque cada bloque unsafe es un lugar donde la garantía del compilador se interrumpe y un humano debe rederivarla. Dos reglas gobiernan:
Primero, bloques diminutos: encierra en unsafe solo la operación que de verdad lo exige, para que el compilador siga verificando todo lo demás. Segundo, encapsulación sólida: envuelve el unsafe tras una API segura cuya solidez hayas probado, de modo que el resto del programa herede la seguridad sin volver a tocar la palabra.
pub struct ParImpar<'a> {
pares: &'a mut [i32],
impares: &'a mut [i32],
}
pub fn separar(v: &mut [i32]) -> (&mut [i32], &mut [i32]) {
let mid = v.len() / 2;
// API publica SEGURA: quien la llama nunca escribe unsafe.
v.split_at_mut(mid) // el unsafe vive encapsulado dentro de split_at_mut
}
El usuario de separar obtiene dos slices mutables disjuntos sin escribir ni ver unsafe: la superficie peligrosa quedó sellada dentro de std. Ese es el patrón de todo Rust idiomático de bajo nivel: un núcleo unsafe minúsculo y auditado, una frontera segura y sólida, y encima un océano de código que hereda las garantías gratis.
Ese patrón tiene nombre en la comunidad: encapsulación de la inseguridad. La medida de calidad de una crate de bajo nivel no es cuánto unsafe contiene, sino cuánto unsafe logra no filtrar a sus usuarios. Una biblioteca excelente puede tener miles de líneas unsafe por dentro y una API pública en la que ningún usuario teclea jamás la palabra. La superficie que de verdad importa auditar no es la interna, sino la que queda expuesta: idealmente, ninguna.
Miri: la conciencia que ejecuta tu unsafe
Ningún compilador de producción detecta el UB: lo asume ausente y optimiza. Miri hace lo contrario. Es un intérprete de nivel MIR que ejecuta tu programa en una máquina virtual instrumentada y detecta en el acto el comportamiento indefinido: accesos fuera de rango, uso tras liberación, lecturas de memoria sin inicializar, desalineación, valores inválidos, carreras de datos y violaciones de aliasing bajo su modelo operativo (Stacked Borrows / Tree Borrows).
rustup +nightly component add miri
cargo +nightly miri test
cargo +nightly miri run
Considera este UB clásico —un puntero que sobrevive a su dato— que compila, e incluso “parece funcionar” en release, pero es una bomba:
fn main() {
let p;
{
let v = vec![1, 2, 3];
p = v.as_ptr(); // p apunta al buffer de v
} // v se destruye aqui: p queda colgante
let _leido = unsafe { *p }; // uso tras liberacion: UB
println!("{_leido}");
}
Un cargo run normal podría imprimir un número plausible y engañarte. Miri, en cambio, detiene la ejecución y señala el uso tras liberación con la línea exacta. No es una prueba de corrección total —solo inspecciona las rutas que tus tests recorren, no todas las posibles—, pero atrapa la inmensa mayoría de los errores reales de unsafe. La receta profesional combina tres capas: pruebas que ejerciten los caminos, Miri sobre esas pruebas, y fuzzing para generar entradas que a ti no se te ocurrirían.
Conviene ser honesto sobre los límites de Miri. No demuestra la ausencia de UB —solo inspecciona lo que se ejecuta, así que una rama nunca cubierta queda a oscuras—; no mide rendimiento, porque interpreta y es mucho más lento que el binario real; y su modelo de aliasing sigue siendo experimental, de modo que en casos raros puede marcar como sospechoso un patrón que la comunidad aún debate. Nada de eso le resta valor: sigue siendo, con diferencia, la mejor red que existe para el unsafe. Pero es una red, no una demostración, y conviene usarla sabiéndolo.
Miri solo ve lo que se ejecuta. Por eso su máximo valor es correrlo sobre tu batería de tests (cargo +nightly miri test): cada caso de prueba se convierte en una ruta auditada en busca de UB. Cuantos más caminos cubran tus tests, más territorio de tu unsafe queda barrido. Un unsafe sin pruebas es un unsafe que Miri no puede ayudarte a validar.
Miri detecta el UB pero solo en las entradas que le das; un fuzzer genera entradas que tú no imaginaste pero no sabe reconocer un UB sutil por sí solo. Ejecutar el fuzzer bajo Miri combina lo mejor de ambos: entradas impredecibles pasadas por un detector implacable. Es la técnica con la que los mantenedores de las crates unsafe más críticas del ecosistema logran dormir tranquilos.
Visto en conjunto, el flujo de trabajo del unsafe responsable encadena cuatro exigencias que se refuerzan entre sí: justificar, minimizar, encapsular y auditar. Ninguna sustituye a las demás, y saltarse una deja un hueco por el que el UB se cuela. Justificar sin encapsular filtra el peligro; encapsular sin auditar confía a ciegas; auditar sin minimizar obliga a Miri a razonar sobre más superficie de la necesaria.
flowchart LR J[Existe alternativa segura] -->|no o medidamente lento| M[Minimizar el bloque unsafe] J -->|si existe| S[No usar unsafe] M --> E[Encapsular tras una API solida] E --> A[Auditar con Miri sobre las pruebas] A --> F[unsafe defendible] style J fill:#cba6f7,color:#11111b style E fill:#89b4fa,color:#11111b style A fill:#f38ba8,color:#11111b style F fill:#a6e3a1,color:#11111b style S fill:#a6e3a1,color:#11111b
Hay dos formas fáciles y erróneas de relacionarse con unsafe, y ambas delatan inmadurez. Una es el entusiasmo: el programador que llega de C y salpica unsafe por comodidad, tratándolo como el modo natural de trabajar y la seguridad como un estorbo del que escapar. La otra es la fobia: quien lo trata como un tabú absoluto y prefiere retorcer su diseño hasta lo grotesco antes que escribir una línea de unsafe, aunque con ello renuncie a la interoperación con C, a las estructuras que el Rust seguro genuinamente no expresa y al último uno por ciento de rendimiento que su dominio exigía. La postura correcta no está en el punto medio tibio, sino en un principio distinto: la parsimonia. unsafe es un impuesto —sobre el lector, sobre el revisor, sobre tu yo futuro—, porque cada bloque es un lugar donde la garantía del compilador caduca y un humano debe reconstruir la prueba a mano. Un impuesto no se evita a toda costa ni se paga con alegría: se paga cuando compra algo que de verdad lo vale, y por el importe mínimo. De ahí las cuatro exigencias que definen el oficio: gastar unsafe solo cuando la alternativa segura no existe o está medidamente fuera de presupuesto; encerrarlo en bloques diminutos para que el compilador siga cubriendo todo lo demás; encapsularlo tras una API sólida para que el peligro no se filtre al resto del programa; y ejecutarlo bajo Miri, que es lo más parecido a una conciencia que tendrás —incapaz de demostrar que tu unsafe es correcto, pero capaz de cazar la mentira en cada ruta que le des a recorrer—. El ingeniero que recurre a unsafe para acallar un borrow checker que no entendió ha invertido el propósito de la herramienta; el que recurre a él a regañadientes, tras probarse a sí mismo que la expresión segura es imposible o está cronometradamente demasiado lenta, y que luego lo envuelve en una API sólida y lo pasa por Miri, practica la disciplina exacta que la palabra fue diseñada para exigir. Porque unsafe es el punto en que Rust deja de ser un lenguaje que te protege y pasa a ser uno que te confía el poder que antes te negaba. Hazte digno de esa confianza: úsalo poco, enciérralo bien, documenta cada invariante y somételo al microscopio. Ahí está la maestría de bajo nivel.
unsafe se justifica en tres casos: FFI, estructuras de datos que el borrow checker no expresa, y optimizaciones que has medido. No se justifica para silenciar un error incomprendido ni para rendimiento no cronometrado. La disciplina es la parsimonia: bloques diminutos para que el compilador cubra el resto, y encapsulación tras una API sólida para que el peligro no se filtre. Miri es un intérprete que ejecuta tu código y detecta UB —fuera de rango, uso tras liberación, valores inválidos, carreras, aliasing— en las rutas que corres; ejecútalo sobre tus pruebas con cargo +nightly miri test. No demuestra corrección total, pero caza casi todo error real. Combínalo con tests y fuzzing.
- Para cada caso decide
unsafeo alternativa segura y justifícalo: llamar amallocde C; una lista doblemente enlazada; sumar dos vectores en un bucle que aún no has perfilado. - Toma un bloque
unsafegrande y refactorízalo para que solo la operación que lo exige quede dentro del bloque. Cuenta cuántas líneas recupera la verificación del compilador. - Envuelve un
unsafetras una función pública segura y argumenta por qué su API es sólida: ¿puede algún uso seguro provocar UB? - Instala
Miriy ejecútalo sobre el ejemplo del puntero colgante. Compara su salida con la de uncargo runnormal en release. - Escribe una prueba que ejercite una ruta con
unsafey córrela bajocargo +nightly miri test. Explica por quéMirino puede garantizar la ausencia de UB en rutas que tus pruebas no tocan.