Ownership en Rust: quién es el dueño de cada valor y cuándo muere
Durante años usé Go para todo lo que no hacía con Rails, y en todo ese tiempo no me hice ni una vez una pregunta que en Rust te haces en cada línea: ¿quién es el dueño de este valor? En Go no hace falta. Creas un slice, se lo pasas a tres funciones, lo guardas en un struct y lo devuelves en un canal, y el recolector de basura se encarga de liberarlo cuando nadie lo mire. Hoy ese hueco lo ocupa Rust, y la pregunta del dueño es la primera que hay que aprender a responder.
En Go frente a Rust, fase a fase dije que el borrow checker es lo que hace lento el primer binario en Rust, y enseñé un error de préstamo. Pero el préstamo es la segunda mitad de la historia. La primera es la propiedad (ownership), y es lo que voy a contar aquí: las reglas, qué pasa en memoria cuando asignas, cuándo se libera cada cosa y qué haces cuando un valor necesita más de un dueño. El post sobre préstamos sale dentro de dos días.
Todos los fragmentos están compilados con Rust 1.98 y la edición 2024, y los errores son la salida real de cargo, recortada donde sobraba.
Tres maneras de responder a «¿quién libera esto?»
Cualquier lenguaje que reserve memoria dinámica tiene que decidir quién la devuelve.
C deja la respuesta en tus manos. Llamas a malloc, llamas a free, y entre una cosa y otra el compilador no comprueba nada. Si liberas dos veces tienes un double free; si usas el puntero después de liberarlo, un use-after-free; si se te olvida, una fuga. Las dos primeras no son errores de estilo: son de las fuentes clásicas de vulnerabilidades.
Go responde en tiempo de ejecución. El recolector de basura recorre la memoria alcanzable y libera lo demás cuando le parece oportuno. Es cómodo y seguro, y cuesta dos cosas: algo de CPU y de latencia, y no saber cuándo se libera algo. Por eso los recursos que no son memoria se cierran a mano con defer f.Close(), y por eso la documentación de runtime.SetFinalizer y de runtime.AddCleanup (Go 1.24) advierte que no hay garantía de que se ejecuten antes de que el programa termine.
Rust responde en tiempo de compilación. Cada valor tiene un dueño, el compilador sabe en qué línea deja de existir ese dueño y en esa línea inserta la liberación. Sin recolector y sin free escrito a mano. A cambio, el compilador te obliga a que el dueño esté claro.
Las tres reglas
El capítulo 4 de The Rust Programming Language las resume en tres frases:
- Cada valor en Rust tiene un dueño (owner).
- Solo puede haber un dueño a la vez.
- Cuando el dueño sale de ámbito, el valor se libera (dropped).
Leídas así parecen obvias. La segunda es la que choca viniendo de Go, porque significa que una asignación normal no siempre copia:
fn main() {
let s1 = String::from("hola");
let s2 = s1; // la propiedad pasa a s2
println!("{s1}, {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; // la propiedad pasa a s2
| -- value moved here
4 | println!("{s1}, {s2}");
| ^^ value borrowed here after move
E0382 es el error de propiedad por excelencia, y el mensaje cuenta la historia completa en tres anotaciones: 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. Tras el move, s1 sigue existiendo como nombre, pero el compilador la da por vacía de contenido y no te deja leerla.
Pila, montículo y por qué una asignación mueve
Para entender el move hay que mirar qué es un String. En la pila ocupa tres palabras: un puntero al búfer del montículo donde están los bytes, la longitud que se usa y la capacidad reservada. Se puede comprobar:
use std::mem::size_of;
fn main() {
let mut s = String::with_capacity(16);
s.push_str("hola");
// Lo que vive en la pila: puntero, longitud y capacidad.
println!("size_of::<String>() = {} bytes", size_of::<String>());
println!("len = {}, capacity = {}", s.len(), s.capacity());
let n: i64 = 42;
let m = n; // i64 es Copy: n sigue siendo válido
println!("{n} {m}");
}
size_of::<String>() = 24 bytes
len = 4, capacity = 16
42 42
Veinticuatro bytes en una máquina de 64 bits: tres veces ocho. Cuando escribes let s2 = s1, Rust copia esos 24 bytes, no los del montículo. Si después dejara vivas las dos variables, habría dos punteros al mismo búfer, y al salir de ámbito los dos intentarían liberarlo: un double free de manual. La regla del dueño único lo resuelve por las bravas: tras copiar la cabecera, la variable de origen queda invalidada. Eso es un move. No hay copia profunda ni recolector, solo 24 bytes copiados y un nombre que deja de valer.
Go hace la misma copia superficial, pero deja vivos los dos nombres:
package main
import "fmt"
func main() {
s1 := []string{"rust", "go"}
s2 := s1 // se copia la cabecera; el array de debajo es el mismo
s2[0] = "c"
fmt.Println(s1[0], s2[0]) // c c: dos nombres, un solo array, y el GC lo vigila
}
En Go esto es legal y seguro en cuanto a memoria: el recolector solo liberará el array cuando no lo alcance ninguna de las dos cabeceras. El precio es que el aliasing es invisible, y cualquiera que tenga una copia del slice puede cambiar lo que ve el otro. Rust prefiere prohibirlo por defecto y que lo pidas de forma explícita.
Copy y Clone: la copia barata y la cara
En el ejemplo anterior let m = n no movió nada. i64 implementa el trait Copy, y para los tipos Copy la asignación es una copia bit a bit tras la que el original sigue siendo válido. El libro enumera los casos de la biblioteca estándar: los enteros, los flotantes, bool, char y las tuplas cuyos elementos sean todos Copy. A eso hay que sumar los arrays de elementos Copy y las referencias compartidas &T. Las referencias mutables &mut T no lo son, porque copiarlas crearía dos caminos para mutar lo mismo.
Clone es otra cosa: una copia explícita, con .clone(), que puede hacer todo el trabajo que haga falta. Clonar un String reserva otro búfer y copia los bytes. La documentación de Copy lo dice sin rodeos: una copia bit a bit de un String solo duplicaría el puntero y acabaría en un double free, «for this reason, String is Clone but not Copy».
Tus propios tipos pueden ser Copy si todos sus campos lo son:
#[derive(Clone, Copy)]
struct Punto {
x: i32,
y: i32,
}
fn main() {
let a = Punto { x: 1, y: 2 };
let b = a; // copia bit a bit: a sigue siendo válido
println!("a = ({}, {}), b = ({}, {})", a.x, a.y, b.x, b.y);
let s = String::from("hola");
let t = s.clone(); // copia profunda, y se ve en el código
println!("{s} {t}");
}
Si un campo no es Copy, el derive falla con E0204:
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct Post {
| ^^^^
3 | titulo: String,
| -------------- this field does not implement `Copy`
Y hay una incompatibilidad que conviene conocer: un tipo que implementa Drop no puede ser Copy (E0184, «Copy not allowed on types with destructors»). Tiene lógica: si un tipo necesita hacer algo al morir, copiarlo en silencio duplicaría esa responsabilidad.
La consecuencia práctica es que en Rust el coste de copiar está a la vista. Si ves un .clone(), ahí hay una reserva de memoria. Si no lo ves, la asignación es un move o una copia de pocos bytes.
Drop: la memoria se libera donde termina el ámbito
La tercera regla dice que el valor se libera cuando su dueño sale de ámbito. En la práctica, en la llave de cierre, el compilador inserta una llamada a drop. Si el tipo implementa el trait Drop, se ejecuta tu código; después se liberan sus campos. Es el patrón RAII de C++ con el compilador como garante. Un tipo que anuncia su muerte enseña el orden:
struct Ruidoso(&'static str);
impl Drop for Ruidoso {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
fn main() {
let _a = Ruidoso("a");
let b = Ruidoso("b");
{
let _c = Ruidoso("c");
println!("fin del bloque interior");
}
drop(b); // std::mem::drop: toma la propiedad y la deja morir
let _ = Ruidoso("x"); // ojo: sin nombre, muere en esta misma línea
let _d = Ruidoso("d");
println!("fin de main");
}
fin del bloque interior
drop c
drop b
drop x
fin de main
drop d
drop a
Tres cosas que leer en esa salida. Primera: c muere al cerrar su bloque, no al final de la función. Segunda: las variables de un ámbito se liberan en orden inverso al de su declaración, d antes que a, como dice la referencia del lenguaje; los campos de un struct, en cambio, se liberan en el orden en que se declaran. Tercera, y es una trampa clásica: let _ = valor no liga el valor a ningún nombre, así que muere en esa misma sentencia. Si lo que guardas es el guard de un Mutex o un fichero temporal, let _guard = y let _ = hacen cosas muy distintas.
Para liberar antes de tiempo existe drop(b), que no es magia: es una función de la biblioteca estándar con el cuerpo vacío que recibe el valor por propiedad y lo deja morir al terminar. Lo que no se puede es llamar al método directamente, porque entonces el compilador volvería a liberarlo al cerrar el ámbito:
error[E0040]: explicit use of destructor method
--> src/main.rs:11:7
|
11 | a.drop();
| ^^^^ explicit destructor calls not allowed
Frente a Go, la diferencia es el determinismo. En Go sabes que la memoria se liberará en algún momento; en Rust sabes en qué línea. Y como Drop sirve para cualquier recurso, el mismo mecanismo cierra ficheros, sockets y locks sin defer: el File se cierra al salir de ámbito y el guard de un Mutex lo libera al morir.
Lo que Rust garantiza y lo que no
En código seguro, sin unsafe, el sistema de propiedad garantiza que no hay double free ni use-after-free: un valor tiene un solo dueño, se libera una sola vez y nadie puede usarlo después de moverlo.
Lo que no garantiza es que no haya fugas. Suena raro, pero está escrito. La documentación de std::mem::forget explica por qué esa función no es unsafe: «Rust's safety guarantees do not include a guarantee that destructors will always run». Fugar memoria es seguro en Rust, en el sentido técnico de la palabra: no corrompe nada, solo la desperdicia. Hay formas deliberadas de hacerlo. Box::leak renuncia a la propiedad a cambio de una referencia que vive hasta el final del programa, útil para configuración que se carga una vez:
fn main() {
// Box::leak renuncia a la propiedad a cambio de una referencia 'static.
let modo: &'static str = Box::leak(String::from("producción").into_boxed_str());
println!("modo: {modo}");
}
Y hay formas accidentales, como un ciclo de Rc, que veremos enseguida.
Funciones: pasar un valor es regalarlo
Pasar un argumento a una función funciona igual que asignar: si el tipo no es Copy, se mueve. La función pasa a ser dueña del valor y lo libera al terminar, salvo que lo devuelva.
fn publicar(titulo: String) {
println!("publicado: {titulo}");
} // aquí muere titulo y se libera su memoria
fn main() {
let t = String::from("Ownership en Rust");
publicar(t);
println!("{t}");
}
error[E0382]: borrow of moved value: `t`
--> src/main.rs:8:16
|
7 | publicar(t);
| - value moved here
8 | println!("{t}");
| ^ value borrowed here after move
|
note: consider changing this parameter type in function `publicar` to borrow instead if owning the value isn't necessary
--> src/main.rs:1:21
|
1 | fn publicar(titulo: String) {
| -------- ^^^^^^ this parameter takes ownership of the value
Fíjate en la nota: el compilador ya sugiere la solución del próximo post, que el parámetro tome prestado en lugar de quedarse el valor. Pero con lo que sabemos hasta aquí, la única forma de que el llamador recupere el String es que la función se lo devuelva:
fn contar_palabras(texto: String) -> (String, usize) {
let n = texto.split_whitespace().count();
(texto, n) // devuelvo el String para que el llamador lo recupere
}
fn main() {
let cuerpo = String::from("cada valor tiene un dueño");
let (cuerpo, n) = contar_palabras(cuerpo);
println!("{n} palabras en «{cuerpo}»");
}
Funciona, y es exactamente el ejemplo que usa el libro antes de presentar las referencias, para admitir a continuación que es demasiada ceremonia. Imagina una función que necesita leer tres valores y devolver un resultado: tendría que devolver una tupla de cuatro. Ese incordio es la motivación del préstamo.
Dos errores más que verás pronto
El primero es E0382 dentro de un bucle. El valor se mueve en la primera vuelta y en la segunda ya no está:
error[E0382]: use of moved value: `tags`
--> src/main.rs:8:42
|
6 | let tags = vec![String::from("rust"), String::from("go")];
| ---- move occurs because `tags` has type `Vec<String>`, which does not implement the `Copy` trait
7 | for idioma in ["es", "en"] {
| -------------------------- inside of this loop
8 | println!("{idioma}: {}", indexar(tags));
| ^^^^ value moved here, in previous iteration of loop
El segundo sorprende mucho viniendo de Go: no puedes sacar un elemento de un vector por índice si no es Copy.
fn main() {
let tags = vec![String::from("rust"), String::from("go")];
let primera = tags[0];
println!("{primera}");
}
error[E0507]: cannot move out of index of `Vec<String>`
--> src/main.rs:3:19
|
3 | let primera = tags[0];
| ^^^^^^^ move occurs because value has type `String`, which does not implement the `Copy` trait
|
help: consider borrowing here
|
3 | let primera = &tags[0];
| +
Si te dejara moverlo, el vector seguiría siendo dueño de un hueco con un String que ya no le pertenece, y al liberar el vector se liberaría dos veces. Las salidas son prestar (&tags[0]), clonar, o sacar el elemento de verdad con métodos que dejan el vector coherente, como remove, swap_remove o std::mem::take.
Cuando un valor necesita varios dueños: Rc y Arc
Hay estructuras en las que un dueño único no encaja: un nodo de un grafo al que apuntan varios, o una configuración que comparten muchos objetos sin que ninguno sea claramente el responsable. Para eso está Rc<T>, un puntero con contador de referencias. Clonar un Rc no copia el valor: suma un dueño. Cuando muere el último, se libera.
use std::rc::Rc;
fn main() {
let tema = Rc::new(String::from("github"));
let post_a = Rc::clone(&tema); // no copia el String: suma un dueño
{
let _post_b = Rc::clone(&tema);
println!("dueños: {}", Rc::strong_count(&tema));
}
println!("dueños: {}", Rc::strong_count(&tema));
println!("tema de post_a: {post_a}");
} // el último Rc que muere libera el String
dueños: 3
dueños: 2
tema de post_a: github
Rc usa un contador normal, sin operaciones atómicas, así que no puede cruzar hilos. El compilador lo sabe porque Rc no implementa Send:
error[E0277]: `Rc<String>` cannot be sent between threads safely
--> src/main.rs:7:27
|
= help: within `{closure@src/main.rs:7:27: 7:34}`, the trait `Send` is not implemented for `Rc<String>`
Para eso está Arc<T>, la versión con contador atómico. Algo más cara, y segura entre hilos.
Hay un matiz importante: tanto Rc como Arc solo dan acceso de lectura al valor compartido. Si varios dueños necesitan modificarlo, hace falta mutabilidad interior: RefCell<T> en un solo hilo, que comprueba las reglas de préstamo en tiempo de ejecución, y Mutex<T> o RwLock<T> entre hilos. El Arc<Mutex<T>> es la combinación que más verás:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let visitas = Arc::new(Mutex::new(0u64));
let mut hilos = Vec::new();
for _ in 0..4 {
let visitas = Arc::clone(&visitas);
hilos.push(thread::spawn(move || {
*visitas.lock().unwrap() += 1;
}));
}
for h in hilos {
h.join().unwrap();
}
println!("visitas: {}", visitas.lock().unwrap());
}
Y aquí está la fuga accidental que prometía. Si dos valores con Rc se apuntan entre sí, ninguno de los dos contadores llega nunca a cero:
use std::cell::RefCell;
use std::rc::Rc;
struct Nodo {
nombre: &'static str,
siguiente: RefCell<Option<Rc<Nodo>>>,
}
impl Drop for Nodo {
fn drop(&mut self) {
println!("drop {}", self.nombre);
}
}
fn main() {
let a = Rc::new(Nodo { nombre: "a", siguiente: RefCell::new(None) });
let b = Rc::new(Nodo { nombre: "b", siguiente: RefCell::new(Some(Rc::clone(&a))) });
*a.siguiente.borrow_mut() = Some(Rc::clone(&b)); // a -> b -> a
println!("a: {}, b: {}", Rc::strong_count(&a), Rc::strong_count(&b));
} // a y b salen de ámbito, los contadores bajan a 1 y nadie imprime "drop"
La salida es una sola línea, a: 2, b: 2, y ningún drop. Compila sin avisos y no viola ninguna garantía: es la fuga segura de la que habla la documentación de forget. El recolector de Go sí libera un ciclo así, porque razona sobre lo alcanzable y no sobre contadores. En Rust se rompe con Weak<T>, una referencia que no cuenta como dueña, y es lo que se usa para el puntero al padre en un árbol.
Dicho esto, mi consejo es el mismo que di en el post comparativo sobre .clone(): Rc y Arc son herramientas, no muletas. Si cada struct de tu programa acaba envuelto en Arc<Mutex<...>>, es probable que el diseño esté pidiendo un dueño claro y préstamos, no más contadores.
Lo que viene
Resumiendo: cada valor tiene un dueño; asignar o pasar a una función un tipo que no es Copy lo mueve; copiar caro se escribe .clone(); el valor se libera en la llave de cierre del ámbito de su dueño, en orden inverso de declaración; y cuando de verdad hacen falta varios dueños, Rc en un hilo y Arc entre varios. Con eso, Rust te da lo que en C es disciplina y en Go es trabajo del runtime: memoria liberada una sola vez, en un momento que conoces, sin que nadie pueda tocarla después.
Lo que queda pendiente es el incordio de la sección de funciones: tener que devolver un valor para recuperarlo. La solución es dejar de moverlo y empezar a prestarlo, con & y &mut, y aceptar las reglas con las que el compilador vigila esos préstamos. De eso va Borrowing en Rust, dentro de dos días.