Saltar al contenido principal

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 s es 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 'static del 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 del String se 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

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 &str es como leer un texto fijo (no se puede cambiar el contenido), mientras que un String permite 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 &str puede apuntar a un string literal o a una parte de un String existente (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 String a partir de un string literal (ya que este sigue siendo texto) con String::from("some") o simplemente inicializarlo vacío con String::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:

  1. La memoria debe solicitarse al asignador de memoria (memory allocator) en tiempo de ejecución
  2. 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..

  • drop no lo llama el programador explícitamente (aunque existe en std::mem::drop para 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 un Drop que:
    1. Libera la memoria del heap que está reservada por el String y la devuelve al allocator
    2. Hace lo necesario para dejar todo en un estado seguro

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).

Nota

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 5 a la variable x
  • Se hace una copia completa del valor de x y se asigna a y
  • Ambas variables (x y y) existen independientemente con el valor 5

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 memoria

Estructura 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 String está ocupando actualmente
  • La capacidad (capacity) indica la cantidad total de memoria, en bytes, que la variable String a recibido del memory allocator
  • En el heap están los datos reales de la variable

Figura 1

let s1 = String::from("Hello");
let s2 = s1; // Se hace un MOVE, no una copia

Lo que sucede internamente:

  1. Se copia únicamente la metadata del stack (puntero, longitud, capacidad)
  2. No se copian los datos del heap (por eficiencia)
  3. Ambas variables apuntarían a los mismos datos en el heap
  4. Inmediatamente, Rust invalida s1, ahora solo s2 tiene propiedad de los datos
  5. Al salir del scope, solo s2 liberará la memoria

Figura 2: Representación en memoria de la variable s2 que tiene una copia del puntero, longitud y capacidad de s1

Figura 2

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

Figura 3

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.

Nota

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

Figura 4

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!");
  1. Se declara s con un valor "Hello" (datos en heap + metadatos en stack)
  2. Se crea un nuevo String con "ahoy" en una nueva ubicación del heap
  3. Se actualiza la metadata de s para apuntar a los nuevos datos
  4. Rust llama automáticamente la función drop sobre los datos originales "Hello"
  5. La memoria del valor anterior se libera inmediatamente
  6. Imprime "ahoy, world!", ya que s ahora apunta al nuevo String.

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.

Figura 5

Diferencia clave con el Move

AspectoMove (s1s2)Reasignación (s = nuevo_valor)
Variables involucradasDos variables diferentesLa misma variable
Datos en heapLos mismos datosDatos completamente nuevos
Qué se copia/mueveMetadata (puntero, longitud, capacidad)Se crean nuevos datos y metadata
Cuándo se libera memoriaAl salir del scope de la variable válidaInmediatamente 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 i32 es barato, porque solo copia un número en el stack.
  • Clonar un String es 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 valores true y false
  • 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 implementa Copy pero (i32, String) no implementa Copy

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.