Aritmética de punteros: offset, add, wrapping y la procedencia
Moverse por la memoria con offset, add y sub en unidades de elemento, no de byte, y las precondiciones que convierten un cálculo fuera de límites en UB antes de dereferenciar nada. La aritmética con envoltura de wrapping_add, y la idea central que separa un puntero de un entero: la procedencia.
Un puntero crudo cobra su verdadero poder cuando se mueve. Recorrer un array, saltar al siguiente nodo de una lista, indexar un buffer: todo es aritmética de punteros, sumar y restar posiciones a una dirección. Rust ofrece esa aritmética con una precisión quirúrgica que C nunca tuvo. add y offset se miden en elementos, no en bytes; tienen precondiciones que convierten un simple cálculo —sin dereferenciar nada— en comportamiento indefinido si te sales de la asignación; y wrapping_add ofrece la versión sin esas ataduras para los casos raros donde la necesitas. Pero por debajo de toda la aritmética late una idea que distingue a Rust de la mentalidad “un puntero es solo un número”: la procedencia. Un puntero recuerda de qué asignación nació, y ese recuerdo, no la dirección numérica, es lo que decide qué accesos son legales.
- Desplazar punteros con
offset,add,suby sus gemelas en bytes, midiendo en unidades deT. - Enunciar las precondiciones de
add/offsetque hacen UB un cálculo fuera de límites. - Usar
wrapping_add/wrapping_offsetcuando necesitas aritmética con envoltura sin la precondición de límites. - Comprender la procedencia y por qué un puntero no es intercambiable por su dirección numérica.
Moverse por la memoria: offset, add, sub
Las operaciones fundamentales desplazan el puntero un número de elementos. add(n) avanza n posiciones —internamente, n * size_of::<T>() bytes—; sub(n) retrocede; offset(delta) acepta un isize con signo. Existen las gemelas byte_add, byte_sub y byte_offset cuando de verdad quieres razonar en bytes.
let v = [10_i32, 20, 30, 40];
let base: *const i32 = v.as_ptr();
unsafe {
let tercero = base.add(2); // avanza 2 elementos = 8 bytes
assert_eq!(*tercero, 30);
let fin = base.add(4); // uno-mas-alla del final: legal formarlo
// let _ = *fin; // pero dereferenciarlo seria UB
let dist = fin.offset_from(base); // distancia en elementos
assert_eq!(dist, 4);
}
Estas operaciones parecen inocentes, pero cargan un contrato severo. Para que add u offset sean legales, el puntero de partida y el de llegada deben caer dentro de —o justo al final de— la misma asignación; el desplazamiento en bytes no puede desbordar un isize; y el cálculo no puede dar la vuelta al espacio de direcciones. Violar cualquiera de estas es comportamiento indefinido en el instante del cálculo, aun si nunca llegas a dereferenciar el resultado. La única concesión es el puntero uno-más-allá-del-final: como en C, formarlo es legal (los bucles lo necesitan como centinela), pero dereferenciarlo no.
Es tentador pensar “mientras no dereferencie, cualquier aritmética vale”. Falso para add y offset: salirte de la asignación al calcular la nueva dirección ya es UB, porque el compilador asume esa precondición para optimizar. Si necesitas calcular una dirección que podría salirse sin penalización, esa es exactamente la razón de existir de wrapping_add.
Medir y saltar en bytes
A veces el paso natural no es un elemento sino un byte: recorrer una cabecera, alinear a mano, saltar campos de tamaños dispares. Para eso están byte_add, byte_sub y byte_offset, que desplazan en bytes crudos sin multiplicar por size_of::<T>(). Y para la operación inversa —cuántos elementos separan dos punteros— está offset_from, que devuelve un isize con signo.
let v = [10_i32, 20, 30, 40];
let base: *const i32 = v.as_ptr();
unsafe {
let por_bytes = base.byte_add(8); // 8 bytes = 2 elementos i32
assert_eq!(*por_bytes, 30);
let d = por_bytes.offset_from(base); // resultado en elementos, no en bytes
assert_eq!(d, 2);
}
offset_from carga sus propias precondiciones: ambos punteros deben pertenecer a la misma asignación, y la resta de direcciones se divide por size_of::<T>(), de modo que para un tipo de tamaño cero la operación carece de sentido y entra en pánico. La regla general se mantiene inquebrantable: la aritmética expresa una intención sobre una asignación concreta, y salirte de ella —midiendo o saltando— rompe el contrato.
wrapping: aritmética sin la promesa de límites
wrapping_add, wrapping_sub y wrapping_offset hacen la misma aritmética pero con envoltura de complemento a dos y sin la precondición de permanecer en la asignación. Calcular con ellas nunca es UB por sí mismo; lo que sigue siendo UB es dereferenciar un puntero que acabe fuera de una asignación viva. Son la herramienta para centinelas que pueden pasarse del final, punteros etiquetados, o algoritmos que comparan direcciones sin tocarlas.
let datos = [0_u8; 4];
let base: *const u8 = datos.as_ptr();
let lejano = base.wrapping_add(usize::MAX); // calcular esto: legal, no es UB
// let _ = *lejano; // dereferenciarlo: eso si seria UB
// util para recorrer hacia atras sin UB al pasarse del inicio
let mut p = base.wrapping_add(4);
while p != base {
p = p.wrapping_sub(1);
unsafe { let _ = *p; } // aqui p si esta siempre dentro
}
La diferencia con add no es cosmética: add promete al optimizador que no te sales, y esa promesa se traduce en código más agresivo; wrapping_add no promete nada sobre límites, solo sobre la aritmética modular. Elige add por defecto —es la que expresa la intención correcta y habilita mejores optimizaciones— y reserva wrapping_* para cuando de verdad necesitas calcular una dirección que podría salirse.
Como la alineación obliga a que un *mut T bien formado tenga sus bits bajos a cero —un T alineado a 8 deja libres los 3 bits inferiores—, las estructuras más optimizadas los aprovechan para guardar banderas dentro del propio puntero. Manipular esos bits sin perder la procedencia es terreno de map_addr; hacerlo con as usize y de vuelta es la vieja receta de C, que Rust te deja usar pero te pide declarar en lugar de dar por supuesta.
Procedencia: un puntero no es solo un número
Aquí está la idea que separa a Rust del folclore de C. En el modelo abstracto de Rust, un puntero no es su dirección: es una dirección más una procedencia, una etiqueta invisible que dice de qué asignación deriva y, por tanto, a qué memoria tiene permiso de acceder. Toda la aritmética de punteros preserva la procedencia: base.add(2) sigue perteneciendo a la asignación de base, aunque numéricamente aterrice donde tú quieras. No puedes fabricar acceso a la asignación B a partir de un puntero cuya procedencia es la asignación A, aunque la aritmética coincida byte a byte con una dirección de B.
let a = [1_u8; 4];
let b = [2_u8; 4];
let pa: *const u8 = a.as_ptr();
// Aunque calcules la direccion numerica de b partiendo de pa,
// el puntero resultante conserva la procedencia de a: leer b por ahi es UB.
let _ = pa.wrapping_add(b.as_ptr() as usize - pa as usize);
Este modelo, formalizado por los trabajos de Stacked Borrows y Tree Borrows y comprobado por Miri, explica por qué convertir un puntero a usize y de vuelta no es una operación neutra: el entero olvida la procedencia. Por eso Rust 2024 estabiliza las APIs de procedencia estricta —addr para leer solo la dirección, with_addr para transplantar una dirección conservando la procedencia de otro puntero, map_addr para transformarla— que hacen explícito lo que el casteo con as deja implícito. En cuanto al aliasing, los punteros crudos entre sí no imponen ninguna regla: puedes tener mil *mut T al mismo sitio sin pecado. Las reglas de exclusividad muerden solo cuando, a partir de esos punteros, materializas una & o una &mut.
flowchart TD P[Puntero crudo] --> D[Direccion numerica] P --> Q[Procedencia asignacion de origen] D --> N[as usize olvida la procedencia] Q --> K[add y offset la preservan] K --> L[Solo accede a la asignacion de origen] N --> M[El entero es solo un numero sin permisos] style P fill:#89b4fa,color:#11111b style Q fill:#a6e3a1,color:#11111b style D fill:#f9e2af,color:#11111b style M fill:#f38ba8,color:#11111b
La intuición que traes de C y de la máquina desnuda es que un puntero es un número: la dirección física de un byte, ni más ni menos, y que por tanto dos punteros con el mismo valor numérico son intercambiables. Rust rompe esa intuición a propósito, y entender por qué es entender el corazón del unsafe moderno. Para que el compilador pueda optimizar de forma agresiva —mantener valores en registros, reordenar lecturas, asumir que dos escrituras no se pisan— necesita razonar sobre qué puede tocar cada puntero, y esa pregunta no se contesta mirando la dirección numérica, sino sabiendo de dónde salió el puntero. Ese origen es la procedencia: la asignación de la que el puntero deriva y, con ella, el permiso de acceder a su rango y solo a él. Un puntero, en el modelo abstracto, no lleva una dirección: lleva una dirección con un pasaporte. La aritmética add y offset sella el pasaporte —te mueves dentro de tu asignación— mientras que castear a usize lo rompe: el entero resultante es apátrida, un número puro sin derecho de acceso a nada, y reconstruir un puntero desde él es un acto de fe que las APIs de procedencia estricta te obligan a declarar. Por eso la ecuación puntero == entero que gobierna la mente del programador de C es, en Rust, sencillamente falsa: dos punteros pueden compartir dirección y diferir en procedencia, y solo uno de ellos tiene permiso para leer ahí. Este no es un tecnicismo académico. Es la razón por la que Miri puede cazar errores que ni siquiera crashean, por la que el optimizador de LLVM puede confiar en el código de Rust, y por la que las estructuras de datos que construirás pueden ser rápidas y correctas a la vez. Un puntero recuerda su origen; un entero lo olvidó. Programar unsafe bien es no olvidarlo tú tampoco.
add, offset y sub desplazan en unidades de T con la precondición de no salir de la asignación: violarla es UB al calcular, no al leer. byte_add salta en bytes crudos y offset_from mide la distancia en elementos. wrapping_add renuncia a la precondición de límites —el cálculo nunca es UB, aunque dereferenciar fuera de una asignación viva lo siga siendo—. Bajo todo late la procedencia: un puntero es una dirección con pasaporte que la aritmética preserva y as usize rompe. Los punteros crudos no imponen aliasing entre sí; las reglas de exclusividad muerden al materializar & o &mut.
- Recorre un
[i32; 5]con un puntero yadd, imprimiendo cada elemento; forma el puntero uno-más-allá-del-final y explica por qué no lo dereferencias. - Usa
offset_frompara medir la distancia en elementos entre dos posiciones del mismo array y verifica que da el número esperado. - Provoca (en papel, no lo ejecutes) un cálculo con
addque se salga de la asignación y argumenta por qué es UB aunque no dereferencies; reescríbelo conwrapping_addpara que el cálculo sea legal. - Explica con tus palabras qué es la procedencia y por qué
p as usize as *const Tno es una operación neutra. - Investiga
addr,with_addrymap_addr; describe qué expresa cada una que el casteoasdeja implícito.