Ownership
Todos los programas deben gestionar el uso de la memoria de la computadora durante su ejecución.
Algunos lenguajes como C/C++ requieren que el programador gestione la memoria manualmente. Permiten reservar una parte de la memoria y liberarla cuando ya no se necesite. Esto puede llevar a errores como fugas de memoria o liberar memoria que aún se está utilizando.
Otros lenguajes como Java, Python, etc. tienen recolectores de basura (garbage collector) que se encargan de liberar la memoria automáticamente cuando ya no se necesita. Esto puede llevar a problemas de rendimiento, ya que el recolector de basura puede ralentizar la aplicación.
Rust utiliza un tercer enfoque; la memoria se asigna mediante un sistema de ownership (propiedad)
- El ownership es un conjunto de reglas que definen cómo el programa administra la memoria
- Si se infringe alguna de las reglas, el programa no compila
- Las reglas no ralentizan el programa en tiempo de ejecución
- Permite al compilador garantizar la seguridad de la memoria (ej. condiciones de carrera o accesos a memoria no seguros) sin necesidad de un garbage collector o de que el programador asigne o libere explícitamente la memoria
Reglas del ownership
- Cada valor tiene un propietario
- Solo puede haber un propietario a la vez
- Cuando el propietario queda fuera del alcance, el valor se elimina
fn main() {
// Regla 1: Cada valor tiene un único dueño
let s1 = String::from("hello"); // "s1" es el dueño del String
// Regla 2: Solo puede haber un propietario a la vez
let s2 = s1; // "s1" deja de ser válido, "s2" ahora es el dueño
println!("{s1}"); // Error: "s1" ya no es válido
// Regla 3: Al salir del scope, el valor se libera automáticamente
{
let s3 = String::from("world");
// "s3" es válido aquí
} // Fin del scope de "s3": "s3" se libera
println!("{s3}"); //Error: "s3" no existe fuera de su scope
}
Alcance de variables
- El alcance es el rango dentro del programa en el que un elemento o variable es válido
- La variable es válida desde su declaración hasta el final del ámbito (scope) actual
fn main() {
{ // "s" no es válida aquí, aún no ha sido declarada
let s = "hello"; // "s" es válida desde este punto en adelante
// se pueden hacer operaciones con s
} // este ámbito ha terminado, "s" ya no es válida
}
Notas del código:
- La variable
ses de tipo string literal (&'static str). - La variable es válida desde su declaración hasta el final del scope actual.
- El alcance (scope) define cuánto tiempo la variable es válida.
- El lifetime
'staticdel literal define cuánto tiempo vive el valor en memoria.
La variable s contiene una referencia a un string literal (&'static str), y los literales de cadena en Rust viven durante toda la ejecución del programa — esto es lo que significa 'static.
s no “posee” el valor, lo cual aún no aplica aquí, porque el ownership real se verá con el tipo String. Es decir, el valor "hello" sigue existiendo en memoria, pero la variable s que lo referenciaba ya no existe fuera del scope.
Esto no es un problema, porque:
- Los string literals (
&'static str) no se asignan dinámicamente en el heap. - Se almacenan en una sección de solo lectura del binario (como parte del propio programa).
- Por lo tanto, no es necesario liberar memoria ni hay riesgo de fuga.
El tipo String
Los tipos primitivos como i32, f64 o bool tienen un tamaño conocido en tiempo de compilación, se almacenan en el stack e implementan el trait Copy. Esto significa que pueden copiarse rápida y fácilmente para crear una nueva instancia independiente cuando otra parte del código necesita usar el mismo valor en un scope diferente. Cuando salen de su scope, simplemente se extraen del stack.
Sin embargo, para comprender mejor el ownership, es más representativo trabajar con datos que se almacenan en el heap. El tipo String es un buen ejemplo porque:
- Tiene un tamaño variable (puede crecer o reducirse en tiempo de ejecución)
- El contenido del texto (
data) se almacena en el heap, mientras que el puntero, longitud y capacidad delStringse almacenan en el stack. - No implementa el trait
Copy - Requiere gestión explícita de memoria
Nos centraremos en los aspectos del String relacionados con el ownership, pero estos principios también se aplican a otros tipos de datos complejos, ya sean proporcionados por la biblioteca estándar o creados por el usuario.
Trait Copy: Los tipos que lo implementan se copian automáticamente cuando se asignan o se pasan a una función.
Los tipos que no implementan Copy (como String) se mueven, transfiriendo su propiedad (ownership).
Diferencia entre string literal &str y String
- La diferencia está en cómo estos dos tipos gestionan la memoria.
- Un
&stres como leer un texto fijo (no se puede cambiar el contenido), mientras que unStringpermite editar, agregar o eliminar texto libremente.
String literal o &'static str o &str
Los string literal (literales de cadena) son:
- Texto que se escribe directamente en el código (hardcoded)
- Viven en memoria estática, no en el stack.
- Se almacenan en el binario compilado
- Son inmutables y son estáticos
- Tienen un tamaño conocido en tiempo de compilación
- Un
&strpuede apuntar a un string literal o a una parte de unStringexistente (un slice de texto). - Su tipo es
&str(slice string) y es el que infiere Rust si no se especifica un tipo de dato a los textos - No son útiles para guardar entradas del usuario ya que estas entradas son variables
let title: &'static str = "My App Name";
String
El tipo String es:
- Una estructura de datos que gestiona memoria en el heap (siguen siendo variables para almacenar texto)
- Mutable (si se declara con
mut) - Puede crecer o reducirse en tiempo de ejecución
- Su contenido puede no conocerse en tiempo de compilación
- Se puede crear un
Stringa partir de un string literal (ya que este sigue siendo texto) conString::from("some")o simplemente inicializarlo vacío conString::new() - Al final del scope, Rust liberará automáticamente la memoria asignada para
s. Esto es parte del sistema de ownership que evita fugas de memoria sin usar un garbage collector.
let mut s = String::from("Hello");
s.push_str(", world"); // push_str() agrega un "literal" a un String
println!("{s}"); // Hello, world
Memoria y asignación
En el caso de string literal &str (literal de cadena) que se conoce su contenido en tiempo de compilación, el texto se codifica directamente en el ejecutable final. Por eso los literales de cadena son rápidos y eficientes. Sin embargo, estas propiedades solo se deben a su inmutabilidad.
Con el tipo String, para admitir un fragmento de texto mutable y ampliable en tiempo de ejecución, es necesario asignar una cantidad de memoria en el heap, desconocida en tiempo de compilación. Esto significa:
- La memoria debe solicitarse al asignador de memoria (memory allocator) en tiempo de ejecución
- Necesitamos una forma de devolver esta memoria al asignador cuando se termine de trabajar con el
String
Nosotros realizamos la primera parte al utilizar String::from(), cuya implementación solicita la memoria necesaria al allocator. Esto es prácticamente universal en los lenguajes de programación.
La segunda parte, devolver esa memoria, es diferente en Rust respecto a otros lenguajes. En lenguajes con garbage collector (GC), este rastrea y limpia la memoria que ya no se usa, sin que el programador tenga que preocuparse por ello. En la mayoría de los lenguajes sin GC es responsabilidad del programador identificar cuando la memoria ya no se usa y ejecutar código para liberarla explícitamente, tal como se hacía para solicitarla. Hacer esto manualmente ha sido un problema. Si se olvida de liberar la memoria, se desperdicia memoria. Si se hace demasiado pronto, se tiene una variable no válida. Si se hace dos veces, también es un error. Es necesario tener siempre un allocate emparejado con un free.
Rust toma un camino diferente para liberar la memoria (sin utilizar allocate y free o un garbage collector): la memoria se devuelve automáticamente cuando la variable que la contiene sale del scope (alcance).
fn main() {
{
let s = String::from("Hello"); // "s" es valido a partir de este momento
// trabajar con "s"
} // fin del scope y "s" ya no es válido
}
Existe un punto natural en el que podemos devolver la memoria que String solicitó al allocator: cuando s sale del scope.
La función drop:
Rust tiene un mecanismo automático: cuando un valor sale del scope (alcance), llama automáticamente a una función especial llamada drop en el orden inverso al que fueron declaradas las variables (último en entrar, primero en salir). Rust ejecuta automáticamente esta función cuando se alcanza la llave de cierre } del scope..
dropno lo llama el programador explícitamente (aunque existe enstd::mem::droppara forzarlo)- Cada tipo puede definir cómo liberarse a sí mismo implementando el trait
Drop - Para
String, los autores de la librería estándar escribieron unDropque:- Libera la memoria del heap que está reservada por el
Stringy la devuelve al allocator - Hace lo necesario para dejar todo en un estado seguro
- Libera la memoria del heap que está reservada por el
Esto cumple la misma función que free() en C o el garbage collector en otros lenguajes, pero sin su sobrecarga en tiempo de ejecución y en un punto predecible (de manera determinista).
En C++, este patrón de desasignación de recursos al final de la vida útil de un elemento se denomina, a veces, Adquisición de Recursos en Inicialización o Resource Acquisition Is Initialization (RAII).
Este patrón tiene un profundo impacto en la forma en que se escribe el código en Rust. El comportamiento del código puede ser inesperado en situaciones más complejas cuando queremos que múltiples variables usen los datos asignados en el heap. Algunas de estas situaciones son:
1. Variables y datos interactuando con Move
El concepto de Move se refiere a la transferencia de propiedad (ownership) del valor de una variable a otra cuando se trabaja con tipos de datos que no implementan el trait Copy (como String).
Comportamiento según el tipo de dato:
Tipos simples (implementan Copy)
let x = 5;
let y = x; // Se hace una COPIA real en el stack
- Se asigna
5a la variablex - Se hace una copia completa del valor de
xy se asigna ay - Ambas variables (
xyy) existen independientemente con el valor5
Aquí no hay Move porque los enteros implementan el trait Copy, así que x sigue siendo válido después de asignarlo a y.
Es como "pasar la llave" de la misma casa a otra persona.
Tipos complejos (no implementan Copy)
Aquí sí hay Move porque String (u otros tipos complejos) no implementa Copy, la propiedad se transfiere de s1 a s2, y s1 ya no es válido.
String en memoriaEstructura de un String en memoria
- Stack: Contienen la metadata (puntero, longitud y capacidad)
- Heap: Contiene los datos reales del
String
Figura 1: Representación en memoria de un String
- La longitud (leng) indica la cantidad de memoria, en bytes, que el contenido del
Stringestá ocupando actualmente - La capacidad (capacity) indica la cantidad total de memoria, en bytes, que la variable
Stringa recibido del memory allocator - En el heap están los datos reales de la variable
let s1 = String::from("Hello");
let s2 = s1; // Se hace un MOVE, no una copia
Lo que sucede internamente:
- Se copia únicamente la metadata del stack (puntero, longitud, capacidad)
- No se copian los datos del heap (por eficiencia)
- Ambas variables apuntarían a los mismos datos en el heap
- Inmediatamente, Rust invalida
s1, ahora solos2tiene propiedad de los datos - Al salir del scope, solo
s2liberará la memoria
Figura 2: Representación en memoria de la variable s2 que tiene una copia del puntero, longitud y capacidad de s1
En la sección anterior, Alcance de variables, se menciona que cuando una variable queda fuera del scope, Rust llama automáticamente a la función drop y limpia la memoria del heap de esa variable. Sin embargo, en la Figura 2 muestra a ambos pointers (punteros) apuntando a la misma ubicación. Esto representa un problema: cuando s2 y s1 queden fuera del scope, ambas intentarán liberar la misma memoria. Esto se conoce como double free error (error de doble liberación). Liberar memoria dos veces puede provocar corrupción de memoria, lo que potencialmente puede generar vulnerabilidades de seguridad.
Para garantizar la seguridad de la memoria, después de la línea let s2 = s1, Rust considera que s1 ya no es válido, por lo tanto Rust no necesita liberar nada cuando s1 queda fuera del scope. Al intentar usar s1 después de crear s2, no funcionará porque Rust impide usar la referencia invalidada.
Figura 3: Representación en memoria después de que s1 haya sido invalidada
Esto soluciona el problema de que ambas variables intenten liberar memoria. Con solo s2 válido, al salir del scope, solo este liberará la memoria, y listo.
Además, esto implica una decisión de diseño: Rust nunca creará automáticamente copias profundas de los datos. Por lo tanto, se puede asumir que cualquier copia automática es económica en términos de rendimiento en tiempo de ejecución.
Así se vería la memoria si Rust también copiara los datos del heap. Si Rust hiciera eso, la operación s2 = s1 podría ser muy costosa en términos de rendimiento en tiempo de ejecución si los datos en el heap fueran grandes.
Figura 4: Otra posibilidad de lo que podría pasar si el código s2 = s1 también copiara los datos del heap
2. Alcance y asignación (o reasignación)
Esta sección trata la reasignación de valores a la misma variable (diferentes datos, mismo dueño), mostrando como Rust maneja la liberación automática de memoria cuando se asignan nuevos valores.
Cuando se reasigna un valor a una variable en Rust, el valor anterior se descarta inmediatamente si no hay referencias a él. Esto se debe a que Rust administra la memoria automáticamente, llamando al método drop en el valor anterior para liberar espacio antes de asignar el nuevo valor.
let mut s = String::from("Hello");
s = String::from("ahoy"); // Reasignación completa
println!("{s}, world!");
- Se declara
scon un valor"Hello"(datos en heap + metadatos en stack) - Se crea un nuevo
Stringcon"ahoy"en una nueva ubicación del heap - Se actualiza la metadata de
spara apuntar a los nuevos datos - Rust llama automáticamente la función
dropsobre los datos originales"Hello" - La memoria del valor anterior se libera inmediatamente
- Imprime
"ahoy, world!", ya quesahora apunta al nuevoString.
Es como demoler una casa vieja y construir una nueva en otro lugar
Figura 5: Representación en memoria después de que el valor inicial (Hello) ha sido reemplazado en su totalidad.
Diferencia clave con el Move
| Aspecto | Move (s1 → s2) | Reasignación (s = nuevo_valor) |
|---|---|---|
| Variables involucradas | Dos variables diferentes | La misma variable |
| Datos en heap | Los mismos datos | Datos completamente nuevos |
| Qué se copia/mueve | Metadata (puntero, longitud, capacidad) | Se crean nuevos datos y metadata |
| Cuándo se libera memoria | Al salir del scope de la variable válida | Inmediatamente en la reasignación |
3. Variables y datos interactuando con Clone
- Copias explícitas y costosas.
- El resultado son dos variables independientes tanto en memoria como en propiedad
Para hacer una copia en profundidad de los datos del heap, no solo los del stack, se puede utilizar el método clone (para datos complejos como los String).
let s1 = String::from("Hello");
let s2 = s1.clone(); // Se asigna nueva memoria para s2
pritnln!("s1 = {s1}, s2 = {s2}");
Esto funciona correctamente, ambas variables siguen siendo válidas. Produce exactamente lo que se muestra en la Figura 4 donde los datos del heap se copian y no solo los metadatos del stack. Ahora hay dos copias independientes de "hello" en la memoria, una para s1 y otra para s2.
La función clone, ejecuta código arbitrario ya que cada tipo de dato tiene su implementación de Clone. Esa implementación puede hacer cosas muy distintas: copiar datos del heap, duplicar estructuras internas, recorrer colecciones, incluso clonar recursivamente sus elementos, y por lo tanto puede costar más tiempo en memoria que una copia superficial.
- Clonar un
i32es barato, porque solo copia un número en el stack. - Clonar un
Stringes más caro, porque copia también el contenido en el heap. - Clonar un
Vec<String>puede ser mucho más caro, porque copia la lista y cada cadena dentro de ella.
4. Datos solo en el stack (Stack-Only Data): Copy
- Tipos que implementan el trait
Copy. - Se tendrían dos valores, similar a aplicar
Clone
let x = 5;
let y = x;
// "x" y "y" existen de forma independiente y se pueden seguir usando.
println!("x = {x}, y = {y}");
Este código parece contradecir lo de las secciones anteriores; no se utilizó clone, pero x sigue siendo válido y no se pasó la propiedad a y.
La razón es que tipos como los enteros, que tienen un tamaño conocido en tiempo de compilación, se almacenan completamente en el stack, por lo que las copias de los valores reales se crean rápidamente.
Esto significa que no hay razón para impedir que x sea válido después de crear la variable y. En otras palabras, no hay diferencia entre una copia superficial o una copia profunda, por lo que llamar el método clone no tendría ningún efecto diferente a la copia superficial común y se puede omitir.
Rust tiene el trait especial Copy que se puede aplicar a tipos almacenados en el stack. Si un tipo implementa el trait Copy, las variables que lo usan no se mueven, sino que se copian de forma trivial lo que las hace válidas después de haberlas asignado a otras variables.
Rust no permite implementar Copy a un tipo si este, o alguna de sus partes, ha implementado la característica Drop. Cualquier grupo de valores escalares simples puede implementar Copy y nada que requiera asignación, o sea algún tipo de recurso, puede implementar Copy. Algunos tipos que implementan Copy:
- Todos los tipos de enteros, como
u32 - El tipo booleano,
bool, con valorestrueyfalse - Todos los tipos de punto flotante, como
f64 - El tipo
char - Tuplas, si solo contienen tipos que también implementen
Copy, por ejemplo(i32, i32)si implementaCopypero(i32, String)no implementaCopy
Ownership y funciones
Cuando pasamos valores a una función, sucede lo mismo que con la asignación de variables. Pasar una variable a una función implica mover o copiar (según su tipo), al igual que pasa con la asignación.
fn main() {
let s = String::from("Hello"); // "s" entra al scope
takes_ownership(s); // el valor de "s" se MUEVE a la funcion
// ... "s" ya no es valido aqui
let x = 5; // "x" entra al scope
makes_copy(x); // "x" se COPIA porque i32 implementa Copy
println!("{x}"); // ... "x" sigue siendo valido
} // Aqui, "x" sale del scope, y despues "s"
// Sin embargo, debido a que el valor de "s" ya ha sido movido a la funcion
// nada especial pasa
fn takes_ownership(some_string: String) { // "some_string" entra al scope
// y recibe la propiedad
println!("{some_string}");
} // Aqui, "some_string" sale del scope y se llama a la funcion `drop()`
// Se libera la memoria del heap y sale del stack
fn makes_copy(some_integer: i32) { // "some_integer" entra al scope (es una copia)
println!("{some_integer}");
} // Aqui, "some_integer" sale del scope, nada especial pasa
// simplemente se olvida o desaparece con el stack frame
// no hay destructores personalizados
// a diferencia del String que si necesita liberar memoria
Valores de retorno y scope
Los valores devueltos por una función también pueden transferir la propiedad.
fn main() {
let s1 = gives_ownership(); // gives_ownership MUEVE SU VALOR DE RETORNO a "s1"
let s2 = String::from("Hello"); // "s2" entra al scope
let s3 = takes_and_gives_back(s2); // "s2" se MUEVE A LA FUNCION "takes_and_gives_back"
// despues, MUEVE SU VALOR DE RETORNO a "s3"
} // Aqui, "s3" queda fuera del scope y se elimina
// "s2" ya se ha movido anteriormente, nada especial pasa
// "s1" sale del scope y se elimina
// La funcion gives_ownership MOVERA SU VALOR DE RETORNO a la funcion que la llame
fn gives_ownership() -> String {
let some_string = String::from("yours"); // some_string entra al scope
some_string // La propiedad de `some_string` se transfiere a quien llame la función
}
// Esta funcion TOMA un String y RETORNA (MUEVE) un String
fn takes_and_gives_back(a_string: String) -> String {
// a_string entra al scope
a_string // La propiedad de "a_string" se devuelve a quien llame la funcion
}
La propiedad (ownership) de una variable sigue siempre el mismo patrón:
- Al asignar un valor a otra variable, esta se mueve
- Cuando una variable que incluye datos del heap queda fuera del scope , el valor se borra con
drop()a menos que la propiedad de los datos se haya movido a otra variable
Esto funciona, pero tomar y devolver la propiedad de una variable es tedioso. ¿Qué pasa si se requiere que una función use un valor, pero que no tome la propiedad?, es molesto porque todo lo que se pasa se tiene que devolver si se necesita usar de nuevo además de otros datos resultantes de la función que también se necesitan devolver.
Una opción es devolver multiples valores usando tuplas:
fn main() {
let s1 = String::from("Hello");
let (s2, len) = calculate_length(s1); // "s1" se mueve a la funcion pero tambien se devuelve
println!("The length of `{s2}` is {len}."); // No se puede usar "s1"
// porque fue movido a la funcion
// pero devuelto como valor de retorno
// por lo que se debe tomar con otro nombre, "s2"
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
Aunque Rust permite devolver varios valores, usando una tupla u otra estructura, Rust cuenta con una función para usar un valor sin transferir la propiedad, llamada referencias, para evitar este tedioso patrón de mover y devolver valores solo para poder seguir usándolos.
¿Por qué no pensamos en esto en otros lenguajes?
En lenguajes como Python, JavaScript, Java o C#, múltiples variables pueden referenciar el mismo objeto sin problemas porque usan garbage collection para limpiar automáticamente la memoria. Rust logra seguridad de memoria sin garbage collector y sin overhead en tiempo de ejecución verificando ownership en compilación, por lo que necesita ser explícito sobre quién "posee" cada valor. Esto hace que los conceptos de "move" y "borrow" sean únicos y necesarios en Rust.
En lenguajes como Python, JavaScript, Java o C#, este problema simplemente no existe porque manejan la memoria de manera diferente. Estos lenguajes utilizan garbage collection o reference counting, donde múltiples variables pueden "apuntar" al mismo objeto en memoria sin problemas de propiedad. Cuando pasas una variable a una función en Python, por ejemplo, tanto la variable original como el parámetro de la función pueden coexistir y acceder al mismo objeto. El garbage collector se encarga automáticamente de limpiar la memoria cuando ya no hay referencias al objeto. Rust, en cambio, garantiza seguridad de memoria sin garbage collector y sin overhead en tiempo de ejecución mediante su sistema de ownership, lo que requiere ser explícito sobre quién "posee" cada valor en cada momento. Esta diferencia fundamental es lo que hace que el concepto de "mover (move)" y "pedir prestado (borrow)" valores sea único y necesario en Rust.