Rust
Tutorial Rust: El concepto de ownership
Aprende el concepto de ownership en Rust, sus reglas básicas y cómo gestiona la memoria entre stack y heap para garantizar seguridad y eficiencia.
Aprende Rust y certifícateReglas básicas
El ownership (propiedad) es uno de los conceptos más innovadores de Rust y constituye la base de sus garantías de seguridad de memoria sin necesidad de un recolector de basura. A diferencia de lenguajes con gestión automática de memoria como Python o Java, o lenguajes con gestión manual como C, Rust implementa un sistema de reglas que el compilador verifica en tiempo de compilación.
El sistema de ownership de Rust se rige por tres reglas fundamentales que debemos comprender:
- Cada valor en Rust tiene una variable que se denomina su propietario (owner).
- Solo puede haber un propietario a la vez.
- Cuando el propietario sale del ámbito (scope), el valor se descarta automáticamente.
Estas reglas pueden parecer sencillas, pero tienen profundas implicaciones en cómo escribimos código en Rust. Veamos cada una con más detalle.
Cada valor tiene un único propietario
En Rust, cuando creamos una variable y le asignamos un valor, esa variable se convierte en la propietaria de dicho valor:
let s = String::from("hola"); // 's' es propietaria del String "hola"
En este ejemplo, la variable s
es la única propietaria del valor String::from("hola")
. Esto significa que s
tiene la responsabilidad exclusiva de ese valor en memoria.
Solo un propietario a la vez
La segunda regla establece que un valor solo puede tener un propietario en un momento dado. Esto evita que múltiples partes del código intenten modificar o liberar el mismo recurso simultáneamente:
let s1 = String::from("hola");
let s2 = s1; // La propiedad se transfiere de s1 a s2
// println!("{}", s1); // ¡Error! s1 ya no es válida
En este código, cuando asignamos s1
a s2
, la propiedad del valor se transfiere (o se "mueve") de s1
a s2
. Después de esta línea, s1
ya no es válida y no puede ser utilizada. El compilador de Rust detectará cualquier intento de usar s1
después de la transferencia y mostrará un error.
Este comportamiento puede parecer restrictivo al principio, pero es fundamental para evitar problemas como:
- Use-after-free: Usar memoria después de que ha sido liberada
- Double-free: Intentar liberar la misma memoria dos veces
- Data races: Accesos concurrentes no sincronizados a datos compartidos
Cuando el propietario sale del ámbito, el valor se descarta
La tercera regla determina cuándo se libera la memoria. En Rust, la memoria se libera automáticamente cuando la variable propietaria sale de su ámbito:
{
let s = String::from("hola mundo"); // 's' es válida desde este punto
// Podemos usar 's' aquí
} // El ámbito termina, 's' ya no es válida y Rust llama a `drop` automáticamente
Cuando una variable sale de su ámbito (como s
al final del bloque), Rust llama automáticamente a una función especial llamada drop
. Esta función se encarga de liberar la memoria y otros recursos asociados con el valor.
Ejemplo práctico de las reglas de ownership
Veamos un ejemplo más completo que ilustra las tres reglas en acción:
fn main() {
// Regla 1: 's1' es propietaria del String
let s1 = String::from("hola");
{
// Regla 2: La propiedad se transfiere a 's2'
let s2 = s1;
// Usar s1 aquí causaría un error
println!("Dentro del ámbito interno: {}", s2);
// Regla 3: Al final de este ámbito, 's2' se descarta
} // 's2' sale de ámbito y se libera la memoria
// Intentar usar 's2' aquí causaría un error porque ya no existe
// Intentar usar 's1' también causaría un error porque su valor fue movido a 's2'
// Creamos un nuevo String
let s3 = String::from("mundo");
println!("En el ámbito principal: {}", s3);
} // 's3' sale de ámbito y se libera la memoria
Ownership en funciones
Las reglas de ownership también se aplican cuando pasamos valores a funciones o los devolvemos desde ellas:
fn main() {
let s = String::from("hola mundo");
tomar_propiedad(s); // La propiedad de 's' se transfiere a la función
// println!("{}", s); // ¡Error! 's' ya no es válida aquí
let x = 5;
hacer_copia(x); // Los tipos primitivos se copian, no se transfieren
println!("{}", x); // Esto funciona porque 'x' sigue siendo válida
}
fn tomar_propiedad(cadena: String) {
println!("{}", cadena);
} // 'cadena' sale de ámbito y se libera la memoria
fn hacer_copia(entero: i32) {
println!("{}", entero);
} // 'entero' sale de ámbito, pero no hay efecto especial
En este ejemplo, cuando pasamos s
a tomar_propiedad()
, la propiedad se transfiere a la función. Cuando la función termina, la variable cadena
sale de ámbito y el valor se libera. Por eso no podemos usar s
después de llamar a la función.
Por otro lado, cuando pasamos x
a hacer_copia()
, se hace una copia del valor (esto se explicará con más detalle en la sección "Move vs Copy"). Por lo tanto, x
sigue siendo válida después de la llamada a la función.
Devolución de propiedad
Las funciones también pueden devolver la propiedad:
fn main() {
let s1 = dar_propiedad(); // 's1' obtiene la propiedad del valor devuelto
let s2 = String::from("hola");
let s3 = tomar_y_devolver(s2); // 's2' pierde la propiedad, 's3' la obtiene
// println!("{}", s2); // ¡Error! 's2' ya no es válida
println!("{}", s3); // Esto funciona porque 's3' es la propietaria ahora
}
fn dar_propiedad() -> String {
let s = String::from("mundo");
s // Devolvemos la propiedad al llamador
}
fn tomar_y_devolver(cadena: String) -> String {
cadena // Devolvemos la misma cadena, transfiriendo la propiedad de vuelta
}
En este ejemplo, dar_propiedad()
crea un String
y transfiere su propiedad al llamador. La función tomar_y_devolver()
toma la propiedad de un String
y luego la devuelve al llamador.
Beneficios del sistema de ownership
El sistema de ownership de Rust proporciona varios beneficios clave:
- Seguridad de memoria: Elimina errores comunes como use-after-free, double-free y fugas de memoria.
- Concurrencia segura: Previene data races en tiempo de compilación.
- Sin overhead en tiempo de ejecución: A diferencia de los recolectores de basura, no hay pausas ni sobrecarga durante la ejecución.
- Predictibilidad: La liberación de recursos es determinista y ocurre exactamente cuando el propietario sale de ámbito.
Estas reglas pueden parecer restrictivas al principio, pero con la práctica se vuelven naturales y te permiten escribir código más seguro y eficiente. En las siguientes secciones, exploraremos más a fondo cómo estas reglas afectan a diferentes tipos de datos y cómo Rust distingue entre mover y copiar valores.
Move vs Copy
En Rust, cuando asignamos un valor a otra variable o lo pasamos a una función, el comportamiento depende del tipo de dato. Algunos valores se mueven (move), transfiriendo la propiedad, mientras que otros se copian (copy), creando un duplicado independiente. Esta distinción es fundamental para entender cómo funciona el ownership en la práctica.
Comportamiento de movimiento (Move)
Por defecto, cuando asignamos un valor a otra variable en Rust, se produce un movimiento de la propiedad:
let s1 = String::from("hola");
let s2 = s1; // La propiedad se mueve de s1 a s2
// println!("{}", s1); // Error: valor usado después de ser movido
println!("{}", s2); // Funciona correctamente
En este ejemplo, el valor de s1
se mueve a s2
. Después de esta operación, s1
ya no es válida y cualquier intento de usarla resultará en un error de compilación. Esto ocurre porque String
es un tipo que gestiona memoria en el heap, y Rust evita crear copias profundas automáticas por razones de rendimiento.
El movimiento afecta principalmente a tipos que:
- Almacenan datos en el heap (como
String
,Vec
,Box
) - No implementan el trait
Copy
- Tienen un tamaño variable o desconocido en tiempo de compilación
Comportamiento de copia (Copy)
A diferencia del movimiento, algunos tipos en Rust implementan el trait Copy
, lo que significa que sus valores se copian automáticamente en lugar de moverse:
let x = 5;
let y = x; // Se crea una copia de x
println!("x = {}, y = {}", x, y); // Ambos son válidos
En este caso, cuando asignamos x
a y
, se crea una copia del valor 5
. Después de esta operación, tanto x
como y
son válidas y contienen el mismo valor, pero son independientes entre sí.
Los tipos que implementan Copy
suelen ser:
- Tipos escalares como enteros, flotantes, booleanos y caracteres
- Tuplas que solo contienen tipos que implementan
Copy
- Arrays de tamaño fijo con elementos que implementan
Copy
- Referencias inmutables (&T)
Comparación directa: Move vs Copy
Veamos un ejemplo que ilustra claramente la diferencia entre ambos comportamientos:
fn main() {
// Ejemplo con tipo Copy (i32)
let a = 42;
let b = a; // Se copia el valor
println!("a: {}, b: {}", a, b); // Ambos son válidos
// Ejemplo con tipo no-Copy (String)
let s1 = String::from("rust");
let s2 = s1; // Se mueve el valor
// println!("s1: {}", s1); // Error: valor usado después de ser movido
println!("s2: {}", s2); // Funciona correctamente
}
Comportamiento en funciones
Esta distinción también es crucial cuando pasamos valores a funciones:
fn main() {
// Con tipo Copy
let num = 10;
procesar_numero(num);
println!("Después de llamar a la función: {}", num); // num sigue siendo válido
// Con tipo no-Copy
let texto = String::from("hola mundo");
procesar_texto(texto);
// println!("Después de llamar a la función: {}", texto); // Error: valor movido
}
fn procesar_numero(x: i32) {
println!("Procesando número: {}", x);
} // x se descarta, pero era una copia
fn procesar_texto(s: String) {
println!("Procesando texto: {}", s);
} // s se descarta, liberando la memoria
Implementando el trait Copy
Podemos hacer que nuestros propios tipos sean copiables implementando el trait Copy
. Sin embargo, esto solo es posible si todos los componentes del tipo también implementan Copy
:
// Definimos una estructura que puede ser copiada
#[derive(Copy, Clone)] // Necesitamos ambos traits
struct Punto {
x: i32,
y: i32,
}
fn main() {
let p1 = Punto { x: 10, y: 20 };
let p2 = p1; // p1 se copia a p2
println!("p1: ({}, {}), p2: ({}, {})", p1.x, p1.y, p2.x, p2.y); // Ambos son válidos
}
Es importante notar que Copy
requiere que también implementemos Clone
, ya que Copy
es un subtrait de Clone
.
Clonación explícita
Para tipos que no implementan Copy
, pero sí implementan Clone
, podemos crear una copia explícita usando el método .clone()
:
let s1 = String::from("hola");
let s2 = s1.clone(); // Creamos una copia profunda
println!("s1: {}, s2: {}", s1, s2); // Ambos son válidos
La diferencia clave es que .clone()
crea una copia explícita de los datos, mientras que el trait Copy
hace que la copia sea implícita en asignaciones y pasos de parámetros.
¿Cuándo usar cada uno?
La elección entre mover y copiar valores depende de varios factores:
- Eficiencia: Mover es más eficiente que copiar para datos grandes
- Semántica: A veces queremos transferir la propiedad, otras veces necesitamos copias independientes
- Tipo de datos: Algunos datos son naturalmente copiables (como números), mientras que otros no (como recursos del sistema)
// Eficiente: movemos un vector grande en lugar de copiarlo
let numeros = vec![1, 2, 3, 4, 5, /* ...miles de elementos... */];
procesar_vector(numeros); // Transferimos la propiedad
// Conveniente: copiamos un valor pequeño
let contador = 42;
incrementar(contador);
println!("Contador original: {}", contador); // contador sigue siendo 42
fn procesar_vector(v: Vec<i32>) {
// Trabajamos con el vector...
}
fn incrementar(mut n: i32) {
n += 1;
println!("Valor incrementado: {}", n);
}
Entender la diferencia entre mover y copiar valores es esencial para escribir código Rust eficiente y correcto. Estos mecanismos, junto con las reglas de ownership, permiten a Rust garantizar la seguridad de memoria sin necesidad de un recolector de basura, dándote control preciso sobre el ciclo de vida de tus datos.
Stack vs Heap
Para entender completamente el sistema de ownership en Rust, es fundamental comprender la diferencia entre las dos regiones principales de memoria donde se almacenan los datos durante la ejecución de un programa: el stack (pila) y el heap (montículo).
El stack y el heap son estructuras de memoria con características y propósitos muy diferentes, y Rust aprovecha estas diferencias para implementar su sistema de ownership de manera eficiente.
El Stack: rápido y predecible
El stack es una región de memoria que funciona siguiendo el principio LIFO (Last In, First Out), donde los datos se añaden y eliminan solo desde la parte superior. Imagina una pila de platos: solo puedes añadir o quitar platos desde arriba.
Características principales del stack:
- Acceso rápido: Las operaciones en el stack son extremadamente rápidas
- Tamaño conocido en compilación: Todos los datos almacenados deben tener un tamaño fijo y conocido
- Asignación y liberación automática: La memoria se asigna cuando se declara una variable y se libera cuando sale de ámbito
- Organización ordenada: Los datos se almacenan de manera contigua y estructurada
En Rust, los siguientes tipos de datos se almacenan típicamente en el stack:
// Todos estos valores se almacenan en el stack
let entero = 42; // i32 - tamaño fijo de 4 bytes
let flotante = 3.14; // f64 - tamaño fijo de 8 bytes
let booleano = true; // bool - tamaño fijo de 1 byte
let caracter = 'A'; // char - tamaño fijo de 4 bytes
let tupla = (1, 2, 3); // Tupla de i32 - tamaño fijo total de 12 bytes
let array = [1, 2, 3, 4, 5]; // Array de tamaño fijo - 20 bytes
Cuando una función se ejecuta, se crea un nuevo marco de pila (stack frame) que contiene sus variables locales. Cuando la función termina, todo ese marco se elimina de una sola vez:
fn calcular() {
let x = 10; // x se crea en el stack
let y = 20; // y se crea en el stack
let resultado = x + y; // resultado se crea en el stack
println!("El resultado es: {}", resultado);
} // x, y y resultado se eliminan automáticamente del stack
El Heap: flexible pero más complejo
El heap es una región de memoria menos estructurada que permite almacenar datos cuyo tamaño puede ser desconocido en tiempo de compilación o puede cambiar durante la ejecución.
Características principales del heap:
- Tamaño dinámico: Puede almacenar datos cuyo tamaño no se conoce en compilación
- Asignación explícita: La memoria debe solicitarse explícitamente
- Acceso más lento: Acceder a datos en el heap es generalmente más lento que en el stack
- Gestión manual: En lenguajes sin recolector de basura, la memoria debe liberarse manualmente
En Rust, los siguientes tipos utilizan el heap:
// Estos valores tienen una parte en el stack y otra en el heap
let cadena = String::from("Hola, mundo"); // La estructura String está en el stack,
// pero los caracteres están en el heap
let vector = vec![1, 2, 3]; // La estructura Vec está en el stack,
// pero los elementos están en el heap
let caja = Box::new(42); // Box es un puntero en el stack a un valor en el heap
Visualizando la diferencia
Para entender mejor la diferencia, veamos cómo se almacena un i32
versus un String
:
let a = 5; // Valor entero almacenado completamente en el stack
let s = String::from("hola"); // Estructura en el stack, datos en el heap
En memoria, esto se vería aproximadamente así:
Para a
:
Stack:
+-------+
| 5 | <- a
+-------+
Para s
:
Stack: Heap:
+-------+-------+---+ +---+---+---+---+
| ptr | len | 4 | | h | o | l | a |
+-------+-------+---+ +---+---+---+---+
| ^
+--------------------+
Donde:
ptr
es un puntero a la ubicación en el heap donde comienzan los datoslen
es la longitud actual de la cadena (4 bytes)4
es la capacidad reservada (en este caso igual a la longitud)
Implicaciones para el ownership
La distinción entre stack y heap es crucial para el sistema de ownership de Rust:
- Tipos en el stack (con trait
Copy
):
- Se copian automáticamente al asignarlos a otra variable
- No hay preocupación por la propiedad compartida
- La liberación de memoria es trivial y automática
- Tipos en el heap (sin trait
Copy
):
- Se mueven al asignarlos a otra variable (transferencia de propiedad)
- Solo puede haber un propietario a la vez
- La memoria se libera cuando el propietario sale de ámbito
Veamos un ejemplo que ilustra estas diferencias:
fn main() {
// Tipo stack (i32)
let x = 5;
let y = x; // Se copia el valor 5
println!("x = {}, y = {}", x, y); // Ambos son válidos
// Tipo heap (String)
let s1 = String::from("hola");
let s2 = s1; // Se transfiere la propiedad, s1 ya no es válido
// println!("{}", s1); // Error: valor usado después de ser movido
println!("{}", s2); // Funciona correctamente
}
Problemas que el ownership previene
El sistema de ownership de Rust, combinado con la comprensión de stack vs heap, previene varios problemas comunes:
1. Double-free (doble liberación)
En lenguajes como C, liberar la misma memoria dos veces puede causar corrupción:
// En C (código problemático)
char* str = malloc(4);
strcpy(str, "hola");
free(str);
// ... código ...
free(str); // ¡Error! Doble liberación
En Rust, esto es imposible gracias al ownership:
let s1 = String::from("hola");
let s2 = s1; // s1 se mueve a s2
// Cuando s2 sale de ámbito, la memoria se libera una sola vez
// s1 ya no es válido, así que no puede causar una doble liberación
2. Use-after-free (uso después de liberación)
Otro problema común en C:
// En C (código problemático)
char* get_string() {
char str[10] = "hola";
return str; // Devuelve puntero a memoria de stack que será inválida
}
void main() {
char* result = get_string();
printf("%s", result); // Comportamiento indefinido
}
En Rust, el compilador detecta estos problemas:
fn get_string() -> String {
let s = String::from("hola");
s // Transferimos la propiedad al llamador
}
fn main() {
let result = get_string();
println!("{}", result); // Perfectamente seguro
}
Optimizaciones del compilador
Es importante mencionar que el compilador de Rust realiza optimizaciones inteligentes. Por ejemplo, en algunos casos puede determinar que ciertos valores nunca necesitarán ser liberados explícitamente y puede optimizar el código para evitar comprobaciones innecesarias.
fn main() {
let mut suma = 0;
// Aunque String usa el heap, el compilador puede optimizar
// este código para evitar asignaciones y liberaciones innecesarias
for i in 0..10 {
let texto = i.to_string();
suma += texto.len();
} // Cada 'texto' se libera al final de cada iteración
println!("Suma: {}", suma);
}
Consideraciones de rendimiento
La elección entre stack y heap tiene implicaciones importantes para el rendimiento:
- Stack: Operaciones muy rápidas, pero tamaño limitado
- Heap: Más flexible, pero las operaciones son más lentas
En Rust, es común intentar usar tipos basados en stack cuando sea posible para mejorar el rendimiento:
// Menos eficiente: usa el heap
let vector = vec![1, 2, 3, 4];
// Más eficiente: usa solo el stack
let array = [1, 2, 3, 4];
// Si necesitamos un tamaño dinámico, no tenemos opción:
let n = obtener_tamaño();
let vector_dinamico = vec![0; n]; // Debe usar el heap
La comprensión de cómo funcionan el stack y el heap, y cómo Rust gestiona la memoria en estas dos regiones, es fundamental para aprovechar al máximo el sistema de ownership. Esta comprensión te permitirá escribir código más eficiente y seguro, aprovechando las garantías que Rust proporciona sin sacrificar rendimiento.
Otras lecciones de Rust
Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Introducción A Rust
Introducción Y Entorno
Primer Programa
Introducción Y Entorno
Instalación Del Entorno
Introducción Y Entorno
Funciones
Sintaxis
Operadores
Sintaxis
Estructuras De Control Condicional
Sintaxis
Arrays Y Strings
Sintaxis
Manejo De Errores Panic
Sintaxis
Variables Y Tipos Básicos
Sintaxis
Estructuras De Control Iterativo
Sintaxis
Colecciones Estándar
Estructuras De Datos
Option Y Result
Estructuras De Datos
Pattern Matching
Estructuras De Datos
Estructuras (Structs)
Estructuras De Datos
Enumeraciones Enums
Estructuras De Datos
El Concepto De Ownership
Ownership
Lifetimes Básicos
Ownership
Slices Y Referencias Parciales
Ownership
References Y Borrowing
Ownership
Funciones Anónimas Closures
Abstracción
Traits De La Biblioteca Estándar
Abstracción
Traits
Abstracción
Generics
Abstracción
Channels Y Paso De Mensajes
Concurrencia
Memoria Compartida Segura
Concurrencia
Threads Y Sincronización Básica
Concurrencia
Introducción A Tokio
Asincronía
Fundamentos Asíncronos Y Futures
Asincronía
Async/await
Asincronía
Ejercicios de programación de Rust
Evalúa tus conocimientos de esta lección El concepto de ownership con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender las tres reglas básicas del ownership en Rust.
- Diferenciar entre mover (move) y copiar (copy) valores y su impacto en la gestión de memoria.
- Entender la distinción entre stack y heap y cómo afecta al almacenamiento de datos.
- Reconocer cómo el sistema de ownership previene errores comunes como doble liberación y uso después de liberar memoria.
- Aplicar el concepto de ownership en funciones y en la transferencia de propiedad de valores.