study.plynte.com
@franguh
Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)

02 · Ownership y préstamos#

Objetivo#

Al terminar esta lección podrás seguir con exactitud rigurosa qué binding es dueño de cada valor en memoria, distinguir cuándo una operación transfiere propiedad (move) de cuándo duplica datos bit a bit (copy), y justificar con precisión matemática cuándo una referencia compartida (&T) o exclusiva (&mut T) es válida. El objetivo no es memorizar recetas mecánicas de “añade .clone() hasta que el compilador se calle”, sino comprender la arquitectura física de memoria en la pila (stack) y en el montículo (heap), interpretando los diagnósticos del borrow checker como evidencia formal sobre la vida útil de tus datos.

Antes de empezar#

Lee previamente 01 · Tipos y flujo de control y ten disponible tu terminal con rustc para experimentar. En esta lección no utilizaremos punteros crudos ni bloques unsafe: el sistema de ownership de Rust permite escribir software de alto rendimiento con acceso directo a memoria garantizando cero vulnerabilidades de corrupción, punteros colgantes o carreras de datos en código seguro. Si necesitas repasar terminología básica, consulta el glosario técnico.


Diccionario de conceptos de la lección (Glosario desde cero)#

Para construir un modelo mental inquebrantable, analizamos cada concepto bajo cuatro dimensiones: intuición cotidiana, definición técnica precisa, ejemplo concreto en código y concepción errónea frecuente.

1. Pila (Stack) vs Montículo (Heap)#

  • Intuición cotidiana: La pila es como una pila de platos en un restaurante: solo puedes colocar o retirar el plato superior; es ultra rápido y ordenado, pero cada plato debe tener un tamaño fijo predeterminado. El montículo es como un almacén general con estantes numerados: puedes solicitar un espacio de cualquier tamaño variable, pero el encargado tarda más en buscar un hueco libre y entregarte la dirección de la etiqueta.
  • Definición técnica precisa:
  • *Pila (Stack): Región de memoria contigua de acceso LIFO (Last In, First Out*) gestionada directamente por el registro de puntero de pila de la CPU (RSP en x86_64). Asignar y liberar memoria en la pila cuesta una única instrucción de ensamblador (sumar o restar un desplazamiento al puntero). Todos los tipos almacenados en la pila deben tener un tamaño conocido en tiempo de compilación (Sized).
  • *Montículo (Heap): Región de memoria dinámica para datos cuyo tamaño se desconoce en compilación o puede cambiar durante la ejecución (como texto de usuario o vectores dinámicos). Asignar memoria en el heap requiere interactuar con el asignador de memoria del sistema operativo (system allocator*), buscando bloques libres contiguos y devolviendo un puntero de dirección de 64 bits.
  • Ejemplo en código:
RUST
  let x: i32 = 42; // Almacenado enteramente en el stack (4 bytes)
  let s: String = String::from("hello"); // Descriptor de 24 bytes en el stack;
                                         // los 5 bytes de texto viven en el heap
  • Concepción errónea: Creer que la pila y el montículo son metáforas abstractas independientes del hardware. Son estructuras de memoria física reales; el acceso a datos en la pila tiene máxima localidad espacial en la memoria caché L1/L2 del procesador, mientras que el acceso al heap sufre mayor latencia por desreferenciación de punteros.

2. Ownership (Las 3 reglas canónicas y Drop)#

  • Intuición cotidiana: Una escritura de propiedad de una casa: en cada momento exacto solo existe un único dueño legal registrado. Si la casa se vende, la propiedad se transfiere por completo; cuando el dueño fallece sin herederos, la propiedad se demuele o se entrega al municipio de forma definitiva.
  • Definición técnica precisa: Sistema estático de gestión de memoria de Rust gobernado por tres invariantes fundamentales impuestas por el compilador:
  1. Cada valor en Rust tiene un único binding que es su *dueño (owner)*.
  2. Solo puede existir un único dueño a la vez.
  3. Cuando el dueño sale del ámbito (scope delimitado por { ... }), el valor se destruye automáticamente y su memoria se libera mediante la ejecución de la función de limpieza del trait Drop.
  • Ejemplo en código:
RUST
  {
      let s = String::from("bounded resource"); // 's' es el dueño
      println!("{s}");
  } // Aquí termina el scope: Rust llama automáticamente a Drop::drop(&mut s)
    // liberando el buffer del heap inmediatamente.
  • Concepción errónea: Creer que Rust tiene un recolector de basura (Garbage Collector) oculto ejecutándose en segundo plano. Rust no tiene runtime de recolección ni pausas: el código de desasignación (free/deallocate) es inyectado estáticamente por el compilador en los límites exactos de los bloques.

3. Semántica de movimiento (Move)#

  • Intuición cotidiana: Entregarle la llave física y el título original de tu auto a otra persona. Ya no puedes conducir el auto porque ya no tienes ni la llave ni el título.
  • Definición técnica precisa: Transferencia de la propiedad de un recurso de un binding a otro. En la pila, Rust copia superficialmente (shallow copy) el descriptor del tipo (por ejemplo, los 24 bytes de un String), pero invalida permanentemente el binding original en tiempo de compilación. Cualquier intento de leer la variable original después del movimiento genera el error E0382.
  • Ejemplo en código:
RUST
  let s1 = String::from("data");
  let s2 = s1; // La propiedad se mueve a s2. s1 queda invalidado.
  // println!("{s1}"); // Error E0382: borrow of moved value: `s1`
  • Concepción errónea: Pensar que let s2 = s1; recorre el heap y copia el texto. Solo copia los 3 campos de 8 bytes de la pila y transfiere la responsabilidad de liberar el heap a s2.

4. Trait Copy (Copia bit a bit en pila)#

  • Intuición cotidiana: Sacar una fotocopia de un papel en una máquina de oficina: ambas personas tienen ahora su propia hoja idéntica y lo que una escriba en su copia no afecta a la otra.
  • Definición técnica precisa: Trait marcador del compilador que indica que el tipo puede ser duplicado simplemente copiando sus bits en la pila mediante una instrucción memcpy de hardware. Los tipos que implementan Copy no tienen semántica de movimiento: el binding original permanece 100% válido tras una asignación o pase a función.
  • Regla estricta: Un tipo solo puede implementar Copy si todos sus componentes viven exclusivamente en la pila y ninguno de ellos implementa el trait Drop.
  • Ejemplo en código:
RUST
  let a: u32 = 100;
  let b = a; // Duplicación bit a bit en la pila
  println!("{a} y {b}"); // Ambos bindings son totalmente válidos
  • Concepción errónea: Creer que String o Vec<T> pueden implementar Copy. Si lo hicieran, dos variables tendrían punteros al mismo búfer del heap, provocando una doble liberación de memoria (double-free) al terminar sus ámbitos.

5. Vulnerabilidad Double-Free y Puntero colgante (Dangling Pointer)#

  • Intuición cotidiana:
  • Double-free: Intentar demoler dos veces la misma casa: la primera demolición tiene éxito, la segunda arroja escombros sobre los obreros de la constructora vecina.
  • Dangling pointer: Guardar en tu agenda el número de teléfono de una persona que ya canceló su línea; si llamas, contesta un desconocido o la llamada colapsa.
  • Definición técnica precisa:
  • Double-Free: Defecto crítico de seguridad de memoria que ocurre cuando se invoca la rutina de desasignación (free) dos veces sobre la misma dirección de memoria del heap, corrompiendo la tabla interna del asignador y permitiendo ataques de ejecución arbitraria de código.
  • Dangling Pointer: Puntero que contiene la dirección de memoria de un dato que ya ha sido liberado o cuyo marco de pila ha sido destruido. Desreferenciarlo provoca Undefined Behavior (comportamiento indefinido).
  • Ejemplo en código (lo que Rust previene):
RUST
  // En C o C++, copiar un puntero sin ownership puede liberar dos veces el buffer.
  // En Rust seguro, el compilador garantiza que esto sea físicamente imposible de compilar.
  • Concepción errónea: Asumir que los errores de punteros colgantes solo ocurren en lenguajes antiguos como C. En lenguajes de alto nivel con recolector de basura pueden no colapsar la memoria física, pero retienen recursos abiertos (fugas de memoria lógica) u operan sobre estados zombies.

6. Borrowing: Préstamo compartido (&T) y exclusivo (&mut T)#

  • Intuición cotidiana:
  • &T: Un libro en una biblioteca pública: muchos lectores pueden leerlo en silencio simultáneamente.
  • &mut T: Una libreta de notas personal en la que estás escribiendo: mientras escribes, nadie más puede leerla ni escribir en ella al mismo tiempo.
  • Definición técnica precisa: Mecanismo mediante el cual un binding accede a un valor sin asumir su propiedad.
  • Referencia compartida (&T): Permite acceso de solo lectura. Puedes tener un número ilimitado de referencias &T vivas al mismo dato al mismo tiempo.
  • Referencia mutable o exclusiva (&mut T): Permite modificar el valor. Solo puede existir una única referencia exclusiva activa para un dato en un instante dado, y ninguna referencia compartida puede coexitir con ella.
  • Ejemplo en código:
RUST
  let mut text = String::from("Rust");
  let r1 = &text; // Préstamo compartido
  let r2 = &text; // Otro préstamo compartido válido
  println!("{r1} y {r2}"); // Fin del uso de r1 y r2

  let r3 = &mut text; // Préstamo exclusivo: solo permitido porque r1 y r2 ya no se usan
  r3.push_str(" 2024");
  • Concepción errónea: Pensar que el alcance de un préstamo abarca obligatoriamente todo el bloque { ... }. Desde la introducción de NLL (Non-Lexical Lifetimes), el compilador calcula la vida de una referencia hasta el punto exacto de su última utilización en el código.

7. Tiempo de vida (Lifetime)#

  • Intuición cotidiana: El lapso durante el cual un inquilino tiene contrato vigente para habitar un departamento: el contrato de arrendamiento jamás puede durar más tiempo que la existencia física del edificio.
  • Definición técnica precisa: Parámetro genérico inferido o declarado formalmente por el compilador (denotado como 'a), que define la región estática del código fuente durante la cual una referencia apunta a datos válidos y vivos en memoria.
  • Ejemplo en código:
RUST
  fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
      if x.len() > y.len() { x } else { y }
  }
  • Concepción errónea: Creer que anotar lifetimes explícitos modifica cuánto tiempo vive un dato en la memoria RAM durante la ejecución. Los lifetimes son puramente descriptivos: informan al compilador sobre la relación entre las duraciones de los datos para que pueda comprobar que ninguna referencia sobreviva a su dueño.

8. Fat Pointer / Slice (&str, &[T]) vs Contenedor poseído (String, Vec<T>)#

  • Intuición cotidiana: El libro completo empastado que compraste en la librería (String) frente a un separador de páginas transparente que señala del capítulo 2 al capítulo 4 (&str).
  • Definición técnica precisa:
  • Contenedor poseído (String, Vec<T>): Descriptor de 3 palabras de máquina ($24$ bytes en 64 bits): puntero al buffer en el heap (ptr), capacidad total asignada (cap) y longitud utilizada (len). Es dueño de la memoria del heap y la libera al caer de ámbito.
  • Fat Pointer / Slice (&str, &[T]): Referencia compuesta de 2 palabras de máquina ($16$ bytes en 64 bits): puntero directo al primer elemento/byte (ptr) y longitud de la vista (len). No posee capacidad ni gestiona desasignación de memoria.
  • Ejemplo en código:
RUST
  let owned_vec: Vec<i32> = vec![10, 20, 30, 40]; // 24 bytes en stack + heap
  let slice_view: &[i32] = &owned_vec[1..3];        // 16 bytes en stack (apunta a 20, len = 2)
  • Concepción errónea: Creer que &str solo puede apuntar a cadenas en el heap. Un &str puede apuntar a memoria del heap, a la memoria estática de solo lectura del binario ("literal"), o incluso a un búfer local en la pila.

9. Reasignación en montículo (Reallocation)#

  • Intuición cotidiana: Una sala de conferencias con 10 sillas. Si llega el asistente número 11, el organizador no puede empujar las paredes de concreto del edificio; debe reservar un auditorio más grande en otro piso, mudar a las 10 personas originales con sus pertenencias, sentar a la persona 11 y desocupar la sala anterior.
  • Definición técnica precisa: Proceso interno ejecutado por estructuras dinámicas como Vec<T> o String cuando una operación de inserción (como .push()) excede la capacidad actual (len == cap). El asignador busca un nuevo bloque de memoria en el heap (típicamente del doble de tamaño), copia los elementos anteriores a la nueva dirección, libera el bloque antiguo y actualiza el puntero de la estructura en la pila.
  • Ejemplo en código:
RUST
  let mut v = Vec::with_capacity(2);
  v.push(1);
  v.push(2);
  v.push(3); // Provoca reasignación: el buffer cambia de dirección de memoria en el heap
  • Concepción errónea: Ignorar que la reasignación cambia la dirección de memoria de los elementos. Si existiera un puntero apuntando a un elemento del buffer viejo, dicho puntero apuntaría a memoria liberada (dangling pointer).

10. Carrera de datos (Data Race)#

  • Intuición cotidiana: Dos personas intentando escribir simultáneamente con bolígrafos sobre el mismo renglón de una libreta al mismo segundo sin mirarse: el texto final queda ilegible y emborronado.
  • Definición técnica precisa: Condición de concurrencia que se produce cuando dos o más hilos o punteros acceden a la misma posición física de memoria concurrentemente, al menos uno de los accesos es de escritura, y no existe sincronización de hardware (como un cerrojo o mutex).
  • Ejemplo en código: Rust erradica las carreras de datos en código seguro garantizando la regla: "Cualquier número de lectores O un único escritor, pero nunca ambos a la vez".
  • Concepción errónea: Confundir una carrera de datos (Data Race) con una condición de carrera lógica (Race Condition). Rust previene de forma absoluta las carreras de datos de memoria en tiempo de compilación. Las condiciones de carrera lógicas de alto nivel (por ejemplo, dos usuarios transfiriendo dinero al mismo tiempo a nivel de base de datos) requieren diseño de transacciones de dominio.

Modelo mental y desempacado paso a paso#

1. La disposición física en memoria: Descriptores de 3 palabras vs Fat Pointers de 2 palabras#

Para escribir Rust idiomático, es imprescindible tener una imagen mental clara de la disposición binaria de la memoria. Supongamos una arquitectura de 64 bits (donde un puntero o usize ocupa exactamente 8 bytes):

TEXT
Estructura de String / Vec<T> en la Pila (Stack) - 24 bytes en total:
┌──────────────────┬──────────────────┬──────────────────┐
│  ptr: 0x55FA...  │  cap: 8          │  len: 5          │
│  (8 bytes)       │  (8 bytes)       │  (8 bytes)       │
└─────────┬────────┴──────────────────┴──────────────────┘
          │
          └────────────────────────┐
                                   ▼
Memoria Dinámica en el Montículo (Heap) - 8 bytes asignados:
┌────┬────┬────┬────┬────┬──────┬──────┬──────┐
│ 'h'│ 'e'│ 'l'│ 'l'│ 'o'│ [unallocated...]   │
└────┴────┴────┴────┴────┴──────┴──────┴──────┘

Estructura de un Slice &str / &[T] en la Pila (Stack) - 16 bytes en total:
┌──────────────────┬──────────────────┐
│  ptr: 0x55FA...  │  len: 5          │
│  (8 bytes)       │  (8 bytes)       │
└─────────┬────────┴──────────────────┘
          │
          └───────────> (Apunta directamente al inicio de los datos, sin poseerlos)

Un contenedor poseído (String, Vec<T>) almacena tres palabras en la pila. La capacidad (cap) le indica cuántos elementos caben en el búfer actual antes de tener que solicitar una reasignación costosa al sistema operativo.

Un slice (&str, &[T]) es un fat pointer (puntero gordo) compuesto exclusivamente de dos palabras: la dirección de memoria donde inician los datos y la longitud de la vista. Carece de capacidad porque un slice nunca puede crecer ni reasignar memoria; es un testigo de solo lectura (o mutación en sitio si es &mut [T]) de una porción de memoria ya existente.

2. ¿Por qué String se mueve y no implementa Copy?#

Analicemos qué sucedería a nivel de hardware si String implementara Copy:

  1. Creas let s1 = String::from("hello");. El descriptor de s1 se ubica en el stack (puntero 0x1000, capacidad 5, longitud 5).
  2. Escribes let s2 = s1;. Si fuera Copy, la CPU realizaría un memcpy de los 24 bytes del stack a s2. Ahora s2 también contiene el puntero 0x1000.
  3. Llegamos al final del bloque: - Se ejecuta el destructor de s2. Llama al asignador: deallocate(0x1000). El bloque de memoria se devuelve al sistema operativo. - Inmediatamente después, se ejecuta el destructor de s1. Llama de nuevo: deallocate(0x1000). - ¡DESASTRE! Double-Free vulnerability: El sistema operativo detecta que se intentó liberar memoria ya desasignada o peor aún, esa dirección ya fue entregada a otro hilo del proceso, corrompiendo datos ajenos.

Para prevenir esta vulnerabilidad sin añadir la penalización de un recolector de basura, *Rust aplica la semántica de movimiento (Move)*: al asignar let s2 = s1;, el compilador transfiere la titularidad a s2 y borra lógicamente a s1. Cuando el bloque termina, solo s2 llama a Drop.

3. El peligro del aliasing mutable: ¿Por qué &T y &mut T no pueden coexistir ni siquiera en un solo hilo?#

Muchos programadores asumen erróneamente que las referencias exclusivas solo son necesarias para la programación concurrente con múltiples hilos. Esto es completamente falso. La regla "Aliasing XOR Mutation" (o muchos lectores, o un solo escritor) protege la memoria en entornos monohilo frente a la invalidación de referencias por reasignación:

Imagina este escenario en un solo hilo:

RUST
let mut numbers = vec![10, 20]; // Supongamos capacidad inicial de 2 elementos
let reference_to_first = &numbers[0]; // Referencia compartida &i32 apuntando al elemento 0

numbers.push(30); // ¡PELIGRO! La capacidad se superó (len era 2, cap era 2)

¿Qué hace push(30) en el heap?

  1. Solicita un nuevo bloque de memoria para 4 enteros en otra dirección (por ejemplo, 0x9000).
  2. Copia 10 y 20 a 0x9000 y escribe 30.
  3. Libera el bloque original donde residían los datos iniciales.
  4. Si ahora intentas leer println!("{reference_to_first}");, estarías leyendo la dirección antigua liberada: ¡acabas de crear un *puntero colgante (dangling pointer)* y un fallo de segmentación!

El compilador de Rust detiene esto en seco emitiendo el error E0502: cannot borrow 'numbers' as mutable because it is also borrowed as immutable.

4. Coerción por desreferenciación (Deref Coercion)#

¿Por qué es preferible escribir funciones que acepten slices (&str o &[T]) en lugar de referencias a contenedores (&String o &Vec<T>)?

Rust implementa el trait Deref:

  • String implementa Deref<Target = str>.
  • Vec<T> implementa Deref<Target = [T]>.

Cuando una función declara su parámetro como fn process(slice: &str), puedes pasarle directamente un &str literal ("static"), una porción de cadena (&s[0..2]), o una referencia a un String (&my_string). El compilador aplica automáticamente la coerción por desreferenciación: toma el puntero y la longitud del descriptor de 3 palabras y construye el fat pointer de 2 palabras en cero ciclos de CPU adicionales.

Si declararas la función como fn process(s: &String), obligarías a quien te llama a poseer necesariamente un String en el heap, impidiendo que te pase cadenas literales o slices sin clonar memoria innecesariamente.


Diagrama#

DIAGRAMA MERMAID
flowchart LR
    A[Valor String con dueño] -->|préstamo &str| B[Lector temporal]
    A -->|fin del alcance| C[Limpieza del valor]
    D[Préstamo &mut T exclusivo] --> E[Modificación temporal]

Diagrama estático del tema
Diagrama estático del tema
Fuente editable: Mermaid.

Un préstamo no se convierte en dueño y no puede seguirse usando después de que el valor deje de existir.


Ejemplo razonado#

El siguiente bloque es un programa completo y ejecutable con prueba integrada. Demuestra cómo una función inspecciona texto y devuelve una porción sin copiar un solo byte de memoria del heap:

RUST
// Contract: Returns a borrowed slice corresponding to the first whitespace-delimited
// word in the provided text. If the text is empty or contains only whitespace,
// it returns an empty string slice.
fn first_word(text: &str) -> &str {
    match text.split_whitespace().next() {
        Some(word) => word,
        None => "",
    }
}

fn main() {
    let title = String::from("ownership has rules");
    let word = first_word(&title);
    println!("First word: {word}");
}

#[cfg(test)]
mod tests {
    use super::first_word;

    #[test]
    fn extracts_first_word_correctly() {
        let sentence = String::from("hello modular rust");
        assert_eq!(first_word(&sentence), "hello");
        assert_eq!(first_word(""), "");
        assert_eq!(first_word("   "), "");
    }
}

Comentario línea por línea y análisis técnico:#

  • Línea 4: fn first_word(text: &str) -> &str
  • La función acepta un fat pointer &str (16 bytes en la pila).
  • La elisión de tiempos de vida (lifetime elision) de Rust desazucara esta firma como: ``rust fn first_word<'a>(text: &'a str) -> &'a str ` Esto declara formalmente: "El slice devuelto está intrínsecamente vinculado a la vida del texto prestado text`". No puede sobrevivir a la variable original.
  • Líneas 5-8:
  • text.split_whitespace() es un iterador perezoso que escanea bytes buscando caracteres de espacio en blanco UTF-8.
  • .next() retorna Option<&str>. Si hay una palabra, devuelve un slice word que apunta a los bytes exactos dentro del buffer de text. No hay reserva en el heap ni duplicación de caracteres.
  • Si la entrada no contiene palabras, retorna un slice estático "" que vive en la memoria binaria del ejecutable.
  • Líneas 11-15 (main):
  • let title = String::from("ownership has rules");: Se reservan 24 bytes en el stack y 19 bytes en el heap.
  • let word = first_word(&title);: &title genera una coerción por desreferenciación a &str. word recibe un fat pointer de 16 bytes que apunta al byte 'o' con longitud 9.
  • Mientras word siga en uso, el compilador prohibirá cualquier mutación sobre title (como title.clear()), pues invalidaría los datos que word está inspeccionando.

Práctica independiente#

Parte 1: Implementar count_matching#

Escribe la función:

RUST
pub fn count_matching(items: &[String], prefix: &str) -> usize

Requisitos y pasos de desarrollo:#

  1. La función debe aceptar un slice de String (&[String]) para poder ser llamada con vectores completos (&vec), arreglos fijos o subsegmentos sin forzar copias de ownership.
  2. Debe iterar sobre los elementos usando .iter(), prestar cada cadena y comprobar si .starts_with(prefix) es verdadero.
  3. Debes validar los siguientes casos en tu arnés de pruebas: - Lista: ["notes", "task", "notebook"], prefijo "no" arrow debe retornar 2. - Misma lista, prefijo vacío "" arrow debe retornar 3 (toda cadena de texto comienza con el prefijo vacío). - Misma lista, prefijo ausente "xyz" arrow debe retornar 0. - Slice vacío [] con cualquier prefijo arrow debe retornar 0.

Parte 2: Experimento de laboratorio con E0502 (Invalidación por mutación de vector)#

Para comprobar empíricamente cómo el analizador de préstamos (borrow checker) previene punteros colgantes en el montículo, crea un archivo temporal experiment.rs con el siguiente código deliberadamente inválido:

RUST
fn main() {
    let mut numbers = vec![1, 2, 3];
    let first = &numbers[0]; // Préstamo inmutable de un elemento del búfer
    numbers.push(4);         // Mutación que podría provocar realocación del búfer
    println!("{first}");     // Uso del préstamo inmutable posterior a la mutación
}

Compila el archivo en tu terminal:

BASH
rustc experiment.rs

Diagnóstico del compilador a estudiar:#

TEXT
error[E0502]: cannot borrow `numbers` as mutable because it is also borrowed as immutable
 --> experiment.rs:4:5
  |
3 |     let first = &numbers[0];
  |                  ------- immutable borrow occurs here
4 |     numbers.push(4);
  |     ^^^^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |                ----- immutable borrow later used here

Análisis físico: El compilador detecta que first mantiene una referencia activa a una posición de memoria dentro del búfer de numbers. Dado que numbers.push(4) requiere acceso mutable exclusivo (&mut numbers) para potencialmente reasignar el búfer completo en el heap, Rust rechaza el código antes de que pueda generar un puntero colgante en tu computadora.


Preguntas de comprensión profunda y diagnóstico socrático#

Analiza minuciosamente estas cinco preguntas diseñadas para diagnosticar la solidez de tu modelo de memoria:

Pregunta 1: Traza de memoria en Stack y Heap#

Supón la siguiente secuencia de instrucciones:

RUST
let s1 = String::from("rust");
let s2 = s1;
  • Dibuja o describe los bytes exactos presentes en la pila y en el montículo para s1 tras la primera línea (en una arquitectura de 64 bits).
  • Tras ejecutarse la segunda línea (let s2 = s1;), ¿cuántos bytes nuevos se reservaron en el heap?
  • ¿Qué valor tienen ahora los campos del descriptor de s1 en la pila? ¿Por qué el compilador marca a s1 como no inicializado en lugar de simplemente permitir que siga existiendo?

Pregunta 2: La incompatibilidad entre Copy y Drop#

En la biblioteca estándar de Rust, existe una regla estricta: un tipo que implementa el trait Drop no puede implementar el trait Copy.

  • Si el compilador permitiera que una estructura que gestiona un descriptor de archivo (file handle) o un búfer de memoria implementara tanto Copy como Drop, ¿qué sucedería cuando dos copias de esa estructura salieran del alcance en momentos diferentes?
  • ¿Por qué tipos como [u8; 32] o (i32, bool) sí implementan Copy, mientras que (i32, String) no puede implementarlo?

Pregunta 3: Anatomía de Fat Pointers y Slices#

Considera las siguientes declaraciones en un sistema operativo de 64 bits:

RUST
let text: String = String::from("systems");
let r_text: &String = &text;
let slice: &str = &text[..];
  • ¿Cuántos bytes ocupa en el stack la variable text?
  • ¿Cuántos bytes ocupa en el stack la variable r_text?
  • ¿Cuántos bytes ocupa en el stack la variable slice?
  • ¿Por qué el tipo str desnudo (sin el &) se denomina "Dynamically Sized Type" (DST) y por qué es ilegal declarar una variable local let x: str; en la pila?

Pregunta 4: Aliasing y mutabilidad en un solo hilo#

Imagina que estás programando en C o C++ y escribes una función que recibe un puntero de lectura const int* current y un puntero de escritura int* target.

  • ¿Qué problema de aliasing ocurre si ambos punteros apuntan a la misma dirección de memoria física?
  • ¿Cómo garantiza la regla de Rust de "un único préstamo mutable O múltiples préstamos inmutables" que el compilador y el optimizador LLVM puedan asumir con total seguridad que ningún otro puntero modificará el valor leído por una referencia compartida?

Pregunta 5: Elisión de Lifetimes e invariantes de función#

Analiza este intento de implementación:

RUST
fn create_greeting() -> &str {
    let s = String::from("hola mundo");
    &s
}
  • ¿Por qué este código falla de forma terminante al compilar?
  • Explica qué ocurre con la memoria del heap asignada a s en el instante exacto en que la función create_greeting finaliza su ejecución.
  • ¿Qué tipo de retorno debe tener create_greeting para ser una función válida, segura y sin punteros colgantes?

Code smell#

Síntoma: Proliferación descontrolada de .clone() para apaciguar al compilador#

Un desarrollador recién llegado a Rust encuentra frecuentes errores E0382 (valor usado después de moverse) o E0502 (conflictos de préstamos). Para silenciar las advertencias, añade llamadas sistemáticas a .clone() en cada paso de su pipeline:

RUST
// Code smell: Clonación defensiva innecesaria
fn process_records(records: Vec<String>) -> usize {
    let copy_for_filtering = records.clone();
    let count = filter_records(copy_for_filtering);
    count
}
  • Evidencia observable: Múltiples invocaciones a .clone() sobre colecciones o cadenas en funciones que únicamente realizan operaciones de lectura o inspección.
  • Hipótesis del problema: El desarrollador está eludiendo el sistema de ownership en lugar de modelar los contratos de acceso mediante préstamos (&[String]).
  • Consecuencia concreta: Caída drástica del rendimiento del sistema: cada .clone() implica una llamada al asignador del sistema operativo, reserva de bloques en el montículo y copia profunda de todos los bytes en memoria. Con colecciones de miles de elementos, esto degrada la latencia y satura la CPU.
  • Tratamiento mínimo justificado: Cambiar la firma de la función para recibir una referencia o un slice (&[String] o &str). De esta forma, la función inspecciona los datos originales en su posición de memoria existente sin reservar un solo byte en el heap.
  • Cuándo NO refactorizar: Mantén .clone() cuando la función realmente necesite almacenar los datos de forma independiente (por ejemplo, al guardar un registro en una estructura de fondo que vivirá más tiempo que la petición que lo originó) o cuando el costo de clonar sea insignificante frente a la complejidad de introducir tiempos de vida anotados en estructuras complejas.

Contraejemplo deliberado: Uso de valor después de moverse (E0382)#

El siguiente programa es deliberadamente erróneo para que observes el diagnóstico emitido por el compilador. Guárdalo y compílalo con rustc main.rs:

RUST
fn main() {
    let first = String::from("note");
    let second = first; // Move ocurre aquí: first pierde la propiedad
    println!("{first}"); // Intento de lectura ilegal
}

Al compilar, Rust emite el diagnóstico:

TEXT
error[E0382]: borrow of moved value: `first`
 --> main.rs:4:16
  |
2 |     let first = String::from("note");
  |         ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
3 |     let second = first;
  |                  ----- value moved here
4 |     println!("{first}");
  |                ^^^^^ value borrowed here after move

Criterio de salida#

Puedes considerar superada esta lección con maestría cuando:

  • Puedes explicar con absoluta claridad la diferencia de tamaño y campos entre el descriptor de un String (3 palabras) y un &str (2 palabras).
  • Puedes trazar paso a paso qué ocurre en el stack y en el heap cuando una variable se mueve (move) y por qué String no puede implementar Copy.
  • Tu función count_matching recibe un slice &[String] y pasa todas las pruebas unitarias (incluyendo colecciones vacías y prefijos vacíos).
  • Has reproducido y analizado el error E0502 en el experimento de mutación con Vec::push, explicando por qué la realocación volvería inseguro el acceso a una referencia previa.

Lecturas oficiales#


Siguiente paso#

Continúa con 03 · Modelar errores y escribir pruebas, donde aplicarás estos contratos de ownership y tipado estricto a operaciones que pueden fallar, utilizando Option y Result para eliminar de raíz los punteros nulos y las excepciones no controladas.

Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)