Ir al contenido

Unsafe en Rust

Todas las garantías de las que ha hablado este curso (nada de referencias colgantes, nada de data races, nada de accesos fuera de límites) las hace cumplir el compilador. unsafe es la forma de decirle al compilador: “déjame hacer algo que tú no puedes comprobar que sea seguro; yo me hago responsable”.

Un malentendido muy común es pensar que unsafe apaga por completo las comprobaciones de seguridad de Rust. No es así. Dentro de un bloque unsafe puedes hacer exactamente cinco cosas extra que, si no, están prohibidas:

  1. Desreferenciar un puntero crudo (raw pointer)
  2. Llamar a una función unsafe
  3. Leer o modificar una variable static mutable
  4. Implementar un trait unsafe
  5. Acceder a los campos de una union

Todo lo demás (las reglas de borrowing, la semántica de move, la comprobación de tipos) sigue aplicándose por completo dentro del código unsafe. Es un bisturí para cinco operaciones concretas, no un interruptor de apagado.

Las referencias normales de Rust (& y &mut) siempre apuntan a datos válidos y bien alineados: eso forma parte de lo que garantiza el compilador. Un puntero crudo (*const T o *mut T) renuncia a esa garantía, y por eso leerlo requiere unsafe:

fn main() {
let value = 5;
let pointer = &value as *const i32;
unsafe {
println!("{}", *pointer);
}
}

Salida:

5
▶ Pruébalo tú mismo

Puedes crear un puntero crudo en código seguro. Solo desreferenciarlo (leer el valor al que apunta) necesita unsafe, porque el compilador ya no puede comprobar que el puntero sea válido.

Algunas funciones están marcadas como unsafe porque tienen requisitos que el compilador no puede comprobar por ti. Al llamarlas, prometes que tú mismo verificaste esos requisitos:

unsafe fn dangerous() {
println!("esta función confía en que comprobaste sus requisitos");
}
fn main() {
unsafe {
dangerous();
}
}

Salida:

esta función confía en que comprobaste sus requisitos
▶ Pruébalo tú mismo

La biblioteca estándar tiene funciones unsafe reales que puedes llamar. Una de ellas es get_unchecked, de los slices. Funciona como numbers[i], pero se salta la comprobación de límites. Si i está fuera de rango, no provoca un panic: lee la memoria que haya en ese lugar, lo que se llama comportamiento indefinido (undefined behavior). Tu programa podría fallar, imprimir basura o parecer que funciona bien hasta que deja de hacerlo.

Así que la regla para llamarla es “solo con un índice que sabes que es válido”. El patrón habitual es comprobar esa regla tú mismo, dentro de una función normal y segura:

fn first_or_zero(numbers: &[i32]) -> i32 {
if numbers.is_empty() {
return 0;
}
// SAFETY: el slice no está vacío, así que el índice 0 está dentro de los límites.
unsafe { *numbers.get_unchecked(0) }
}
fn main() {
println!("{}", first_or_zero(&[7, 8, 9]));
println!("{}", first_or_zero(&[]));
}

Salida:

7
0
▶ Pruébalo tú mismo

Fíjate en lo que ve quien la llama: fn first_or_zero(numbers: &[i32]) -> i32. No hay unsafe en la firma, ni forma de llamarla mal, porque la función comprueba la regla antes de llegar siquiera al bloque unsafe. El unsafe se queda pequeño y escondido dentro.

Dos costumbres que siguen los desarrolladores de Rust con experiencia:

  • Escribe un comentario // SAFETY: encima de cada bloque unsafe, explicando por qué se cumplen las reglas. Es para la próxima persona que lea el código, y para tu yo del futuro. La etiqueta SAFETY: se deja en inglés porque es la convención que buscan las herramientas, como Clippy.
  • Haz el bloque unsafe lo más pequeño posible, para que haya menos código que revisar dos veces.

Siendo sinceros, no escribirías esta función exacta en código real: numbers.first() hace lo mismo de forma segura, y el compilador es muy bueno eliminando las comprobaciones de límites que puede demostrar que no hacen falta. Pero el patrón (comprobar las reglas y luego llamar a unsafe dentro de una función segura) es exactamente como se construyen Vec, String y compañía. La página de FFI usa el mismo truco para envolver funciones de C.

Si Rust gira en torno a la seguridad, ¿por qué permite esto? Porque hay cosas que de verdad es imposible demostrar que son seguras al compilar, aunque en la práctica lo sean: hablar directamente con el hardware, llamar a una biblioteca de C (consulta FFI) o construir ciertas estructuras de datos de alto rendimiento. unsafe permite que la biblioteca estándar (y, en raras ocasiones, tu propio código) implemente esas piezas de bajo nivel, mientras que todo lo que se construye encima sigue siendo Rust seguro normal, comprobado por completo.

Escribir unsafe no significa que tu código ahora tenga errores. Significa que, en ese bloque concreto, el compilador ya no puede detectar por ti ciertos tipos de errores, y te toca a ti mantener las mismas garantías a mano. La mayoría de los desarrolladores de Rust con experiencia escriben muy poco código unsafe directamente; suele vivir en rincones pequeños y revisados con mucho cuidado, en lo más profundo de una biblioteca, envuelto en una API segura para que la use todo el mundo.

1. Cambia un valor a través de un puntero crudo

Sección titulada «1. Cambia un valor a través de un puntero crudo»

Crea let mut score = 10;, crea un puntero crudo *mut i32 hacia él y usa el puntero para sumarle 5. Después imprime score. Pista: crea el puntero con &mut score as *mut i32 y escribe *pointer += 5 dentro de un bloque unsafe.

Ver solución
fn main() {
let mut score = 10;
let pointer = &mut score as *mut i32; // crearlo es seguro
unsafe {
*pointer += 5; // escribir a través de él no lo es
}
println!("puntuación = {score}");
}

Salida:

puntuación = 15
▶ Pruébalo tú mismo

En la práctica esto es seguro porque score sigue viva y nada más la está usando mientras escribimos. Cumplir esas promesas es tu trabajo en cuanto usas unsafe.

Escribe fn get_or(numbers: &[i32], index: usize, default: i32) -> i32. Devuelve el elemento en index, o default si el índice está fuera de rango. Usa get_unchecked para la lectura y asegúrate de que la función nunca se pueda llamar de una forma que rompa su regla. Pista: compara primero index < numbers.len() y llama a get_unchecked solo cuando sea cierto.

Ver solución
fn get_or(numbers: &[i32], index: usize, default: i32) -> i32 {
if index < numbers.len() {
// SAFETY: acabamos de comprobar que index está dentro de los límites.
unsafe { *numbers.get_unchecked(index) }
} else {
default
}
}
fn main() {
let numbers = [10, 20, 30];
println!("{}", get_or(&numbers, 1, -1));
println!("{}", get_or(&numbers, 5, -1));
}

Salida:

20
-1
▶ Pruébalo tú mismo

El índice 5 nunca llega al bloque unsafe, así que se maneja de forma segura. Como comparación, la versión totalmente segura es numbers.get(index).copied().unwrap_or(default), y es lo que escribirías normalmente.

  • unsafe permite exactamente cinco operaciones (sobre todo, punteros crudos y llamadas a funciones unsafe); todo lo demás se sigue comprobando por completo.
  • Un puntero crudo (*const T / *mut T) se puede crear en código seguro; desreferenciarlo requiere unsafe.
  • Envuelve unsafe en una función pequeña y segura que compruebe primero las reglas, y explica por qué es correcto en un comentario // SAFETY:.
  • unsafe existe para poder construir código de bajo nivel (acceso al hardware, interoperar con C), mientras el código que lo usa sigue siendo seguro.
  • Si estás empezando, rara vez tendrás que escribirlo: busca primero una abstracción segura.

Comprueba lo aprendido

1. ¿Qué permite realmente un bloque unsafe?

2. ¿Puedes crear un puntero crudo en código seguro?

3. Si estás empezando, ¿con qué frecuencia deberías esperar escribir unsafe tú mismo?

4. ¿Escribir código unsafe significa que tu programa ahora tiene errores?

Puntuación: 0 / 4