wandres.dev
UNSAFE · cuándo y por qué

Invariantes y la responsabilidad del bloque unsafe

Dentro de unsafe, tú mantienes las invariantes que el compilador ya no comprueba: punteros válidos y alineados, unicidad de las referencias mutables, valores bien formados. Romper una no es un bug cualquiera: es comportamiento indefinido real, y el optimizador razona desde la premisa de que jamás ocurre. Aquí entra el comentario SAFETY.

⏱ 19 min

Cuando abres un bloque unsafe, el compilador deja de comprobar un puñado de invariantes, pero esas invariantes no dejan de existir: solo cambian de guardián. Pasas a ser tú quien garantiza que cada puntero apunta a memoria válida y alineada, que ninguna referencia mutable comparte su objeto con otra, que ningún valor lleva bits que su tipo prohíbe. Y si fallas, no obtienes una excepción ni un panic diagnosticable: obtienes comportamiento indefinidoUndefined Behavior, UB—, la única categoría de fallo que toda la arquitectura de Rust existe para abolir. El UB no es “quizá peta”: es peor, porque el optimizador razona desde la premisa de que jamás ocurre y compila tu programa a partir de esa mentira. Un solo unsafe incorrecto en un millón de líneas puede envenenar el binario entero. Por eso el oficio del unsafe gira en torno a un hábito aparentemente burocrático y en realidad central: nombrar, en cada punto, la invariante que estás sosteniendo.

🎯 Al terminar esta lección sabrás
  • Enumerar las invariantes que el compilador deja de comprobar dentro de unsafe: validez de punteros, unicidad de &mut, valores bien formados.
  • Entender el comportamiento indefinido como premisa del optimizador, no como “a veces falla”.
  • Distinguir una abstracción sólida de una no sólida según exista o no una entrada segura que provoque UB.
  • Adoptar el comentario // SAFETY: como la práctica que ata cada bloque a la invariante que lo justifica.

Qué invariantes pasan a tu cargo

El compilador deja de vigilar, entre otras, estas condiciones —y ahora las firmas tú:

📍

Validez del puntero

Todo puntero que desreferencias debe apuntar a memoria viva, correctamente alineada para su tipo y no liberada. Un *mut T a un valor ya destruido es UB al leerlo.

🔒

Unicidad de &mut

Mientras exista un &mut T, ninguna otra referencia puede leer ni escribir ese dato. Fabricar dos &mut solapados desde punteros crudos es UB aunque compile.

Valores bien formados

Un bool solo puede valer 0 o 1; una referencia nunca es nula; un char es un escalar Unicode válido. Producir un valor prohibido —vía transmute— es UB inmediato.

🧬

Procedencia e init

El puntero debe derivar del objeto correcto (provenance) y la memoria estar inicializada antes de leerse. Leer memoria sin inicializar es UB.

Ninguna de estas condiciones es nueva: son las mismas que el borrow checker, el sistema de tipos y el inicializador imponían por ti fuera de unsafe. Lo que cambia no es la regla, sino quién responde de ella.

Conviene además separar dos niveles de invariante que la tradición de Rust distingue con cuidado. La invariante de validez debe cumplirse en todo instante o hay UB inmediato: un &T no puede ser nulo, ni estar desalineado, ni un microsegundo. La invariante de seguridad es más laxa: puedes romperla temporalmente dentro de un bloque unsafe mientras reconstruyes una estructura, con tal de restaurarla antes de devolver el control a código seguro. Un Vec a medio redimensionar viola su invariante de seguridad por un instante, jamás la de validez. Saber en cuál de los dos niveles operas te dice cuánto margen tienes antes de cruzar la línea sin retorno.

Estas invariantes comparten un rasgo incómodo: en su mayoría son no locales. Que un puntero sea válido depende de que su asignación siga viva, lo cual depende de código que quizá está en otra función. Que un &mut sea único depende de que nadie más, en ningún punto alcanzable del programa, sostenga otra referencia al mismo dato. Verificarlas a mano exige razonar sobre el programa entero, no sobre la línea que tienes delante, y ahí reside la verdadera dificultad del unsafe: no en teclear la palabra, sino en cargar con una obligación cuyo alcance excede el bloque.

Comportamiento indefinido: la premisa, no el síntoma

El error fatal es pensar el UB como “un fallo que puede o no manifestarse”. El UB es una premisa lógica que el compilador toma como axioma. La especificación dice: “estas cosas nunca ocurren”; el optimizador lo cree literalmente y transforma tu código apoyándose en esa certeza. Si tú provocas una de esas cosas, no has causado “un fallo”: has hecho falsa una premisa sobre la que se construyó todo el razonamiento, y de una premisa falsa se deduce cualquier cosa.

let x: u8 = 3;
let b: bool = unsafe { std::mem::transmute(x) }; // UB: 3 no es un bool valido
if b { /* ... */ } else { /* ... */ }            // el compilador puede tomar
                                                  // cualquier rama, o ninguna

Aquí b contiene el patrón de bits 3, que ningún bool legítimo tiene. El compilador, que sabe por especificación que todo bool vale 0 o 1, puede haber generado código que asume exactamente eso: podría saltar a una rama imposible, indexar una tabla fuera de rango, o borrar el if entero. No hay “resultado incorrecto pero acotado”; hay ausencia total de significado. Esa es la diferencia abismal entre un panic —parada definida y diagnosticable— y el UB —terreno sin mapa.

Por eso los lenguajes que admiten UB ponen tanto peso en no cometerlo jamás: no existe un manejo posterior, no hay catch, no hay recuperación. La única defensa es la prevención, y la prevención vive en las invariantes que ahora sostienes. Un Result te deja reaccionar a un fallo; un UB no te deja reaccionar a nada, porque para cuando se manifiesta, el significado del programa ya se evaporó y el binario que ejecutas puede no corresponder a ninguna lectura sensata de tu código fuente.

🛑
El UB viaja hacia atrás y hacia los lados en el tiempo

Una intuición peligrosa es creer que el UB solo afecta a la línea que lo comete. No: como el optimizador reordena y fusiona código apoyándose en la premisa de que el UB no existe, sus efectos pueden aparecer antes de la línea culpable, en otra función, o como la desaparición de una comprobación que creías a salvo. Un UB latente que “funcionó en mis pruebas” puede detonar al cambiar de compilador, de nivel de optimización o de plataforma. No hay UB benigno; solo hay UB que todavía no te ha mordido.

Un segundo ejemplo, más cotidiano que el transmute, es la referencia colgante fabricada con punteros crudos:

fn colgante() -> i32 {
    let p: *const i32;
    {
        let x = 10;
        p = &x;         // p apunta a x
    }                    // x muere aqui: p queda colgante
    unsafe { *p }        // UB: leer memoria de un valor que ya no existe
}

El borrow checker habría rechazado este patrón con referencias normales; al bajar a un puntero crudo lo permitiste, y con ello asumiste la invariante de validez que el compilador sostenía. Leer *p tras la muerte de x no “a veces da basura”: es UB, y el optimizador puede compilar colgante de maneras que ni siquiera devuelven un entero coherente.

Solidez: el criterio que decide si tu unsafe es correcto

La pregunta correcta ante una abstracción con unsafe no es “¿mi ejemplo funciona?”, sino “¿existe alguna entrada segura que provoque UB?”. Si existe una sola, aunque tú nunca la uses, la abstracción es no sólida (unsound) y está rota. Mira el papel de un assert:

pub struct Bolsa {
    ptr: *mut u8,
    len: usize,
}

impl Bolsa {
    pub fn obtener(&self, i: usize) -> u8 {
        assert!(i < self.len);              // este assert es lo que la hace SOLIDA
        // SAFETY: i < len garantiza que ptr.add(i) cae dentro del bloque vivo.
        unsafe { *self.ptr.add(i) }
    }
}

El assert parece un peaje molesto, pero es la pieza que sostiene la solidez. obtener es una función segura: cualquiera puede llamarla con cualquier i sin escribir unsafe. Si borras el assert, entonces bolsa.obtener(9999) —una llamada perfectamente segura desde la perspectiva del que la usa— desreferencia un puntero fuera de rango y comete UB. La abstracción se vuelve no sólida no porque la uses mal, sino porque permite usarla mal desde código seguro. El comentario // SAFETY: documenta la invariante exacta que el bloque presupone; el assert la hace cierta.

Procedencia: el puntero recuerda de dónde viene

Hay una invariante aún más sutil que gobierna los punteros crudos: la procedencia (provenance). En el modelo abstracto de Rust un puntero no es un mero número: lleva asociado el permiso de acceder a una asignación concreta. Dos punteros con idéntica dirección numérica pueden tener procedencias distintas, y usar uno para tocar la memoria del otro es UB aunque el entero coincida. Por eso pasear un puntero fuera de su asignación con aritmética y luego desreferenciarlo es indefinido, por más que la dirección resultante “exista” en el mapa de memoria del proceso:

let a = [10u8, 20, 30];
let p = a.as_ptr();
let q = p.wrapping_add(10);   // direccion fuera de la asignacion de `a`
// Desreferenciar q es UB: su procedencia es la de `a`, y ahi no cae dentro.

La edición 2024 consolidó una API de procedencia estricta.addr(), .with_addr(), ptr::without_provenance— que separa la dirección numérica del permiso de acceso, para escribir código de punteros correcto por construcción en vez de por casualidad. Y por debajo, qué secuencias exactas de accesos son legales lo fijan modelos operativos experimentales: Stacked Borrows y su sucesor Tree Borrows. No hace falta dominarlos para escribir unsafe sólido, pero sí saber que existen, porque son precisamente las reglas que Miri aplicará para cazarte una violación de aliasing que tus ojos no vieron.

flowchart LR
A[Asignacion del array a] --> P[Puntero p con procedencia de a]
P --> V[Derivado dentro de a: desreferenciar es valido]
P --> U[Derivado fuera de a: desreferenciar es UB]
style A fill:#89b4fa,color:#11111b
style V fill:#a6e3a1,color:#11111b
style U fill:#f38ba8,color:#11111b
⚠️
No hay UB benigno: el que hoy funciona es el que mañana traiciona

El error más caro con las invariantes es concluir, tras ver que un programa con UB “funciona”, que el UB era inofensivo. No lo era: simplemente el optimizador de esa versión, en ese nivel, en esa plataforma eligió una interpretación que no te hizo daño. Cambia el compilador, sube el nivel de optimización o migra de arquitectura, y la misma línea puede detonar. Un UB latente no es un problema resuelto: es una deuda con interés, y el cobro llega en el peor momento posible.

El bloque unsafe es un contrato, y el optimizador cobra al contado toda mentira

Fuera de unsafe, el compilador es un asistente de pruebas que descarga cada obligación de seguridad por ti, en silencio, millones de veces. Dentro, se topa con afirmaciones que no sabe demostrar y te tiende el bolígrafo: “esto no puedo probarlo; firma tú que es cierto”. Y aquí está la asimetría que hay que entender hasta los huesos: el optimizador cree tu firma sin reservas. No la audita, no la verifica en ejecución, no añade una red por si mentiste. Trata tu promesa como verdad de base y construye sobre ella con toda su artillería: elimina comprobaciones que tu invariante vuelve redundantes, reordena accesos que tu unicidad garantiza independientes, asume que todo bool vale 0 o 1 porque tú juraste no fabricar uno inválido. Si la firma era honesta, el resultado es código impecable y veloz. Si mentiste, el optimizador no “gestiona” la mentira ni la detecta: deduce a partir de ella, y de una premisa falsa se sigue cualquier cosa —una rama imposible, un salto a la nada, la corrupción silenciosa de una estructura tres funciones más allá—. Por eso el UB no es un bug ordinario que rompe una línea: es una grieta en los cimientos lógicos sobre los que se compiló todo el binario, y puede manifestarse lejísimos, en el tiempo y en el espacio, de donde lo cometiste. Las invariantes que ahora sostienes —puntero válido, &mut único, valor bien formado— no son tecnicismos: son precisamente las promesas que el lenguaje entero da por ciertas para poder razonar. Sostenerlas a mano es el oficio, y el comentario // SAFETY: no es papeleo: es la disciplina de nombrar, en cada punto de firma, exactamente qué juras y por qué es cierto, para que tú al releerlo y otro al revisarlo puedan verificar la promesa en vez de confiar a ciegas. La madurez con unsafe empieza el día en que dejas de preguntarte “¿funciona mi ejemplo?” y empiezas a preguntarte “¿podría alguien romper esto desde código seguro?”. Esa segunda pregunta es la solidez, y la solidez es lo único que separa una abstracción unsafe correcta de una bomba con temporizador.

📝
Lo esencial de las invariantes

Dentro de unsafe el compilador deja de comprobar ciertas invariantes, pero no desaparecen: las firmas tú. Punteros válidos y alineados, unicidad de cada &mut, valores bien formados, memoria inicializada y procedencia correcta. Romper una es comportamiento indefinido: no “a veces falla”, sino una premisa falsa desde la que el optimizador deduce cualquier cosa, con efectos deslocalizados en tiempo y espacio. El criterio de corrección es la solidez: una abstracción es no sólida si existe alguna entrada segura que provoque UB, aunque nadie la use así. El assert que valida la precondición y el comentario // SAFETY: que nombra la invariante son las herramientas del oficio.

⚔️ Firma solo lo que puedas sostener
  1. Escribe la Bolsa con y sin el assert. Argumenta por qué la versión sin assert es no sólida aunque tus pruebas pasen.
  2. Usa transmute para crear un bool a partir de 2u8 y describe por qué es UB y por qué el resultado no está simplemente “mal”, sino indefinido.
  3. Para tres bloques unsafe de tu invención, redacta el comentario // SAFETY: que nombra la invariante concreta que cada uno presupone.
  4. Explica, con el ejemplo del bool inválido, cómo el UB puede manifestarse en una línea distinta de la que lo comete.
  5. Define solidez con tus palabras y aplícala: ¿es sólida una API que solo comete UB cuando quien la llama pasa un índice fuera de rango? Justifícalo.