El problema: mover es copiar bytes, y eso invalida los punteros internos
En Rust un move es un `memcpy` ciego del stack más la invalidación estática del origen: eso lo hacía barato y predecible. Pero esa misma ceguera esconde una consecuencia: la dirección de un valor puede cambiar en cualquier momento, y si un valor guarda un puntero a sí mismo, el move copia el puntero tal cual mientras el dato aterriza en otra dirección. El puntero queda colgante.
En el nivel 8 aprendiste que mover un valor es casi gratis: se copian las pocas palabras del stack y se invalida el origen, sin que corra ni una línea de código de usuario. Esa fue la buena noticia. Ahora viene la letra pequeña. Si mover es reubicar los bytes de un valor a otra dirección de memoria, entonces la dirección de cualquier valor puede cambiar en cualquier momento, sin previo aviso y sin que puedas intervenir. Para casi todo, da igual: los bytes son idénticos vivan donde vivan. Pero existe una familia de valores para los que no da igual —los que guardan un puntero a sí mismos—, y sobre ellos el move de Rust cae como una guillotina silenciosa. Este nivel entero nace de ese único conflicto.
- Recordar que un move es un
memcpydel stack que reubica el valor en una dirección nueva. - Reconocer cuántas operaciones cotidianas mueven un valor sin anunciarlo.
- Ver por qué un puntero a uno mismo sobrevive intacto en bits pero apunta a la dirección vieja.
- Entender por qué Rust, al no ejecutar código durante un move, no tiene forma de reparar ese puntero.
Mover es reubicar, y ocurre por todas partes
Un move copia la representación superficial de un valor a otra posición de memoria y marca el origen como muerto. La palabra clave es otra posición: tras el move, el valor vive en una dirección distinta. Y esto no es un evento raro que provoques a mano; el lenguaje mueve valores constantemente, en sitios que ni miras:
let a = String::from("x");
let b = a; // move: reubicado por la asignacion
tragar(b); // move: reubicado al pasar por valor
let v = devolver(); // move: reubicado al retornar
let mut lista = vec![uno()];
lista.push(dos()); // el Vec puede realojar: reubica TODO su contenido
std::mem::swap(&mut x, &mut y); // reubica intercambiando dos valores
let z = std::mem::replace(&mut r, otro()); // reubica sacando el viejo
Asignar, pasar a una función, retornar, insertar en un contenedor que crece y realoja, swap, replace, take: cada uno de estos reubica valores. La conclusión incómoda es que un valor de Rust no tiene una dirección estable garantizada. Puede estar hoy en 0x1000 y, tras un inocente push a un vector que lo contenía, mañana en 0x2000. El compilador se reserva ese derecho porque de él dependen el rendimiento y la ergonomía de todo el lenguaje.
Repasemos el mecanismo exacto, porque es la raíz de todo lo que sigue. Un move de Rust es un memcpy de los bytes del stack y nada más. No hay constructor de movimiento como en C++, no se dispara ningún método, no se puede interceptar ni personalizar. El compilador puede incluso elidir la copia si demuestra que sobra. Esta simplicidad es justo lo que hacía los moves baratos y predecibles. Recuérdala: en un rato será también la razón de que no se puedan arreglar los punteros internos.
Todo valor cae en uno de dos mundos, y la diferencia entre ellos es el eje del nivel entero:
Valor sin auto-referencia
Un i32, un String, un Vec<T>. Sus bytes significan lo mismo en cualquier dirección, así que reubicarlos con un memcpy es inofensivo. Mover es gratis y seguro, y es el caso del noventa y nueve por ciento del código.
Valor auto-referencial
Guarda un puntero a uno de sus propios campos. Al moverlo, el puntero se copia intacto y sigue apuntando a la dirección vieja: queda colgante. Aquí el mismo memcpy ciego es catastrófico.
El valor que se apunta a sí mismo
Para casi todos los valores, la reubicación es inocua: los bytes de un String o de un i32 significan lo mismo en cualquier dirección. El problema aparece cuando entre esos bytes hay una dirección de memoria que apunta al propio valor. Imagina un struct que guarda un buffer y, junto a él, un puntero a ese buffer:
struct Autoref {
dato: [u8; 4],
puntero: *const u8, // se pretende que apunte a self.dato
}
Supón que este Autoref vive en la dirección 0x1000, de modo que dato empieza en 0x1000 y puntero vale exactamente 0x1000: apunta a su propio campo vecino. Ahora lo mueves —una asignación, un retorno, lo que sea— y el valor aterriza en 0x2000. El memcpy copia los ocho-y-pico bytes literalmente: dato se recrea en 0x2000, pero puntero se copia tal cual, y sigue valiendo 0x1000.
// Antes del move: el struct vive en 0x1000
// dato -> ocupa 0x1000..0x1004
// puntero -> vale 0x1000 (correcto: apunta a dato)
//
// Despues del move: el struct vive en 0x2000
// dato -> ahora ocupa 0x2000..0x2004
// puntero -> SIGUE valiendo 0x1000 (colgante: alli ya no hay nada suyo)
El puntero quedó colgante. Apunta a una dirección que ya no contiene el campo dato —quizá basura, quizá otro valor reciclado por el stack—. Leer a través de él es undefined behavior. Y fíjate en lo pérfido del caso: el move no rompió nada visible, no falló ninguna aserción, los bits del puntero se preservaron con toda fidelidad. Precisamente por preservarlos con fidelidad, el puntero ahora miente.
Que la dirección cambia con el move no es una metáfora; se puede observar. La función direccion extrae la posición actual de un valor, y basta un move para verla saltar:
fn direccion<T>(v: &T) -> usize {
v as *const T as usize // la direccion donde vive el valor ahora mismo
}
fn main() {
let s = String::from("dato");
println!("antes del move: {:#x}", direccion(&s));
let movido = s; // move: el valor se reubica en `movido`
println!("tras el move: {:#x}", direccion(&movido)); // otra direccion distinta
}
Para un String este salto es inofensivo, porque ninguno de sus bytes guardaba la dirección vieja. El problema aparece solo cuando sí la guardaba: entonces el salto de dirección deja atrás un puntero que apuntaba al sitio de antes.
Por qué Rust no puede repararlo por ti
En C++ este problema se resuelve —a un coste— con un constructor de movimiento: código de usuario que corre durante el move y que puede reapuntar el puntero a la nueva dirección. El objeto se entera de que lo están reubicando y arregla sus punteros internos sobre la marcha. Rust renunció a eso deliberadamente: como viste, en un move de Rust no corre ningún código de usuario. No hay ningún gancho donde meter la reparación. La misma decisión que hace los moves gratis y predecibles es la que deja los punteros internos sin nadie que los actualice.
// C++ (conceptual): el move ejecuta un cuerpo que puede reapuntar el campo
// Autoref(Autoref&& otro) { dato = otro.dato; puntero = &dato; } // se arregla
//
// Rust: el move es un memcpy y punto; no hay cuerpo donde arreglar nada
// let movido = autoref; // `puntero` se copia tal cual; nadie lo reapunta
La asimetría es total. C++ paga con un constructor que puede tener bugs y con un coste oculto en cada move, pero a cambio puede sostener valores auto-referenciales. Rust cobra moves triviales y sin sorpresas, pero al precio de que un valor auto-referencial, movido, queda roto sin remedio automático.
Rust queda entonces ante una disyuntiva. O bien prohíbe de raíz construir valores auto-referenciales —y en efecto, en Rust seguro casi no se pueden expresar, como verás en la siguiente lección—, o bien inventa una forma de prometerle al compilador que cierto valor no se moverá nunca, de modo que sus punteros internos permanezcan válidos. Esa segunda vía es Pin, y es la protagonista del nivel. Pero antes de conocer la solución hay que sentir el peso del problema: no es un caso académico, porque —lo verás pronto— cada función async no trivial fabrica uno de estos valores.
flowchart LR A[Struct en 0x1000 con puntero que vale 0x1000] -->|move copia los bytes tal cual| B[Struct en 0x2000] B --> C[El campo dato viaja a 0x2000] B --> D[El puntero sigue valiendo 0x1000] D --> E[Apunta a memoria que ya no es el dato y queda colgante] style A fill:#89b4fa,color:#11111b style B fill:#cba6f7,color:#11111b style D fill:#f9e2af,color:#11111b style E fill:#f38ba8,color:#11111b
Detente en la ironía, porque es la clave de todo el nivel. Lo que hace del move de Rust una operación segura, barata y libre de sorpresas es exactamente una cosa: que es un memcpy ciego sin código de usuario de por medio. No hay constructor que pueda tener un bug, no hay coste oculto en una firma, no hay estado fantasma que rastrear; mueves bits y punto. Esa ceguera es una virtud en el noventa y nueve por ciento de los casos, donde los bytes de un valor significan lo mismo en cualquier dirección. Pero un valor auto-referencial es precisamente aquel para el que la dirección forma parte de su significado: uno de sus bytes es un puntero que solo es correcto en un sitio. Para ese valor, un memcpy ciego es catastrófico, porque copia el puntero con fidelidad perfecta a una dirección donde ya no apunta a nada suyo. Y la misma ausencia de código de usuario que hacía el move seguro es ahora la ausencia del gancho donde C++ repararía el puntero. No son dos propiedades distintas —una buena, otra mala—: es una sola decisión de diseño, vista desde dos casos de uso opuestos. Comprender esto disuelve el misterio de Pin. Rust no va a añadir constructores de movimiento para el caso raro —eso encarecería el caso común y traicionaría el modelo entero—. Va a hacer algo mucho más astuto: dejar el move exactamente como está, y construir en el sistema de tipos una forma de marcar los pocos valores que no deben moverse, para que la promesa de inmovilidad se verifique en compilación y sin coste. La solución no toca el move; toca lo que el sistema de tipos te permite hacer con un valor. Guarda esa idea: el resto del nivel es desarrollarla.
Mover un valor en Rust lo reubica en otra dirección mediante un memcpy del stack, sin ejecutar código de usuario, y ocurre en asignaciones, paso por valor, retornos, realojos de Vec, swap y replace. Un valor no tiene, por tanto, dirección estable garantizada. Para casi todos da igual, pero un valor que guarda un puntero a sí mismo queda destrozado: el memcpy copia el puntero tal cual y este sigue apuntando a la dirección vieja, ahora colgante. Como en un move no corre ningún gancho, Rust no puede reapuntarlo como haría un constructor de movimiento de C++. De ahí la necesidad de un mecanismo que prometa inmovilidad: Pin.
- Enumera cinco operaciones distintas que muevan un valor sin que aparezca un signo
=explícito, y explica en cuál de ellas la dirección del valor casi seguro cambia. - Dibuja en papel el struct
Autorefen0x1000conpunterovaliendo0x1000, muévelo a0x2000byte a byte y marca por quépunteroqueda colgante. - Explica por qué el mismo
memcpyque hace el move barato es lo que impide reparar el puntero interno. - Contrasta con C++: describe qué haría un constructor de movimiento con el campo
puntero, y por qué Rust decidió no tener ese gancho. - Argumenta por qué prohibir del todo los valores auto-referenciales sería una solución posible pero insuficiente de cara al código
asyncque verás en la lección siguiente.