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

Qué es unsafe y por qué existe

Rust seguro es un verificador conservador: solo acepta lo que puede demostrar. Existen programas seguros que no sabe probar, y para ese margen existe unsafe. No apaga comprobaciones: te deja hacer cosas cuya seguridad tú garantizas, trasladando la carga de la prueba de la máquina a ti.

⏱ 18 min

El compilador de Rust es, en el fondo, un verificador estático: no ejecuta tu programa, sino que intenta demostrar que jamás incurrirá en comportamiento indefinido. Y como todo verificador honesto, cuando no logra la prueba, rechaza. Esto lo hace conservador: existen programas perfectamente seguros que el borrow checker no sabe demostrar y por tanto prohíbe. unsafe es la puerta que Rust reserva para ese margen exacto: el conjunto de operaciones cuya seguridad puedes garantizar pero la máquina no. No es un interruptor que apaga la seguridad ni un permiso para “hacer C dentro de Rust”. Es un desplazamiento de la carga de la prueba: donde antes el compilador firmaba la corrección, ahora firmas tú. Entender esa transferencia de responsabilidad —qué obligas al contraerla, qué saldas al ejercerla— es entender por qué Rust necesita, en su mismísimo núcleo, una palabra que parece contradecir todo lo que promete.

🎯 Al terminar esta lección sabrás
  • Comprender por qué el Rust seguro es conservador: rechaza programas seguros que no puede probar.
  • Distinguir las dos caras de unsafe: la unsafe fn que contrae una obligación y el bloque unsafe que la salda.
  • Ver que toda la biblioteca estándar —Vec, Box, Arc— se construye sobre unsafe encapsulado.
  • Situar la edición 2024 y su exigencia de bloques unsafe explícitos dentro de una unsafe fn.

El verificador es conservador por diseño

Recupera una intuición del borrow checker: acepta un programa solo si puede demostrar que respeta las reglas de aliasing y de vidas. Pero demostrar es más difícil que ser verdad. Hay código cuya seguridad es evidente para un humano y aun así el análisis, por grueso, no la captura. El caso canónico es partir un slice mutable en dos mitades disjuntas:

fn partir_en_dos(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    assert!(mid <= len);
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Las dos mitades no se solapan: es provablemente seguro tener un &mut a cada una. Pero el borrow checker solo ve dos préstamos mutables derivados del mismo slice y, fiel a su regla de aliasing exclusivo, los rechazaría. La verdad excede a la prueba. unsafe es el mecanismo con el que le dices al compilador: “aquí hay una garantía que yo puedo sostener y tú no sabes verificar; déjame ejercerla”. Por eso split_at_mut, la versión real de esta función en std, está construida exactamente así.

Fíjate en lo que no está pasando: no estás engañando al compilador ni desactivando nada. Estás usando unsafe para afirmar un hecho —que estas dos mitades son disjuntas— que es verdadero y que el análisis, por su granularidad, no sabe deducir. La assert! de la primera línea no es decorativa: convierte tu afirmación en algo que se comprueba en ejecución, de modo que si alguien llama con un mid imposible, el programa aborta limpiamente en vez de fabricar dos slices solapados. Esa combinación —una verdad que tú conoces más una comprobación que la respalda— es el molde de casi todo unsafe bien escrito.

El slice partido no es un caso aislado. La misma tensión reaparece en una lista doblemente enlazada —donde cada nodo apunta a su vecino y el vecino de vuelta a él, un aliasing que el modelo de propiedad no expresa—, en un grafo con ciclos, o en una arena de nodos. Todas son estructuras seguras y útiles que el borrow checker, por su naturaleza conservadora, no sabe aprobar. unsafe existe para que Rust no tenga que renunciar a ellas ni empujarte de vuelta a un lenguaje sin garantías.

ℹ️
La brecha del verificador no es un defecto, es una elección

Todo verificador estático vive atrapado entre dos fracasos: aceptar un programa peligroso —inadmisible— o rechazar uno seguro —irritante—. Rust jamás cede en el primero, así que toda la fricción cae en el segundo. unsafe no elimina la brecha; te da una salida controlada para los casos concretos en que sabes que tu programa cae del lado seguro aunque la prueba automática no llegue.

Las dos caras: contraer una deuda y saldarla

La palabra unsafe aparece en dos posiciones que la gente confunde, y son casi opuestas.

Una unsafe fn declara una obligación: anuncia que la función tiene un contrato que quien la llame debe cumplir, o habrá comportamiento indefinido. Es una deuda que la firma contrae y traslada a la persona que llama.

/// # Seguridad
/// `idx` debe ser estrictamente menor que `self.len()`.
/// Llamarla fuera de rango es comportamiento indefinido.
unsafe fn obtener_sin_comprobar(&self, idx: usize) -> &T {
    // ...
}

Un bloque unsafe hace lo contrario: salda una obligación. Es tu afirmación —“he verificado a mano que las condiciones se cumplen aquí”— que autoriza a ejercer un superpoder o a llamar a una unsafe fn. La deuda que la unsafe fn contrajo, el bloque unsafe la paga en el punto de uso.

let x = coleccion.obtener_sin_comprobar(3);      // no compila: deuda impaga
let y = unsafe { coleccion.obtener_sin_comprobar(3) }; // saldada: yo garantizo 3 < len
🧾

unsafe fn · contraer

La palabra en la firma declara una deuda: quien llame debe cumplir un contrato o habrá comportamiento indefinido. La responsabilidad viaja hacia quien usa.

✒️

bloque unsafe · saldar

La palabra en el cuerpo paga la deuda: afirmas que aquí, en este punto exacto, las condiciones del contrato se cumplen. La responsabilidad la asumes tú.

⚠️
Edición 2024: dentro de una unsafe fn ya no basta el título

Antes, el cuerpo entero de una unsafe fn era implícitamente un bloque unsafe. Desde la edición 2024 el lint unsafe_op_in_unsafe_fn es aviso por defecto: dentro de una unsafe fn sigues necesitando bloques unsafe explícitos para ejercer superpoderes. La razón es profunda: contraer una obligación en la firma y saldar otras al usar operaciones internas son actos distintos, y mezclarlos ocultaba en qué línea exacta descansa cada garantía.

Toda la seguridad descansa sobre unsafe

Aquí está la paradoja fundacional. Vec, Box, Rc, Arc, RefCell, Mutex: cada abstracción segura que usas a diario está implementada con unsafe por dentro. Vec maneja un puntero crudo, memoria sin inicializar y llamadas al asignador; nada de eso lo puede expresar el Rust seguro. Lo que hace Vec es encapsular ese unsafe tras una API cuya propiedad clave es la solidez (soundness): ningún uso desde código seguro, por retorcido que sea, puede provocar comportamiento indefinido.

flowchart TD
A[Codigo seguro del usuario] -->|solo llama a la API| B[Frontera segura y solida]
B --> C[Interior con unsafe encapsulado]
C --> D[Punteros crudos, memoria sin inicializar, asignador]
E[El autor garantiza las invariantes] --> C
style A fill:#a6e3a1,color:#11111b
style B fill:#89b4fa,color:#11111b
style C fill:#f38ba8,color:#11111b
style E fill:#cba6f7,color:#11111b

La palabra clave es encapsular. Vec no te expone su puntero crudo ni te pide que mantengas sus invariantes: te da push, get, len, y por dentro traduce cada uno a operaciones sobre memoria cruda que él, y solo él, sabe mantener coherentes. Tú heredas la seguridad sin heredar la responsabilidad. Ese trato es lo que hace posible que millones de programas Rust jamás escriban unsafe y aun así se apoyen, en cada Vec que usan, sobre un núcleo que sí lo hace.

Esa es la división del trabajo de todo Rust idiomático: un núcleo pequeño de unsafe, auditado y encapsulado, sobre el que se levanta un océano de código seguro que hereda sus garantías gratis. unsafe no es lo contrario de la seguridad de Rust; es su cimiento.

Seguro, sólido y correcto son tres cosas distintas

El principiante funde en una sola idea tres adjetivos que conviene separar quirúrgicamente. Un fragmento es seguro si no ejerce ningún superpoder: el compilador garantiza por sí solo que no puede provocar comportamiento indefinido. Una abstracción es sólida (sound) si ninguna combinación de usos desde código seguro, por retorcida que sea, logra arrancarle un UB. Y un programa es correcto si hace lo que promete. Los tres son independientes entre sí:

fn suma(a: i32, b: i32) -> i32 {
    a - b   // SEGURA pero INCORRECTA: ningun compilador te salva de esto
}

Esta resta disfrazada de suma es perfectamente segura —no hay UB posible— y a la vez desastrosamente incorrecta. La lección es que “seguro” en Rust no significa “sin errores”: significa “sin comportamiento indefinido”, una frontera mucho más estrecha y mucho más grave. Por eso unsafe no habla jamás de corrección lógica —de eso se ocupan los tests—, sino de esa línea fina donde el programa deja de tener significado definido. Cuando construyes una abstracción con unsafe, tu deber no es que sea correcta, sino que sea sólida: que ni el usuario más retorcido pueda, sin teclear unsafe, empujarla al comportamiento indefinido.

💡
El comentario SAFETY es parte del código, no un adorno

La convención universal en Rust es preceder cada bloque unsafe con un comentario // SAFETY: que nombra la invariante concreta que ese bloque presupone y por qué se cumple aquí. No es decoración: es la prueba, escrita en prosa, que el compilador te delegó. Un bloque unsafe sin // SAFETY: es una firma sin contrato adjunto —nadie, ni tú mismo dentro de un mes, podrá revisar si la promesa se sostiene—. Volverás sobre esta disciplina en la lección de invariantes.

unsafe no apaga la seguridad: reubica la prueba de la máquina al humano

La lectura ingenua de unsafe —“el modo en que Rust deja de comprobar y te fías”— es exactamente la equivocación que envenena el diseño de quien la sostiene. Rust nunca deja de exigir seguridad; lo que cambia dentro de unsafe es quién produce la demostración. Fuera, el compilador es un asistente de pruebas que descarga cada obligación automáticamente y solo acepta lo que sabe probar. Dentro, ese asistente se topa con operaciones cuya corrección no puede derivar —desreferenciar un puntero crudo, confiar en que dos mitades no se solapan— y hace lo único honesto que puede hacer: te pasa el bolígrafo. “Yo no sé demostrar esto; fírmalo tú.” La invariante que sostiene la seguridad no desaparece —estaba ahí desde el principio, sujetando el lenguaje entero—; simplemente pasas a ser tú el responsable legal de mantenerla. Por eso unsafe es a la vez humilde y temible. Humilde, porque reconoce la brecha inevitable de todo verificador estático: siempre habrá programas seguros que la máquina no sabe probar, y negarse a ejecutarlos jamás significaría amputar la interoperación con C, las estructuras de datos que el borrow checker no expresa y el último uno por ciento de rendimiento medido. Temible, porque una firma falsa no la atrapa ninguna excepción: el optimizador tratará tu promesa como verdad de base y compilará sobre ella, de modo que si mentiste, no “gestiona” la mentira, construye encima. La grandeza de la palabra es que hace visible y localizada esa transferencia de confianza: en vez de diluir el peligro por todo el programa como en C, lo concentra en bloques marcados, pequeños y auditables, donde un revisor sabe exactamente dónde mirar. unsafe es el punto en que Rust deja de ser un lenguaje que te protege y pasa a ser uno que te cree. Toda la disciplina que sigue —minimizar la superficie, encapsular tras APIs sólidas, comentar cada invariante— existe para ser digno de esa credulidad.

📝
Lo esencial de qué es unsafe

El Rust seguro es un verificador conservador: solo acepta programas cuya seguridad puede demostrar, y rechaza algunos que son seguros pero no probables. unsafe cubre ese margen desplazando la carga de la prueba del compilador a ti. Tiene dos caras: la unsafe fn contrae una obligación que traslada a quien llama; el bloque unsafe la salda afirmando que las condiciones se cumplen. La edición 2024 exige bloques unsafe explícitos incluso dentro de una unsafe fn. Toda la biblioteca estándar se construye sobre unsafe encapsulado tras APIs sólidas: no es lo opuesto a la seguridad, es su cimiento.

⚔️ Localiza la brecha del verificador
  1. Escribe tu propia partir_en_dos y confirma que la versión sin unsafe —devolver dos &mut a mitades del mismo slice— es rechazada por el borrow checker. Explica qué regla cree violar.
  2. Declara una unsafe fn obtener_sin_comprobar con su comentario # Seguridad. Llámala sin bloque y con bloque; describe qué obligación contrae la firma y cuál saldas tú.
  3. En una unsafe fn de la edición 2024, ejerce un superpoder sin bloque unsafe interno y observa el aviso de unsafe_op_in_unsafe_fn. Razona por qué la edición separa contraer de saldar.
  4. Enumera tres tipos de std implementados con unsafe interno y explica qué significa que su API pública sea sólida.
  5. Argumenta por qué un lenguaje que se niega a incluir unsafe sacrificaría capacidades reales, no solo peligro.