Ownership en Rust: seguridad de memoria sin GC
1. Resumen
Ownership es el mecanismo central que permite a Rust garantizar la seguridad de memoria sin necesidad de garbage collector (GC) como en Java o Go, y sin que tengas que hacer malloc/free manualmente como en C. Este artículo explica las tres reglas de ownership, el concepto de borrowing, lifetime, y los errores de compile-time más comunes. Después de leerlo, entenderás por qué Rust es “exigente” durante la compilación, pero a cambio no tienes dangling pointers, double free ni data races en tiempo de ejecución.
2. El problema: gestión de memoria sin GC
Lenguajes como Java usan GC para limpiar la basura — es conveniente pero consume CPU e introduce pausas impredecibles. C/C++ te dan total control pero es fácil caer en double free, use-after-free y memory leaks. Rust elige un tercer camino: el compilador rastrea quién “posee” qué datos, y libera automáticamente la memoria cuando el propietario sale del scope. Sin overhead en tiempo de ejecución, sin necesidad de thread GC.
fn main() {
let s = String::from("Non La Dev");
println!("{s}");
} // s sale del scope aquí, Rust llama automáticamente a drop(s) para liberar la memoria del heap
La línea String::from asigna memoria en el heap. Cuando la variable s sale del }, Rust inserta automáticamente una llamada a drop — similar a finally { free(s) } pero generado por el compilador, sin necesidad de escribirlo manualmente.
3. Las tres reglas de Ownership
Rust tiene exactamente tres reglas; memorízalas y habrás entendido el 80% del mecanismo:
- Cada valor en Rust tiene una variable llamada owner.
- En un momento dado, hay solo un owner único.
- Cuando el owner sale del scope, el valor es dropped (liberado).
3.1. Semántica de move
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership se mueve de s1 a s2
println!("{s2}");
// println!("{s1}"); // error de compilación: s1 ha sido movido
}
A diferencia de Java (asignar una variable solo copia la referencia, ambas apuntan al mismo objeto, el GC se encarga del resto), Rust interpreta let s2 = s1 como una transferencia de propiedad. Después de esa línea, s1 ya no es válida. Si descomenta la línea final, obtendrá un error:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}");
| ^^^^ value borrowed here after move
Esto previene de raíz el error de double free: si tanto s1 como s2 compartieran un puntero al mismo área de heap, cuando ambas saliesen del scope, Rust intentaría hacer drop dos veces — causando un crash. El compilador lo impide en el momento de la compilación.
3.2. Clone cuando quieres mantener ambas
Si realmente necesitas dos copias independientes, llama .clone() explícitamente:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy del área de heap
println!("{s1} - {s2}"); // ambas siguen siendo válidas
}
.clone() asigna una nueva área de heap y copia el contenido. El costo en tiempo de ejecución es obvio, y Rust te obliga a llamarlo explícitamente para que nadie clone accidentalmente una estructura de datos enorme sin darse cuenta — a diferencia de muchos lenguajes que hacen copia implícita.
3.3. Copy trait para tipos de datos simples
fn main() {
let x = 5;
let y = x; // copia, no un move
println!("x = {x}, y = {y}"); // ambas siguen siendo válidas
}
Los tipos primitivos como i32, bool, char, o tuplas que contengan solo estos tipos implementan el trait Copy. Como tienen tamaño fijo y viven completamente en el stack, es más económico copiar que hacer un move, así que Rust copia automáticamente en lugar de invalidar la variable original.
Nota: un tipo no puede implementar tanto
CopycomoDrop. Si el tipo tiene recursos que necesitan limpieza (heap, file handle…), no se le permite implementarCopy.
4. Ownership al pasar a una función
Pasar una variable a una función también sigue la semántica de move, igual que asignar a una variable:
fn print_order(order: String) {
println!("Order: {order}");
} // order se libera aquí, no en el caller
fn main() {
let order = String::from("ORDER-001");
print_order(order);
// println!("{order}"); // error: order se ha movido a la función
}
Cuando llamas print_order(order), la propiedad de order se transfiere al parámetro de la función. Cuando la función termina, el valor se libera dentro de ella — main ya no tiene derecho a usar order. Esta es la razón por la que borrowing (la siguiente sección) es casi siempre necesario cuando solo quieres “pedir prestado para leer” datos sin transferir la propiedad.
5. Borrowing: pedir prestado sin tomar la propiedad
5.1. Referencia inmutable (&T)
fn print_order(order: &String) {
println!("Order: {order}");
}
fn main() {
let order = String::from("ORDER-001");
print_order(&order); // pedir prestado, no un move
println!("Todavía puedo usar: {order}");
}
El símbolo & crea una reference — pides prestado los datos sin tomar su propiedad. La función print_order solo lee, no posee order. Después de que la función termina, main sigue teniendo plena potestad sobre la variable original.
5.2. Referencia mutable (&mut T)
fn add_discount(total: &mut f64) {
*total -= 10.0;
}
fn main() {
let mut total = 100.0;
add_discount(&mut total);
println!("Total: {total}"); // Total: 90
}
&mut permite pedir prestado para modificar. Para poder llamarla, la variable original debe declararse mut. El símbolo * se usa para desreferenciar y asignar un nuevo valor a la memoria a la que apunta la reference.
5.3. Las reglas del borrowing
Rust aplica dos leyes de borrowing en compile-time, llamadas colectivamente borrow checker:
- En un momento dado, puedes tener una mutable reference, O varias immutable references — pero no mezclar ambas.
- Las referencias nunca pueden vivir más que los datos originales (evitando dangling references).
fn main() {
let mut total = 100.0;
let r1 = &total;
let r2 = &total; // OK: múltiples immutable references a la vez
println!("{r1} {r2}");
let r3 = &mut total; // error si r1, r2 aún se usan después de este punto
println!("{r3}");
}
Si r1, r2 ya no se usan después de la primera línea println!, el compilador (gracias a Non-Lexical Lifetimes) permite que r3 tome un préstamo mutable a continuación. Pero si intentas usar r1 después de que r3 aparezca, obtendrás:
error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable
Esta regla elimina data races en compile-time: no es posible tener a alguien leyendo y a alguien escribiendo simultáneamente en la misma memoria.
6. Lifetime: garantizar que las referencias no apunten a la “nada”
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let order1 = String::from("ORDER-001");
let order2 = String::from("ORDER-002-LONG");
let result = longest(order1.as_str(), order2.as_str());
println!("Más largo: {result}");
}
La anotación 'a es una lifetime annotation, que le dice al compilador: “la reference devuelta vive no más que tanto x como y”. Rust no puede inferir la relación entre lifetimes de múltiples referencias en este caso, así que tienes que declararlo explícitamente. La mayoría de las veces Rust infiere lifetimes automáticamente (lifetime elision), así que raramente tienes que escribir 'a manualmente — solo cuando una función recibe múltiples referencias y devuelve una de ellas.
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected named lifetime parameter
Si falta 'a, el compilador reporta un error porque no sabe si la referencia devuelta está asociada con x o con y.
7. Cuando necesitas múltiples propietarios: Rc y RefCell
La propiedad de un solo propietario a veces es demasiado rígida, por ejemplo cuando construyes estructuras de datos como árboles o grafos donde múltiples nodos necesitan hacer referencia al mismo valor.
<!-- No necesitas dependencias, Rc/RefCell están en std -->
use std::cell::RefCell;
use std::rc::Rc;
struct Customer {
name: String,
balance: RefCell<f64>,
}
fn main() {
let customer = Rc::new(Customer {
name: String::from("Nón Lá"),
balance: RefCell::new(0.0),
});
let c1 = Rc::clone(&customer);
let c2 = Rc::clone(&customer);
*c1.balance.borrow_mut() += 50.0;
*c2.balance.borrow_mut() += 30.0;
println!("{} balance = {}", customer.name, customer.balance.borrow());
println!("Número de referencias: {}", Rc::strong_count(&customer));
}
RcRefCell<T> permite mutable borrows en tiempo de ejecución en lugar de compile-time (interior mutability) — a cambio, si violas las reglas de borrowing (por ejemplo, dos borrow_mut() simultáneamente), el programa hará panic en lugar de mostrar un error de compilación.
Nón Lá balance = 80
Número de referencias: 3
Nota:
Rc/RefCellsolo son para single-thread. Si necesitas compartir datos entre múltiples threads, usaArc(atomic reference count) junto conMutexoRwLock. No abuses deRc<RefCell<T>>— sacrifica la ventaja del “borrow checker que atrapa errores en compile-time”, que es el punto fuerte de Rust.
8. Escribir tests para verificar el comportamiento de ownership
#[derive(Clone)]
struct Order {
id: String,
amount: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.amount -= order.amount * percent / 100.0;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_original_order_untouched_when_cloned() {
let original = Order { id: "ORD-1".into(), amount: 100.0 };
let mut copy = original.clone();
apply_discount(&mut copy, 10.0);
assert_eq!(original.amount, 100.0); // el original no cambia
assert_eq!(copy.amount, 90.0);
}
#[test]
fn should_mutate_through_mutable_borrow() {
let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
apply_discount(&mut order, 25.0);
assert_eq!(order.amount, 150.0);
}
}
Ejecuta cargo test, resultado:
running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
El primer test demuestra que .clone() crea una copia independiente — modificar copy no afecta a original. El segundo test confirma que &mut realmente modifica los datos originales a través de la reference.
9. Errores comunes
- Use after move: intentar usar una variable después de que se ha movido a otro lugar (función, nueva variable, struct). Arreglarlo usando
.clone()si necesitas dos copias, o pasando una reference (&T). - Cannot borrow as mutable more than once: tienes un
&mutpero intentas crear otro&o&mutsimultáneamente. Reduce el scope de la reference anterior (usando un bloque{}o dejándola “vivir” menos tiempo) para resolverlo. - Dangling reference: devolver una reference que apunta a una variable local que ha sido liberada cuando la función termina. Rust bloquea completamente este error en compile-time — verás un error como
missing lifetime specifieroborrowed value does not live long enough. - Abusar de
.clone()para evadir el borrow checker: compila pero desperdicia rendimiento innecesariamente. Trata cada.clone()como una pregunta: “¿realmente necesito una copia independiente, o solo estoy eludiendo entender ownership?”
10. Cuándo el ownership estricto no es necesario
Si escribes scripts cortos, prototipos rápidos, o los datos siempre son pequeños y no hay requisitos altos de rendimiento/seguridad, el costo de aprender y cumplir con el borrow checker podría no valer la pena — lenguajes como Python o Go te ofrecerán mayor velocidad de desarrollo. Ownership brilla más en sistemas que requieren alto rendimiento y seguridad de memoria rigurosa: sistemas operativos, embedded, motores de juegos, o servicios backend que requieren baja latencia sin pausas de GC.
11. Conclusión
Ownership es el fundamento que permite a Rust lograr algo que parecía contradictorio: seguridad de memoria como un lenguaje con GC, pero con rendimiento equivalente a C/C++. Las tres reglas owner-move-drop, el mecanismo de borrowing con la regla de “uno mutable o múltiples inmutables”, y las lifetime annotations trabajan juntas para que el compilador atrape errores memory-unsafe en compile-time en lugar de permitir crashes en tiempo de ejecución. Al principio, el borrow checker puede hacerte “discutir” bastante con el compilador, pero una vez que te acostumbras, escribirás código con muchos menos bugs sin necesidad de que ningún runtime lo vigile.