En esta página

Ownership: el corazón de Rust

15 min lectura TextoCap. 2 — Ownership y borrowing

Ownership: el concepto central de Rust

El sistema de ownership (propiedad) es lo que distingue a Rust de todos los demás lenguajes de programación. Es el mecanismo que permite a Rust garantizar seguridad de memoria sin un recolector de basura (GC).

Para entender por qué ownership existe, primero necesitamos entender el problema que resuelve.

El problema: gestión de memoria

Todos los programas deben gestionar la memoria que usan. Existen dos enfoques principales:

  1. Recolección de basura (GC): El lenguaje rastrea automáticamente qué memoria ya no se usa y la libera. Ventaja: simple para el programador. Desventaja: pausas impredecibles, mayor uso de memoria, no apto para sistemas en tiempo real.

  2. Gestión manual: El programador es responsable de malloc/free o new/delete. Ventaja: control total. Desventaja: errores de use-after-free, double-free, memory leaks, buffer overflows.

Rust toma un tercer camino: el compilador verifica reglas de ownership en tiempo de compilación y genera código que libera memoria automáticamente en el momento exacto correcto, sin ningún overhead en tiempo de ejecución.

Las tres reglas de Ownership

Rust tiene exactamente tres reglas:

  1. Cada valor en Rust tiene exactamente un owner (propietario).
  2. Solo puede haber un owner a la vez.
  3. Cuando el owner sale de scope, el valor se descarta (drop).

Estas reglas suenan simples pero tienen implicaciones profundas.

Stack vs Heap

Para entender ownership, necesitas tener claro la diferencia entre stack y heap:

Stack (pila):

  • Memoria LIFO (last in, first out)
  • Tamaño conocido en tiempo de compilación
  • Asignación y desasignación instantáneas
  • Muy rápido
  • Ejemplos: i32, f64, bool, char, [i32; 5]

Heap:

  • Memoria dinámica, puede crecer o reducirse
  • El OS busca un espacio libre y devuelve un puntero
  • Más lento que el stack
  • Requiere gestión explícita (en C/C++) u ownership (en Rust)
  • Ejemplos: String, Vec<T>, Box<T>
fn main() {
    // En el stack: tamaño fijo, copiado automáticamente
    let x: i32 = 5;    // 4 bytes en el stack
    let y = x;         // Copia trivial
    println!("x={x}, y={y}"); // Ambos válidos
    
    // En el heap: String tiene 3 partes en el stack:
    // - puntero al heap (8 bytes)
    // - longitud actual (8 bytes)
    // - capacidad (8 bytes)
    // Más el contenido en el heap
    let s1 = String::from("hola");
    println!("s1 apunta a {} bytes en el heap", s1.capacity());
}

Move semantics (semántica de movimiento)

Cuando asignas un valor de heap a otra variable, Rust realiza un move (movimiento), no una copia:

fn main() {
    let s1 = String::from("hola");
    let s2 = s1; // s1 se MUEVE a s2
    
    // En este punto:
    // - s2 es el owner del String "hola"
    // - s1 ya NO es válido — el compilador lo sabe
    
    println!("{s2}"); // OK
    // println!("{s1}"); // Error: "value borrowed here after move"
}

¿Por qué no simplemente copiar? Si Rust copiara automáticamente, tendríamos dos owners del mismo dato en el heap. Cuando ambos salieran de scope, se intentaría liberar la misma memoria dos veces — un error de "double free" que causa comportamiento indefinido.

En lugar de copiar, Rust invalida s1 cuando se mueve a s2. Solo s2 libera la memoria cuando sale de scope.

Clone: copia explícita del heap

Cuando sí necesitas una copia completa de un valor en el heap, usa .clone():

fn main() {
    let s1 = String::from("hola");
    let s2 = s1.clone(); // Copia profunda explícita
    
    println!("s1 = {s1}"); // OK: s1 sigue siendo válido
    println!("s2 = {s2}"); // OK: s2 es una copia independiente
    
    // Modificar s2 no afecta s1
    let mut s3 = s1.clone();
    s3.push_str(" mundo");
    println!("s1 = {s1}");  // "hola"
    println!("s3 = {s3}");  // "hola mundo"
}

El punto clave: .clone() es costoso y explícito. Cuando lo ves en el código, sabes que hay una copia de heap ocurriendo.

El trait Copy

Los tipos "baratos de copiar" implementan el trait Copy. Para estos tipos, la asignación siempre crea una copia en lugar de un move:

fn main() {
    // Tipos Copy: la asignación copia, no mueve
    let a: i32 = 10;
    let b = a;     // Copia
    let c: f64 = 3.14;
    let d = c;     // Copia
    let e: bool = true;
    let f = e;     // Copia
    let g: char = 'Ñ';
    let h = g;     // Copia
    
    // Todos siguen siendo válidos
    println!("{a} {b} {c} {d} {e} {f} {g} {h}");
    
    // Las referencias &T son Copy (la referencia se copia, no el dato)
    let s = String::from("texto");
    let r1: &String = &s;
    let r2 = r1; // Copia la referencia
    println!("{r1} {r2}"); // Ambas válidas
}

Un tipo puede implementar Copy solo si todos sus campos también son Copy. String no puede ser Copy porque gestiona memoria del heap.

Ownership y funciones

El ownership sigue las mismas reglas cuando pasas valores a funciones:

fn main() {
    let s = String::from("Rust");
    
    // Pasar s MUEVE el ownership a la función
    imprimir(s);
    // println!("{s}"); // Error: s fue movido
    
    // Para no perder el ownership, la función puede retornarlo
    let s2 = String::from("Ownership");
    let s2 = retornar_ownership(s2);
    println!("Recibí de vuelta: {s2}");
    
    // Esto es verboso — las referencias (siguiente lección) lo solucionan
}

fn imprimir(cadena: String) {
    println!("{cadena}");
} // cadena se dropea aquí

fn retornar_ownership(cadena: String) -> String {
    println!("{cadena}");
    cadena // Retorna el ownership al caller
}

Drop: liberación automática de memoria

Cuando un owner sale de scope, Rust llama automáticamente al destructor del valor, implementado a través del trait Drop:

struct RecursoImportante {
    nombre: String,
}

impl Drop for RecursoImportante {
    fn drop(&mut self) {
        println!("Liberando recurso: {}", self.nombre);
    }
}

fn main() {
    let r1 = RecursoImportante { nombre: String::from("Conexión DB") };
    let r2 = RecursoImportante { nombre: String::from("Archivo de log") };
    
    println!("Recursos creados");
    
    // Al salir de scope, Rust llama drop() automáticamente
    // en orden inverso de creación: r2 primero, luego r1
    println!("Terminando main...");
} // r2.drop() luego r1.drop() — sin intervención del programador

Este patrón, llamado RAII (Resource Acquisition Is Initialization), garantiza que los recursos siempre se liberan, incluso si hay una condición de error. No es posible olvidar cerrar una conexión de base de datos en Rust.

Ownership con estructuras de control

fn main() {
    let nombre = String::from("Rustáceo");
    
    // El ownership se mueve al bloque if que lo consume
    if nombre.len() > 3 {
        let saludo = format!("¡Hola, {nombre}!");
        println!("{saludo}");
        // nombre se puede usar aquí
        println!("Tu nombre tiene {} letras", nombre.len());
    }
    
    // nombre sigue disponible aquí porque no fue movido
    println!("Nombre final: {nombre}");
    
    // Ejemplo donde SÍ se mueve:
    let datos = String::from("datos importantes");
    let procesados = procesar(datos); // datos se mueve
    // datos ya no está disponible
    println!("Procesado: {procesados}");
}

fn procesar(entrada: String) -> String {
    entrada.to_uppercase()
}

El sistema de ownership puede sentirse restrictivo al principio, pero es el fundamento de todas las garantías de seguridad de Rust. La siguiente lección te enseñará references y borrowing — la forma elegante de usar datos sin transferir ownership.

El trait Copy
Los tipos que implementan Copy se copian automáticamente en lugar de moverse. Estos son: todos los enteros (i32, u64...), flotantes (f32, f64), bool, char, referencias (&T), y tuplas/arrays de tipos Copy. Los tipos con heap (String, Vec) NO son Copy.
Drop y el destructor
Cuando un owner sale de scope, Rust llama automáticamente a drop(), que libera la memoria del heap. Esto garantiza que nunca habrá memory leaks en código Rust seguro. No necesitas free() ni delete().
rust
fn main() {
    // String en el heap — tiene owner
    let s1 = String::from("hola");

    // MOVE: s1 se mueve a s2, s1 ya no es válido
    let s2 = s1;
    // println!("{s1}"); // Error: value moved

    println!("s2 = {s2}");

    // CLONE: copia profunda del heap
    let s3 = s2.clone();
    println!("s2 = {s2}, s3 = {s3}");

    // Tipos Copy viven en el stack: se copian automáticamente
    let x: i32 = 5;
    let y = x;  // Copia, no move
    println!("x = {x}, y = {y}"); // Ambos válidos

    // Ownership y funciones
    let cadena = String::from("mundo");
    tomar_ownership(cadena);
    // println!("{cadena}"); // Error: moved

    let numero = 42_i32;
    hacer_copia(numero);
    println!("numero sigue siendo {numero}"); // Válido: i32 es Copy
}

fn tomar_ownership(s: String) {
    println!("Tomé ownership de: {s}");
} // s se dropea aquí

fn hacer_copia(n: i32) {
    println!("Tengo una copia: {n}");
}