Aprende Rust

Lección 06 de 26 Ownership y préstamos

Ownership: quién es dueño de cada valor

  • 8 min de lectura
  • 4 ejercicios
  1. Cada valor tiene un dueño: la variable que lo guarda. Aquí s1 es la dueña del texto "hola".
  2. let s2 = s1; mueve el valor: el texto no se copia, cambia de manos. Ahora la dueña es s2.
  3. Solo puede haber un dueño a la vez. s1 ya no es válida: si intentas usarla, el código no compila.
  4. Cuando la dueña sale de su ámbito (la llave }), Rust libera la memoria. Sin recolector de basura.
1/4

El ownership (propiedad) es la idea que hace único a Rust y, probablemente, la que más te costará al principio. Merece la pena: gracias a ella, Rust gestiona la memoria de forma segura sin recolector de basura y sin que tengas que liberarla a mano.

No es una función ni una librería: es un conjunto de reglas que el compilador comprueba al compilar. Si las incumples, el programa no compila. Y como todo se decide antes de ejecutar, no tiene ningún coste en tiempo de ejecución.

¿Por qué existe el ownership?

Todo programa reserva memoria mientras se ejecuta y, en algún momento, tiene que liberarla. Los lenguajes lo resuelven de tres formas:

EstrategiaLenguajesInconveniente
Recolector de basura (GC)JavaScript, Python, Java, GoGasta memoria y CPU extra, y puede pausar el programa
Gestión manualC, C++Si olvidas liberar, hay fugas; si liberas dos veces o usas memoria ya liberada, hay errores graves
OwnershipRustTienes que aprender sus reglas (justo lo que harás ahora)

Rust se queda con lo mejor de cada mundo: la memoria se libera sola, como con un GC, pero en el momento exacto y sin coste extra, como si lo hubieras hecho tú a mano.

El stack y el heap

Para entender el ownership necesitas conocer las dos zonas de memoria que usa tu programa:

  • El stack (pila) guarda los valores en orden y los quita en orden inverso, como una pila de platos. Es muy rápido, pero solo admite datos de tamaño fijo y conocido al compilar: un i32, un bool, un char…
  • El heap (montón) sirve para datos cuyo tamaño puede cambiar o no se conoce al compilar, como un texto que escribe el usuario. Pides espacio, el sistema lo busca y te devuelve un puntero: la dirección donde está. Es más lento y alguien tiene que liberar ese espacio cuando ya no haga falta.

Las variables locales de una función viven en el stack y desaparecen solas al terminar. El reto es el heap: saber qué parte del código usa cada dato, no dejar basura y no liberar nada dos veces. Eso es exactamente lo que resuelve el ownership.

Las tres reglas

Apréndetelas bien, porque todo lo demás se deriva de ellas:

  1. Cada valor tiene un dueño (owner): la variable que lo contiene.
  2. Solo puede haber un dueño a la vez.
  3. Cuando el dueño sale de su ámbito, el valor se libera.

Ámbito de una variable

El ámbito (scope) es la zona del programa en la que una variable es válida. Va desde que la declaras hasta la llave } que cierra su bloque:

fn main() {
    // aquí s todavía no existe
    {
        let s = "hola"; // s es válida desde aquí
        println!("{s}");
    } // fin del bloque: s deja de ser válida
    // aquí ya no puedes usar s
}

El tipo String

Los literales de texto como "hola" tienen un tamaño fijo y se guardan dentro del propio binario: son rápidos, pero no pueden cambiar. Para un texto que puede crecer, Rust tiene el tipo String, que guarda su contenido en el heap:

fn main() {
    let mut s = String::from("hola"); // crea un String a partir de un literal
    s.push_str(", mundo"); // añade texto al final
    println!("{s}");
}

Un String tiene dos partes:

  • En el stack: un puntero al texto, su longitud (len, los bytes que ocupa) y su capacidad (cap, los bytes reservados).
  • En el heap: los bytes del texto.

¿Y quién libera el heap? Su dueño. Cuando la variable sale de su ámbito, Rust llama automáticamente a una función especial, drop, que devuelve esa memoria al sistema. Sin GC y sin que escribas nada:

{
    let s = String::from("hola"); // se reserva memoria en el heap
    // trabajas con s
} // s sale de ámbito: Rust llama a drop y libera el heap

Move: la propiedad cambia de manos

Con números, asignar una variable a otra hace una copia y las dos siguen siendo válidas:

fn main() {
    let x = 5;
    let y = x; // se copia el 5
    println!("x = {x}, y = {y}");
}

Con un String pasa algo distinto:

let s1 = String::from("hola");
let s2 = s1;

Rust copia la parte del stack (puntero, longitud y capacidad), pero no el texto del heap. Si las dos variables fueran dueñas, al salir de ámbito ambas intentarían liberar la misma memoria: un error grave llamado double free. Para evitarlo, después de la asignación Rust considera que s1 ya no es válida. Se dice que el valor se ha movido (move) de s1 a s2:

Si intentas usar s1 después del move, el programa no compila. Pulsa Ejecutar para ver el error real:

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

El mensaje lo cuenta todo: s1 es un String, que no es Copy; en la línea 3 su valor se movió a s2 y en la línea 4 intentas usarlo. Lo de borrowed (prestado) es porque println! toma prestado lo que imprime: los préstamos los verás en la siguiente lección.

Importante: Rust nunca hace copias profundas de forma automática. Por eso una asignación siempre es barata: como mucho copia unos pocos bytes del stack.

Clone: copia profunda explícita

Si de verdad necesitas dos copias independientes, incluido el texto del heap, usa el método .clone():

fn main() {
    let s1 = String::from("hola");
    let s2 = s1.clone(); // copia también el texto del heap
    println!("s1 = {s1}, s2 = {s2}");
}

Ahora hay dos textos en el heap, cada uno con su dueño. Clonar puede ser caro si los datos son grandes, así que cuando veas .clone() en un código tómalo como una señal: ahí se duplica memoria a propósito.

Tipos Copy

Entonces, ¿por qué let y = x; no movía el número? Porque un entero tiene tamaño fijo y vive entero en el stack: copiarlo es tan barato que no hay diferencia entre copia superficial y profunda. Estos tipos son Copy: en lugar de moverse, se copian.

  • Todos los enteros: i32, u8, u64, usize…
  • Los booleanos: bool.
  • Los flotantes: f32 y f64.
  • Los caracteres: char.
  • Las tuplas y los arrays cuyos elementos sean todos Copy: (i32, f64) sí lo es, (i32, String) no.
fn main() {
    let activo = true;
    let otro = activo; // bool es Copy

    let punto = (3, 4);
    let copia = punto; // tupla de i32: también es Copy

    println!("{activo} {otro}");
    println!("({}, {}) y ({}, {})", punto.0, punto.1, copia.0, copia.1);
}

Una regla práctica: si el tipo guarda algo en el heap, como String, no es Copy y se mueve.

Ownership y funciones

Pasar un valor a una función funciona igual que asignarlo: los tipos Copy se copian y el resto se mueven al parámetro.

fn main() {
    let texto = String::from("hola");
    tomar_propiedad(texto); // texto se mueve a la función
    // a partir de aquí, texto ya no es válida

    let numero = 5;
    copiar(numero); // i32 es Copy: la función recibe una copia
    println!("numero sigue valiendo {numero}");
}

fn tomar_propiedad(s: String) {
    println!("Ahora soy la dueña de: {s}");
} // s sale de ámbito y se libera el heap

fn copiar(n: i32) {
    println!("He recibido una copia: {n}");
}

Si usas el String después de pasarlo a la función, vuelves a encontrarte con el error E0382:

fn main() {
    let saludo = String::from("hola");
    imprimir(saludo);
    imprimir(saludo);
}

fn imprimir(texto: String) {
    println!("{texto}");
}
error[E0382]: use of moved value: `saludo`
 --> src/main.rs:4:14
  |
3 |     imprimir(saludo);
  |              ------ value moved here
4 |     imprimir(saludo);
  |              ^^^^^^ value used here after move
  |
note: consider changing this parameter type in function `imprimir` to borrow instead if owning the value isn't necessary
help: consider cloning the value if the performance cost is acceptable

El compilador te propone dos soluciones: clonar el valor o hacer que la función lo tome prestado. La segunda suele ser la buena y la verás en la siguiente lección.

Cuidado con los bucles: Si mueves un valor dentro de un bucle, deja de ser válido en la primera vuelta y el compilador te avisará con value moved here, in previous iteration of loop.

Devolver la propiedad

Una función también puede devolver un valor y, con él, su propiedad. El nuevo dueño es quien recibe el resultado:

fn main() {
    let s1 = crear_saludo(); // crear_saludo le da la propiedad a s1
    let s2 = String::from("Hola, Rust");
    let s3 = anadir_firma(s2); // s2 se mueve a la función y vuelve en s3
    println!("{s1}");
    println!("{s3}");
}

fn crear_saludo() -> String {
    let saludo = String::from("Buenos días");
    saludo // se devuelve: la propiedad pasa a quien llama
}

fn anadir_firma(texto: String) -> String {
    let mut resultado = texto; // la propiedad pasa a una variable mutable
    resultado.push_str(" (firmado: Ferris)");
    resultado
}

Funciona, pero imagina que solo quieres saber la longitud de un texto sin quedarte con él. Tendrías que devolverlo junto al resultado en una tupla:

fn main() {
    let s1 = String::from("hola");
    let (s2, longitud) = calcular_longitud(s1);
    println!("«{s2}» tiene {longitud} bytes");
}

fn calcular_longitud(s: String) -> (String, usize) {
    let longitud = s.len(); // len() devuelve el número de bytes
    (s, longitud)
}

Demasiada ceremonia para algo tan simple. Por suerte, Rust permite usar un valor sin tomar su propiedad: con referencias, el tema de la siguiente lección.

Resumen

OperaciónTipo Copy (i32, bool, char…)Tipo con heap (String)
let b = a;Copia: a sigue siendo válidaMove: a deja de ser válida
funcion(a)La función recibe una copiaEl valor se mueve a la función
a.clone()No hace faltaCopia profunda, heap incluido
Fin del ámbitoDesaparece del stackdrop libera el heap

Clave: Cada valor tiene un único dueño. Mover es pasar la propiedad, clonar es duplicar y, cuando el dueño desaparece, el valor se libera. Si entiendes esto, ya tienes lo más difícil de Rust.

Hora de practicar

Ejercicios

0/4 resueltos

Reto 1 de 4

Este programa no compila porque el valor de original se mueve a copia. Arréglalo para que imprima los dos textos sin quitar ningún println!: haz una copia profunda del String.
Salida esperada
original: Ferris
copia: Ferris
src/main.rs
$ cargo run Listo

Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.

¿Algo no cuadra en este reto? Avísanos