wandres.dev
ABSTRACCIONES SEGURAS · envolver unsafe

El patrón central: un núcleo unsafe tras una API 100% segura

El pilar de la programación de sistemas en Rust: encerrar un núcleo `unsafe` diminuto y auditado tras una API completamente segura cuyas invariantes garantizas tú. Así el usuario nunca escribe `unsafe` y el compilador demuestra el resto. `Vec`, `String` y `split_at_mut` no son magia: son este patrón aplicado con disciplina.

⏱ 22 min

Casi todo std está construido sobre unsafe. Vec, String, Rc, Mutex, HashMap: por dentro, todos manipulan punteros crudos, memoria sin inicializar y llamadas que el compilador no puede verificar. Y sin embargo tú los usas miles de veces sin escribir una sola línea de unsafe. Eso no es casualidad: es el patrón central de la programación de sistemas en Rust. Un núcleo unsafe, diminuto y auditado, se encierra tras una API completamente segura cuyas invariantes garantiza el autor. El compilador verifica al usuario; el autor verifica el núcleo; entre ambos, una frontera de confianza. Dominar Rust de sistemas es dominar esa frontera.

🎯 Al terminar esta lección sabrás
  • Entender qué autoriza unsafe y qué no desactiva del compilador.
  • Definir soundness: que ningún uso seguro pueda provocar comportamiento indefinido.
  • Reconocer la anatomía del patrón: núcleo mínimo, invariantes, frontera de privacidad.
  • Ver por qué Vec y split_at_mut son este patrón, no una excepción.

Qué es y qué no es unsafe

Primero, demoler el malentendido. unsafe no apaga el borrow checker, no desactiva el sistema de tipos, no vuelve tu código “peligroso” por decreto. Lo único que hace es concederte cinco superpoderes que el resto del lenguaje te niega porque no puede verificar su corrección:

  • Desreferenciar un puntero crudo (*const T, *mut T).
  • Llamar a una función o método unsafe.
  • Acceder o mutar una static mut.
  • Implementar un trait unsafe, como Send o Sync.
  • Leer los campos de una union.

Nada más. La aritmética sigue comprobada, los tipos siguen siendo tipos, el borrow checker sigue vigilando las referencias normales. Y unsafe tiene dos caras gramaticales que conviene no confundir. Una unsafe fn o un unsafe trait exigen: declaran que hay condiciones que el llamador debe cumplir y que el compilador no comprobará. Un bloque unsafe { ... } salda: afirma “yo, aquí, he verificado a mano que esas condiciones se cumplen”. Exigir y saldar: toda la disciplina del unsafe vive en esa asimetría.

let mut v = vec![1, 2, 3];
let p: *mut i32 = v.as_mut_ptr();
// Crear el puntero crudo es seguro; desreferenciarlo, no:
unsafe {
    // SAFETY: p viene de as_mut_ptr, apunta al primer i32, vivo y alineado
    *p = 10;
}
⚠️
unsafe no silencia el borrow checker

El malentendido más caro del principiante es creer que unsafe es una válvula para acallar al borrow checker cuando protesta. No lo es. Dentro de un bloque unsafe, las referencias &T y &mut T siguen sujetas a las mismas reglas de aliasing y de lifetimes; lo único que ganas son los cinco superpoderes de arriba. Si el borrow checker rechaza tu código, unsafe no lo va a aprobar: tendrías que reformularlo con punteros crudos —que sí escapan a su control— y entonces cargar tú con la prueba que él te daba gratis. unsafe no apaga la vigilancia; abre una puerta lateral cuya seguridad debes garantizar a mano, y por eso pasar por ella sin necesidad es un mal negocio.

Soundness: el único pecado es dejar que el usuario rompa algo

ℹ️
La palabra clave es soundness, no seguridad

Una API es sound (sólida) cuando no existe ningún programa seguro que, usándola, pueda provocar comportamiento indefinido. Es unsound cuando existe aunque sea uno. Fíjate en el cuantificador: no basta con que tus ejemplos funcionen; debe ser imposible romperla desde código seguro, incluso con la combinación más retorcida de llamadas. La unsoundness es el pecado mortal de Rust: un unwrap que revienta es un bug corriente; una API unsound es una traición al contrato del lenguaje, porque promete una seguridad que no cumple.

El objetivo del patrón se formula entonces con precisión quirúrgica: quiero ofrecer una funcionalidad que por dentro necesita unsafe, pero cuya superficie pública sea sound. El usuario podrá llamar a mis métodos en cualquier orden, con cualquier argumento que el tipo admita, y jamás disparará comportamiento indefinido. Si lo consigo, he trasladado toda la carga de la prueba de sus manos a las mías, y la he confinado a un puñado de líneas auditables.

La anatomía: núcleo, invariantes, frontera

🔒

Núcleo unsafe mínimo

El código que de verdad toca punteros o memoria cruda. Cuanto más pequeño, menos superficie que auditar. Idealmente, unas pocas líneas por método.

📜

Invariantes explícitas

Los hechos que deben ser ciertos para que el núcleo sea correcto: un puntero no nulo, una longitud que no excede la capacidad, unos bytes inicializados.

🛡️

Frontera de privacidad

Campos privados. Si el usuario pudiera tocar el puntero o la longitud a mano, rompería las invariantes y la API sería unsound. La privacidad es estructural, no cosmética.

API pública segura

Métodos sin unsafe en su firma. El usuario nunca escribe unsafe; el compilador verifica su código como el de cualquier otro.

Vec<T> es el ejemplo canónico. Por dentro es un (NonNull<T>, usize, usize): puntero, longitud y capacidad. Sus invariantes: el puntero es válido para cap elementos, los primeros len están inicializados, y len <= cap. push escribe en memoria sin inicializar con ptr::write; pop la lee con ptr::read; ambos usan unsafe por dentro. Pero su firma es segura, porque cada método preserva las invariantes. Y los tres campos son privados: por eso no puedes escribir v.len = 9999 y forzar a Vec a leer basura. La privacidad no adorna: es el cerrojo que sostiene la solidez. Basta con exponer un campo para volver unsound toda la abstracción:

pub struct MalVec<T> {
    pub len: usize,   // FATAL: publico rompe la invariante desde codigo seguro
    ptr: NonNull<T>,  // cap y ptr privados no bastan si len es manipulable
    cap: usize,
}

// El usuario, sin escribir una sola linea de unsafe, provoca UB:
//   v.len = 9999;         // miente sobre cuantos elementos hay
//   let x = &v[5000];     // lee memoria sin inicializar -> comportamiento indefinido

No hay unsafe a la vista en el código del usuario, y sin embargo el resultado es UB: eso es precisamente lo que significa que una API sea unsound. La lección es tajante: la frontera de privacidad no es una recomendación de estilo, es parte de la prueba de solidez.

El otro ejemplo revelador es split_at_mut, que parte un &mut [T] en dos mitades mutables disjuntas. El borrow checker no puede demostrar que las mitades no se solapan —no razona sobre índices—, así que la única implementación posible pasa por punteros crudos:

fn split_at_mut<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    assert!(mid <= len); // la invariante que hace sound al nucleo
    unsafe {
        // SAFETY: mid <= len, luego las dos mitades son disjuntas y caben
        // dentro del slice original; no hay solapamiento ni acceso fuera de rango
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

La firma es 100% segura. El assert! garantiza mid <= len, y de ahí se deduce que las dos mitades no se solapan y caen dentro del slice: por eso el unsafe es correcto. El usuario recibe dos &mut disjuntas y nunca sospecha que hubo punteros crudos de por medio.

flowchart TD
U[Usuario escribe solo codigo seguro] --> API[API publica sin unsafe en la firma]
API --> INV[Invariantes que garantiza el autor]
P[Campos privados] --> INV
INV --> N[Nucleo unsafe pequeno y auditado]
N --> SO[Soundness ningun uso seguro provoca UB]
style U fill:#89b4fa,color:#11111b
style N fill:#f38ba8,color:#11111b
style SO fill:#a6e3a1,color:#11111b
style P fill:#cba6f7,color:#11111b
unsafe no elimina la verificación: la traslada del compilador a un humano acotado

Lo que de verdad ocurre en este patrón es un cambio de quién demuestra qué, y merece meditarse despacio. En Rust seguro, el compilador es un demostrador de teoremas: por cada programa que acepta, ha probado que no hay use-after-free, ni doble liberación, ni carreras de datos, ni accesos fuera de rango. Esa prueba es automática, total y gratuita para ti. Pero hay teoremas que la máquina no sabe demostrar —que dos mitades de un slice son disjuntas, que un puntero recién obtenido de C no es nulo, que estos bytes ya están inicializados— no porque sean falsos, sino porque exceden lo que su lógica puede razonar. Ahí unsafe no desactiva la verificación: la delega. Te dice: “esto no lo sé probar yo; fírmalo tú”. Y el patrón central es la respuesta madura a esa delegación. En lugar de esparcir unsafe por todo el programa —lo que obligaría a auditar el programa entero—, lo concentras en un núcleo diminuto, le pones alrededor unas invariantes que tú garantizas, y cierras la frontera con privacidad. El resultado tiene una propiedad casi mágica: la carga de la prueba, que en C se reparte entre todos los sitios de uso, aquí se comprime en unas pocas líneas que un humano puede leer, entender y verificar de una vez. Fuera de esa cápsula, todo el edificio vuelve a ser seguro por construcción, y el compilador reanuda su labor de demostrador infalible. Por eso Vec es a la vez el tipo más usado y uno de los más audita­bles de Rust: su unsafe no está diluido en diez mil push, sino condensado en su implementación, tras una fachada que ningún uso legítimo puede quebrar. La grandeza del patrón no es que “usa unsafe con cuidado”; es que convierte una propiedad global e intratable —la ausencia de UB en todo el programa— en una obligación local y finita que cabe en la cabeza de una persona. Eso es ingeniería de la confianza, y es la esencia de programar sistemas en Rust.

📝
Lo esencial del patrón central

unsafe concede cinco superpoderes y no desactiva nada más; sus dos caras son exigir (unsafe fn, unsafe trait) y saldar (bloque unsafe). El objetivo es la soundness: que ningún código seguro pueda provocar UB. El patrón lo logra con cuatro piezas: un núcleo unsafe mínimo, unas invariantes que el autor garantiza, una frontera de privacidad que impide romperlas desde fuera y una API pública sin unsafe en la firma. Vec y split_at_mut son este patrón. Su virtud: comprimir una propiedad global e intratable en una obligación local y auditable.

⚔️ Encuentra la frontera de confianza
  1. Enumera los cinco superpoderes de unsafe y explica por qué el borrow checker sigue activo dentro de un bloque unsafe.
  2. Define con tus palabras soundness usando el cuantificador “ningún programa seguro”. Da un ejemplo de API unsound y otro de API que solo tiene un unwrap peligroso; distingue ambos males.
  3. Escribe la firma de split_at_mut y señala cuál es la invariante que el assert! garantiza y por qué de ella se deduce que las mitades son disjuntas.
  4. Imagina que Vec hiciera público su campo len. Redacta el programa seguro de tres líneas que provocaría UB, y explica por qué la privacidad es lo único que lo impide.
  5. Busca en la documentación de std un método cuya firma sea segura pero cuya implementación sepas que usa unsafe (por ejemplo en Vec o en str). Justifica qué invariante lo hace sound.