Ownership y Borrowing en Rust: Guía Completa

Domina el sistema de ownership y borrowing de Rust. Reglas de propiedad, referencias, lifetimes y patrones avanzados de gestión de memoria.

Ownership y Borrowing en Rust - Guía Completa

El sistema de ownership es lo que distingue a Rust de cualquier otro lenguaje de programación. Este enfoque único garantiza la seguridad de memoria sin recolector de basura, detectando errores en tiempo de compilación en lugar de en ejecución. Esta guía exhaustiva explora los mecanismos de ownership y borrowing, desde los fundamentos hasta los patrones avanzados de producción.

Filosofía de Rust

El compilador de Rust actúa como un asistente de programación exigente: cada error de ownership bloqueado en tiempo de compilación representa un bug potencial evitado en producción.

Las Tres Reglas Fundamentales del Ownership

El sistema de ownership descansa sobre tres reglas simples pero estrictas. Una vez interiorizadas, el modelo mental de Rust se vuelve natural y predecible.

ownership_rules.rsrust
// Demonstration of the three fundamental rules

fn main() {
    // Rule 1: Each value has exactly ONE owner
    let s1 = String::from("hello");  // s1 is the sole owner

    // Rule 2: There can only be one owner at a time
    let s2 = s1;  // Ownership transferred (moved) from s1 to s2
    // println!("{}", s1);  // Compile ERROR: s1 no longer exists
    println!("s2 = {}", s2);  // Only s2 is valid now

    // Rule 3: When the owner goes out of scope, the value is dropped
    {
        let s3 = String::from("temporary");
        println!("s3 inside block = {}", s3);
    }  // s3 is automatically freed here (drop is called)
    // println!("{}", s3);  // ERROR: s3 no longer exists
}

Estas tres reglas eliminan categorías enteras de bugs: use-after-free, double-free y fugas de memoria. El compilador verifica estáticamente que se respeten estas reglas.

Move vs Copy: Entender la Semántica de Transferencia

El comportamiento de la asignación depende del tipo de dato. Los tipos que implementan el trait Copy se duplican, mientras que los demás se transfieren mediante move.

move_vs_copy.rsrust
// Distinction between Copy types and Move types

fn main() {
    // Copy types: values stored on the stack, known size
    let x: i32 = 42;
    let y = x;  // x is COPIED, not moved
    println!("x = {}, y = {}", x, y);  // Both are valid

    // Other Copy types: f64, bool, char, tuples of Copy types
    let point = (3.0, 4.0);
    let point_copy = point;  // Tuple copy
    println!("Original: {:?}, Copy: {:?}", point, point_copy);

    // Move types: values on the heap, dynamic size
    let s1 = String::from("owned");
    let s2 = s1;  // s1 is MOVED to s2
    // println!("{}", s1);  // ERROR: value moved
    println!("s2 = {}", s2);

    // Vec, HashMap, Box are also Move types
    let vec1 = vec![1, 2, 3];
    let vec2 = vec1;  // Move, not copy
    // println!("{:?}", vec1);  // ERROR
    println!("vec2 = {:?}", vec2);
}

// Explicit clone to duplicate Move types
fn explicit_clone() {
    let original = String::from("important data");
    let clone = original.clone();  // Explicit duplication (memory cost)

    println!("Original: {}", original);  // Still valid
    println!("Clone: {}", clone);  // Independent copy
}

La distinción Move/Copy es fundamental: determina si la asignación transfiere la propiedad o crea una copia independiente.

Cuándo Usar Clone

Llamar a .clone() debe ser intencional. Un código lleno de clones puede indicar un problema de diseño. El borrowing suele ser una mejor solución.

Borrowing: Referencias Inmutables y Mutables

El borrowing permite acceder a un valor sin tomar su propiedad. Este mecanismo hace que el código Rust sea seguro y eficiente al mismo tiempo.

borrowing_basics.rsrust
// Immutable and mutable references

fn main() {
    let s = String::from("hello");

    // Immutable reference: read-only, multiple allowed
    let len = calculate_length(&s);  // Immutable borrow
    println!("'{}' has {} characters", s, len);  // s still valid

    // Multiple simultaneous immutable references: OK
    let r1 = &s;
    let r2 = &s;
    let r3 = &s;
    println!("r1={}, r2={}, r3={}", r1, r2, r3);
}

fn calculate_length(s: &String) -> usize {
    // s is a reference, not the owner
    s.len()
}  // s goes out of scope but doesn't drop anything (not owner)

// Mutable references: modification allowed
fn mutable_borrowing() {
    let mut s = String::from("hello");

    change(&mut s);  // Mutable borrow
    println!("After modification: {}", s);
}

fn change(s: &mut String) {
    s.push_str(", world!");  // Modification via mutable reference
}

La regla de oro del borrowing: o bien múltiples referencias inmutables O una sola referencia mutable, nunca ambas simultáneamente.

Reglas del Borrow Checker

El borrow checker es el componente del compilador que verifica las reglas de borrowing. Comprender sus errores permite resolver problemas rápidamente.

borrow_checker_rules.rsrust
// Strict borrow checker rules

fn main() {
    // RULE 1: No mutable reference with immutable references
    let mut s = String::from("hello");

    let r1 = &s;      // Immutable reference: OK
    let r2 = &s;      // Another immutable reference: OK
    // let r3 = &mut s;  // ERROR: cannot borrow as mutable
    println!("{} and {}", r1, r2);

    // AFTER using r1 and r2, they are "dead"
    let r3 = &mut s;  // Now OK: r1 and r2 no longer used
    r3.push_str(" world");
    println!("{}", r3);

    // RULE 2: Only one mutable reference at a time
    let mut data = String::from("exclusive");
    let ref1 = &mut data;
    // let ref2 = &mut data;  // ERROR: already borrowed mutably
    ref1.push_str("!");
    println!("{}", ref1);
}

// RULE 3: References cannot outlive the data
fn dangling_reference_prevented() {
    let reference;
    {
        let s = String::from("short-lived");
        // reference = &s;  // ERROR: s doesn't live long enough
    }
    // s is dropped here, reference would be invalid

    // Solution: move the value out of the scope
    let owned_outside;
    {
        let s = String::from("moved out");
        owned_outside = s;  // Move, not reference
    }
    println!("{}", owned_outside);  // OK: owned_outside is the owner
}

El borrow checker utiliza Non-Lexical Lifetimes (NLL): una referencia se considera activa solamente hasta su último uso, no hasta el final del scope.

¿Listo para aprobar tus entrevistas de Rust?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Lifetimes: Anotar la Duración de las Referencias

Los lifetimes son anotaciones que ayudan al compilador a verificar que las referencias permanecen válidas. La mayor parte del tiempo se infieren automáticamente.

lifetimes_explained.rsrust
// Explicit lifetime annotations

// Without annotation: compiler infers lifetimes
fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

// With explicit annotation: same function
fn first_word_explicit<'a>(s: &'a str) -> &'a str {
    // 'a means: returned reference lives as long as the input
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

// When annotations are necessary: multiple references
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // Compiler cannot guess which reference is returned
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(&string1, &string2);
        println!("Longest: {}", result);  // OK here
    }
    // println!("{}", result);  // ERROR if uncommented: string2 dropped
}

Los lifetimes no cambian la duración de vida de los datos; describen las relaciones entre las duraciones de vida de diferentes referencias.

Lifetimes en Structs

Cuando un struct contiene referencias, los lifetimes deben anotarse para garantizar que el struct no sobreviva a los datos referenciados.

struct_lifetimes.rsrust
// Structs containing references

// Struct with reference: lifetime required
struct ImportantExcerpt<'a> {
    part: &'a str,  // This reference must live at least as long as the struct
}

impl<'a> ImportantExcerpt<'a> {
    // Method returning a reference with the same lifetime
    fn level(&self) -> i32 {
        3
    }

    // Elision rule: &self implies the output lifetime
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention: {}", announcement);
        self.part  // Returns with 'a lifetime from self
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();

    let excerpt = ImportantExcerpt {
        part: first_sentence,  // OK: novel outlives excerpt
    };

    println!("Excerpt: {}", excerpt.part);
    println!("Level: {}", excerpt.level());
}

// Static lifetime: reference valid for the entire program duration
fn static_lifetime_example() {
    let s: &'static str = "This string is in the binary";
    // String literals always have 'static lifetime
    println!("{}", s);
}

Las reglas de elisión de lifetimes a menudo permiten omitir las anotaciones en los casos comunes, haciendo el código más legible.

Patrones Avanzados: Mutabilidad Interior

A veces la mutabilidad debe verificarse en tiempo de ejecución en lugar de en compilación. Rust proporciona tipos para este patrón: RefCell y Cell.

interior_mutability.rsrust
// Interior mutability with RefCell and Cell

use std::cell::{Cell, RefCell};

// Cell: for Copy types, replaces the entire value
struct Counter {
    count: Cell<u32>,  // Mutable despite &self
}

impl Counter {
    fn new() -> Counter {
        Counter { count: Cell::new(0) }
    }

    fn increment(&self) {
        // Modification via immutable reference!
        self.count.set(self.count.get() + 1);
    }

    fn get(&self) -> u32 {
        self.count.get()
    }
}

// RefCell: for non-Copy types, checks at runtime
struct CachedValue {
    value: RefCell<Option<String>>,
}

impl CachedValue {
    fn new() -> CachedValue {
        CachedValue { value: RefCell::new(None) }
    }

    fn get_or_compute(&self, compute: impl FnOnce() -> String) -> String {
        // borrow() for reading, borrow_mut() for writing
        if self.value.borrow().is_none() {
            *self.value.borrow_mut() = Some(compute());
        }
        self.value.borrow().as_ref().unwrap().clone()
    }
}

fn main() {
    let counter = Counter::new();
    counter.increment();
    counter.increment();
    println!("Counter: {}", counter.get());  // 2

    let cache = CachedValue::new();
    let result = cache.get_or_compute(|| {
        println!("Expensive computation...");
        String::from("result")
    });
    println!("Value: {}", result);

    // Second call: no recomputation
    let result2 = cache.get_or_compute(|| String::from("never executed"));
    println!("Cache hit: {}", result2);
}

RefCell y Cell trasladan la verificación del borrowing al tiempo de ejecución. Una violación de regla provoca un panic en lugar de un error de compilación.

Cuidado con los Panics

RefCell::borrow_mut() provoca panic si el valor ya está prestado. Conviene usar try_borrow_mut() para una gestión explícita de errores.

Smart Pointers y Ownership

Los smart pointers como Box, Rc y Arc ofrecen diferentes estrategias de ownership para casos de uso específicos.

smart_pointers.rsrust
// Box, Rc, and Arc for different ownership patterns

use std::rc::Rc;
use std::sync::Arc;
use std::thread;

// Box: single owner, data on the heap
fn box_example() {
    let boxed = Box::new(vec![1, 2, 3, 4, 5]);
    println!("Boxed vec: {:?}", boxed);
    // Useful for: recursive types, large objects, trait objects
}

// Rc: reference counting, multiple owners (single-thread)
fn rc_example() {
    let data = Rc::new(String::from("shared data"));

    let clone1 = Rc::clone(&data);  // Increments the counter
    let clone2 = Rc::clone(&data);

    println!("Count: {}", Rc::strong_count(&data));  // 3
    println!("All share: {}, {}, {}", data, clone1, clone2);
}  // Freed when counter reaches 0

// Arc: thread-safe Rc (Atomic Reference Counting)
fn arc_example() {
    let data = Arc::new(vec![1, 2, 3]);

    let handles: Vec<_> = (0..3).map(|i| {
        let data_clone = Arc::clone(&data);
        thread::spawn(move || {
            println!("Thread {}: {:?}", i, data_clone);
        })
    }).collect();

    for handle in handles {
        handle.join().unwrap();
    }
}

fn main() {
    box_example();
    rc_example();
    arc_example();
}

La elección del smart pointer depende del patrón de ownership: único (Box), compartido en un solo hilo (Rc) o compartido entre múltiples hilos (Arc).

Patrones Prácticos de Ownership

A continuación se presentan patrones comunes para estructurar código en torno al sistema de ownership.

ownership_patterns.rsrust
// Practical patterns for ownership management

// Pattern 1: Builder pattern with chained ownership
struct RequestBuilder {
    url: String,
    headers: Vec<(String, String)>,
    timeout: Option<u64>,
}

impl RequestBuilder {
    fn new(url: &str) -> Self {
        RequestBuilder {
            url: url.to_string(),
            headers: Vec::new(),
            timeout: None,
        }
    }

    // Consumes self and returns the new self
    fn header(mut self, key: &str, value: &str) -> Self {
        self.headers.push((key.to_string(), value.to_string()));
        self  // Returns ownership
    }

    fn timeout(mut self, seconds: u64) -> Self {
        self.timeout = Some(seconds);
        self
    }

    fn build(self) -> Request {
        Request {
            url: self.url,
            headers: self.headers,
            timeout: self.timeout.unwrap_or(30),
        }
    }
}

struct Request {
    url: String,
    headers: Vec<(String, String)>,
    timeout: u64,
}

// Pattern 2: Cow (Copy-on-Write) to avoid allocations
use std::borrow::Cow;

fn process_text(input: &str) -> Cow<str> {
    if input.contains("REPLACE") {
        // Allocation only if modification needed
        Cow::Owned(input.replace("REPLACE", "NEW"))
    } else {
        // No allocation, returns a reference
        Cow::Borrowed(input)
    }
}

// Pattern 3: Take to extract from an Option
fn extract_value(data: &mut Option<String>) -> String {
    data.take().unwrap_or_else(|| String::from("default"))
    // take() replaces with None and returns ownership of the value
}

fn main() {
    // Builder pattern
    let request = RequestBuilder::new("https://api.example.com")
        .header("Authorization", "Bearer token")
        .header("Content-Type", "application/json")
        .timeout(60)
        .build();

    println!("URL: {}, Timeout: {}s", request.url, request.timeout);

    // Cow pattern
    let text1 = process_text("hello world");  // No allocation
    let text2 = process_text("hello REPLACE");  // Allocation
    println!("{} | {}", text1, text2);

    // Take pattern
    let mut optional = Some(String::from("extracted"));
    let value = extract_value(&mut optional);
    println!("Value: {}, Option: {:?}", value, optional);  // None
}

Estos patrones aprovechan el sistema de ownership para crear APIs ergonómicas y eficientes.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Conclusión

El sistema de ownership y borrowing de Rust representa un cambio de paradigma en la gestión de memoria. Una vez dominado, se convierte en un aliado poderoso para escribir código eficiente y seguro a la vez.

Puntos clave a retener:

✅ Tres reglas de ownership: propietario único, transferencia de propiedad, drop automático

✅ Borrowing: múltiples referencias inmutables O una referencia mutable exclusiva

✅ Lifetimes: anotar las relaciones entre las duraciones de vida de las referencias

✅ Mutabilidad interior: RefCell y Cell para una mutabilidad verificada en tiempo de ejecución

✅ Smart pointers: Box (único), Rc (compartido), Arc (thread-safe)

✅ Patrones prácticos: Builder, Cow, Take para APIs idiomáticas

El borrow checker puede parecer estricto al principio, pero cada error que señala representa un bug potencial evitado. Con la práctica, pensar en términos de ownership se vuelve natural y mejora la calidad del código en todos los lenguajes.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

#rust
#ownership
#borrowing
#memory management
#systems programming

Compartir

Artículos relacionados