wandres.dev
PUNTEROS CRUDOS · los cinco superpoderes

Conversiones y NonNull: referencias, punteros y direcciones

El triángulo de conversiones entre referencia, puntero crudo y dirección numérica: coerciones seguras en un sentido, dereferencias unsafe en el otro, y el peligro de los lifetimes sin límite. Y NonNull<T>, el puntero que promete no ser nulo, con su nicho gratis y su covarianza, la base de Box, Rc y Vec.

⏱ 18 min

Referencia, puntero crudo y dirección numérica son tres formas de decir “dónde está esto”, y buena parte de la programación de bajo nivel consiste en moverse entre ellas con precisión. El triángulo tiene una geometría clara: bajar de referencia a puntero es seguro porque tiras garantías; subir de puntero a referencia es unsafe porque las reafirmas; y convertir a usize y de vuelta cruza además la frontera de la procedencia. Pero hay un cuarto vértice que las estructuras de datos serias no pueden ignorar: NonNull<T>, un puntero crudo con una única promesa cosida al tipo —que nunca es nulo—. Esa promesa, aparentemente modesta, desbloquea el nicho que hace que Option<NonNull<T>> no ocupe ni un byte de más, la covarianza que las referencias tienen y los punteros crudos no, y por eso es el ladrillo con el que Box, Rc, Arc y Vec están construidos por dentro.

🎯 Al terminar esta lección sabrás
  • Recorrer el triángulo referencia, puntero y dirección con sus conversiones seguras y unsafe.
  • Reconstruir referencias desde punteros con &*p y evitar la trampa del lifetime sin límite.
  • Usar NonNull<T> y su API: new, as_ref, as_ptr, dangling, cast.
  • Explicar por qué las estructuras de datos eligen NonNull por el nicho y la covarianza.

El triángulo: referencia, puntero, dirección

Bajar de referencia a puntero es una coerción segura: descartas promesas, no las inventas. Subir de puntero a referencia con &*p o &mut *p es unsafe, porque ahí sí reafirmas las cuatro invariantes —y una quinta traicionera, el lifetime—. Cruzar a usize con as o addr extrae la dirección; volver desde un entero fabrica un puntero cuya procedencia has de justificar.

let mut n = 5_i32;
let p: *mut i32 = &raw mut n;

let dir: usize = p.addr();              // procedencia estricta: solo la direccion
let pc: *const i32 = p.cast_const();    // mut a const
let otro: *const u8 = p.cast();         // reinterpreta el tipo apuntado

let r: &mut i32 = unsafe { &mut *p };   // puntero a referencia: unsafe
*r += 1;
assert_eq!(n, 6);

La trampa más silenciosa vive en &*p. Al reconstruir una referencia, tú eliges su lifetime, y si no lo acotas el compilador le concede uno tan largo como haga falta —un lifetime sin límite— que puede sobrevivir a la memoria apuntada y abrir un use-after-free que el borrow checker no verá venir. La disciplina es atar siempre la referencia reconstruida a un lifetime real, típicamente el de la estructura que posee la memoria.

⚠️
El lifetime sin límite de &*p

unsafe { &*p } produce una referencia con el lifetime que el contexto pida, sin relación con la vida real de la memoria. Si esa referencia escapa a un ámbito donde la memoria ya murió, es UB, y el compilador no te avisará porque tú le juraste que era válida. Devuelve estas referencias solo atadas al &self de un método, nunca sueltas.

NonNull: el puntero que promete no ser nulo

NonNull<T> es un *mut T con una invariante grabada en el tipo: jamás es nulo. Esa promesa cambia tres cosas. Primero, habilita el nicho: como el patrón de bits “todo ceros” es imposible, el compilador lo reutiliza para representar None, y Option<NonNull<T>> ocupa exactamente lo mismo que un puntero. Segundo, NonNull<T> es covariante en T, como las referencias, mientras que *mut T es invariante: eso lo hace apto para estructuras genéricas donde la varianza importa. Tercero, documenta la invariante para quien lea el tipo.

use std::ptr::NonNull;
use std::mem::size_of;

let mut n = 42_i32;
let nn: NonNull<i32> = NonNull::new(&raw mut n).unwrap();  // new devuelve Option
unsafe {
    assert_eq!(*nn.as_ref(), 42);   // as_ref da una &T (cuidado con el lifetime)
    *nn.as_ptr() = 43;               // as_ptr recupera el *mut T
}
assert_eq!(n, 43);

// el nicho: Option sale gratis
assert_eq!(size_of::<Option<NonNull<i32>>>(), size_of::<*mut i32>());

La API es deliberadamente afilada: NonNull::new(p) devuelve Option<NonNull<T>> comprobando la nulidad; new_unchecked la omite bajo tu responsabilidad; as_ptr recupera el *mut T; as_ref y as_mut reconstruyen referencias (con el mismo cuidado de lifetime de antes); cast reinterpreta el tipo; y NonNull::dangling() fabrica un puntero no-nulo, bien alineado pero colgante, el valor idóneo para representar una colección vacía o un tipo de tamaño cero sin reservar memoria. Como los punteros crudos, NonNull no es Send ni Sync, así que los contenedores que lo envuelven declaran esas capacidades a mano cuando su invariante lo justifica.

Por qué las estructuras de datos eligen NonNull

Abre el código de Box, Rc, Arc o Vec y encontrarás NonNull (a menudo tras el envoltorio Unique) en el corazón de todas. La elección no es casual: reúne cuatro ventajas que ningún otro tipo ofrece a la vez.

🎁

Nicho gratis

Option<NonNull<T>> mide lo que un puntero. Una lista enlazada usa Option<NonNull<Nodo>> como enlace y no paga ni un byte por el None.

🔀

Covarianza

NonNull<T> es covariante como &T, así que un NonNull<'largo> encaja donde se espera uno más corto; *mut T no lo permitiría.

📝

Invariante documentada

El tipo mismo declara “aquí nunca hay nulo”. Quien lea la estructura sabe que no debe contemplar ese caso.

🫙

dangling para lo vacío

NonNull::dangling() da un puntero válido de tipo sin reservar memoria: la colección recién creada y los tipos de tamaño cero lo usan como centinela.

use std::ptr::NonNull;

// El esqueleto de una lista: el enlace es Option<NonNull<Nodo>>, nicho incluido.
struct Nodo<T> {
    valor: T,
    siguiente: Option<NonNull<Nodo<T>>>,   // None reutiliza el patron cero
}

struct Lista<T> {
    cabeza: Option<NonNull<Nodo<T>>>,
}

En la biblioteca estándar, NonNull suele viajar envuelto en el tipo interno Unique<T>, que añade una promesa más —la propiedad exclusiva de lo apuntado— y comunica al compilador que ese puntero no aliasea, habilitando optimizaciones análogas a las de una &mut. Box y Vec lo usan por dentro; tú, desde un crate de usuario, trabajarás con NonNull directamente y reafirmarás a mano, con tus invariantes, lo que Unique afirma de fábrica.

flowchart LR
R[Referencia ref T] -->|coercion segura| P[Puntero crudo]
P -->|deref unsafe con lifetime| R
P -->|addr o as usize| D[Direccion usize]
D -->|with addr o as ptr| P
P -->|NonNull new| NN[NonNull T no nulo]
NN -->|nicho y covarianza| E[Base de Box Rc Arc Vec]
style R fill:#a6e3a1,color:#11111b
style P fill:#89b4fa,color:#11111b
style D fill:#f9e2af,color:#11111b
style NN fill:#cba6f7,color:#11111b
style E fill:#f38ba8,color:#11111b
NonNull: cuando una invariante se convierte en tipo

El salto conceptual de NonNull<T> es el mismo que ya viste en Option, en NonZeroU32, en los newtypes que validan en el constructor: tomar un hecho que de otro modo tendrías que recordar, comprobar y documentar en tiempo de ejecución, y hornearlo en el tipo para que el compilador lo dé por cierto sin volver a preguntarlo. Un *mut T puede ser nulo, así que cada vez que lo usas cargas mentalmente con la duda “¿y si es nulo aquí?”; NonNull<T> liquida esa duda de una vez, no con una comprobación en cada acceso, sino con una promesa hecha una sola vez —al construirlo— que el tipo propaga gratis a todos los usos posteriores. Y la recompensa no es solo higiene mental: es material. Porque el compilador ahora sabe que el patrón de bits cero es imposible, puede robar ese patrón para significar otra cosa, y así Option<NonNull<T>> cabe donde antes cabía solo el puntero, sin la palabra extra que un bool de discriminante costaría. Esto es lo que en Rust se llama optimización de nicho, y es la razón física de que una lista enlazada de un millón de nodos no desperdicie un millón de palabras en marcar cuáles son el final. La lección más amplia, la que recorre todo el diseño de Rust, es que los tipos no son etiquetas pasivas sobre datos: son afirmaciones que el compilador explota. Cada invariante que consigas trasladar de tu cabeza al sistema de tipos deja de ser una carga que tú vigilas y pasa a ser un hecho que la máquina aprovecha, en corrección y en tamaño. NonNull es el ejemplo más puro de esa filosofía aplicada al territorio unsafe: el puntero más peligroso del lenguaje, domesticado por la única promesa que hacía falta grabar en su tipo.

📝
Lo esencial de conversiones y NonNull

Bajar de referencia a puntero es coerción segura; subir con &*p es unsafe y elige un lifetime que debes atar a memoria real para no crear un lifetime sin límite. addr extrae la dirección; cast, cast_mut y cast_const reinterpretan tipo y mutabilidad. NonNull<T> graba en el tipo la promesa de no-nulidad, y con ella gana el nicho —Option<NonNull<T>> mide lo que un puntero— y la covarianza que *mut T no tiene. Por eso es el cimiento interno de Box, Rc, Arc y Vec, con dangling() como centinela para lo vacío.

⚔️ Recorre el triángulo y graba la invariante
  1. Parte de un let mut, obtén un *mut i32, extrae su dirección con addr, reconstruye una &mut i32 con &mut *p y modifica el valor; identifica qué paso es unsafe y por qué.
  2. Escribe una función que devuelva &i32 a partir de un *const i32 y observa cómo el lifetime sin límite es un peligro; corrígela atándolo a un parámetro con lifetime.
  3. Crea un NonNull<i32> con new().unwrap(), lee y escribe a través de él, y demuestra con size_of que Option<NonNull<i32>> mide lo mismo que *mut i32.
  4. Explica por qué una lista enlazada prefiere Option<NonNull<Nodo>> a *mut Nodo apelando a nicho y covarianza.
  5. Investiga NonNull::dangling() y describe dos situaciones —colección vacía y tipo de tamaño cero— donde es el valor correcto.