Rust
Tutorial Rust: References y Borrowing
Aprende en Rust cómo usar referencias y borrowing para gestionar memoria de forma segura y eficiente con ejemplos prácticos.
Aprende Rust y certifícateReferencias inmutables
En Rust, las referencias nos permiten acceder a un valor sin tomar su propiedad. Esto resuelve un problema común: ¿cómo podemos utilizar un valor en múltiples partes de nuestro código sin transferir su propiedad cada vez? La respuesta está en el concepto de borrowing (préstamo), que nos permite "tomar prestado" un valor temporalmente.
Una referencia inmutable se crea utilizando el operador &
y nos permite leer el valor sin modificarlo. Podemos pensar en ellas como "punteros seguros" que garantizan que el valor al que apuntan no cambiará mientras exista la referencia.
Veamos un ejemplo básico de cómo crear y usar una referencia inmutable:
fn main() {
let s1 = String::from("hola");
// Creamos una referencia inmutable a s1
let s1_ref = &s1;
println!("La cadena es: {}", s1_ref);
// Podemos seguir usando s1 después porque solo la prestamos
println!("Original aún disponible: {}", s1);
}
En este ejemplo, s1_ref
es una referencia a s1
, no su dueño. Cuando s1_ref
sale del ámbito, solo la referencia se destruye, no el valor original.
Pasando referencias a funciones
Una de las aplicaciones más comunes de las referencias es pasar valores a funciones sin transferir su propiedad. Comparemos dos enfoques:
// Esta función toma la propiedad del String
fn calcular_longitud(s: String) -> (String, usize) {
let longitud = s.len();
// Debemos devolver el String para que el llamante pueda seguir usándolo
(s, longitud)
}
// Esta función solo toma una referencia
fn calcular_longitud_ref(s: &String) -> usize {
s.len()
// No necesitamos devolver nada, solo tomamos prestado el valor
}
fn main() {
let s1 = String::from("hola mundo");
// Enfoque 1: Transferencia de propiedad
let (s1, longitud) = calcular_longitud(s1);
println!("'{}' tiene {} caracteres", s1, longitud);
// Enfoque 2: Usando referencias
let s2 = String::from("rust es genial");
let longitud = calcular_longitud_ref(&s2);
println!("'{}' tiene {} caracteres", s2, longitud);
}
El segundo enfoque con referencias es mucho más limpio y eficiente. No necesitamos devolver el valor original porque nunca transferimos su propiedad.
Acceso a campos y métodos
Cuando tenemos una referencia, podemos acceder a los campos y métodos del valor referenciado de forma transparente. Rust realiza desreferenciación automática cuando usamos el operador punto (.
):
struct Persona {
nombre: String,
edad: u32,
}
fn imprimir_datos(persona: &Persona) {
// Rust desreferencia automáticamente cuando usamos el operador punto
println!("Nombre: {}, Edad: {}", persona.nombre, persona.edad);
}
fn main() {
let juan = Persona {
nombre: String::from("Juan"),
edad: 30,
};
imprimir_datos(&juan);
// Podemos seguir usando juan después
println!("Aún tengo acceso a: {}", juan.nombre);
}
Referencias a referencias
También podemos crear referencias a referencias. Rust maneja esto de manera transparente:
fn main() {
let s1 = String::from("hola");
let s1_ref = &s1; // referencia a s1
let s1_ref_ref = &s1_ref; // referencia a la referencia
println!("Valor directo: {}", s1);
println!("A través de una referencia: {}", s1_ref);
println!("A través de referencia doble: {}", s1_ref_ref);
}
Múltiples referencias inmutables
Una característica importante de las referencias inmutables es que podemos tener múltiples referencias inmutables al mismo valor simultáneamente:
fn main() {
let texto = String::from("Rust garantiza seguridad de memoria");
let ref1 = &texto;
let ref2 = &texto;
let ref3 = &texto;
println!("Referencia 1: {}", ref1);
println!("Referencia 2: {}", ref2);
println!("Referencia 3: {}", ref3);
}
Esto es seguro porque las referencias inmutables solo permiten leer el valor, no modificarlo, por lo que no hay riesgo de condiciones de carrera o corrupción de datos.
Ámbito de las referencias
Las referencias tienen un ámbito de vida (lifetime) que determina cuánto tiempo son válidas. El compilador de Rust garantiza que ninguna referencia sobreviva al valor al que apunta:
fn main() {
let referencia;
{
let valor = 5;
referencia = &valor; // ERROR: valor no vive lo suficiente
} // valor sale del ámbito aquí
// Intentar usar referencia aquí causaría un error
// println!("El valor es: {}", referencia);
}
Este código no compilará porque Rust detecta que referencia
apuntaría a memoria liberada cuando valor
sale del ámbito.
Inmutabilidad de las referencias inmutables
Es importante entender que una referencia inmutable no permite modificar el valor al que apunta:
fn main() {
let mut s = String::from("hola");
let r = &s; // r es una referencia inmutable
// Esto causaría un error de compilación:
// r.push_str(" mundo");
println!("{}", r);
}
Si intentáramos modificar el valor a través de una referencia inmutable, el compilador nos detendría con un error. Esta es una de las formas en que Rust garantiza la seguridad de memoria en tiempo de compilación.
Las referencias inmutables son una herramienta fundamental en Rust que nos permite compartir datos de forma segura sin los riesgos asociados a los punteros en otros lenguajes. Nos permiten acceder a valores sin tomar su propiedad, facilitando un código más limpio y eficiente.
Referencias mutables
Mientras que las referencias inmutables nos permiten leer valores sin tomar su propiedad, en muchas situaciones necesitamos modificar los datos a los que estamos accediendo. Para esto, Rust nos proporciona las referencias mutables, que nos permiten alterar el valor prestado.
Para crear una referencia mutable, utilizamos la sintaxis &mut
en lugar de solo &
. Sin embargo, para poder crear una referencia mutable, el valor original también debe ser declarado como mutable:
fn main() {
let mut s = String::from("hola");
let r = &mut s;
// Podemos modificar el valor a través de la referencia mutable
r.push_str(" mundo");
println!("{}", s); // Imprime: "hola mundo"
}
En este ejemplo, primero declaramos s
como mutable con let mut
, y luego creamos una referencia mutable con &mut s
. A través de esta referencia, podemos modificar el valor original.
Modificando valores a través de referencias mutables
Las referencias mutables nos permiten alterar el valor prestado utilizando todos los métodos y operaciones que modifican el valor:
fn main() {
let mut numeros = vec![1, 2, 3];
// Creamos una referencia mutable al vector
let nums_ref = &mut numeros;
// Modificamos el vector a través de la referencia
nums_ref.push(4);
nums_ref[0] = 10;
println!("Vector modificado: {:?}", numeros); // Imprime: [10, 2, 3, 4]
}
Funciones que modifican valores prestados
Una aplicación común de las referencias mutables es pasar valores a funciones para que puedan modificarlos sin tomar su propiedad:
fn agregar_apellido(nombre_completo: &mut String) {
nombre_completo.push_str(" Pérez");
}
fn main() {
let mut nombre = String::from("Ana");
agregar_apellido(&mut nombre);
println!("Nombre completo: {}", nombre); // Imprime: "Ana Pérez"
}
Este patrón es muy útil cuando queremos que una función modifique un valor sin transferir su propiedad, lo que nos permite seguir usando el valor después de llamar a la función.
Restricción importante: una sola referencia mutable
A diferencia de las referencias inmutables, Rust impone una restricción fundamental: solo puede existir una única referencia mutable a un valor en un momento dado. Intentar crear múltiples referencias mutables simultáneas resultará en un error de compilación:
fn main() {
let mut s = String::from("hola");
let r1 = &mut s;
let r2 = &mut s; // ERROR: no se pueden tener dos referencias mutables
println!("{}, {}", r1, r2);
}
Este código no compilará porque viola la regla de exclusividad de las referencias mutables. El compilador de Rust nos mostrará un error indicando que no podemos tomar prestado s
como mutable más de una vez a la vez.
Ámbito de las referencias mutables
Una forma de trabajar con esta restricción es utilizar bloques de ámbito para limitar dónde existen las referencias mutables:
fn main() {
let mut s = String::from("hola");
{
let r1 = &mut s;
r1.push_str(" amigos");
} // r1 sale del ámbito aquí, liberando la referencia mutable
// Ahora podemos crear una nueva referencia mutable
let r2 = &mut s;
r2.push_str("!");
println!("{}", s); // Imprime: "hola amigos!"
}
En este ejemplo, la primera referencia mutable r1
sale del ámbito al final del bloque, lo que nos permite crear una segunda referencia mutable r2
después.
Modificando estructuras complejas
Las referencias mutables son particularmente útiles cuando trabajamos con estructuras de datos complejas:
struct Usuario {
nombre: String,
edad: u32,
activo: bool,
}
fn desactivar_usuario(usuario: &mut Usuario) {
usuario.activo = false;
}
fn cumplir_años(usuario: &mut Usuario) {
usuario.edad += 1;
}
fn main() {
let mut usuario = Usuario {
nombre: String::from("Carlos"),
edad: 28,
activo: true,
};
// Modificamos el usuario a través de funciones
desactivar_usuario(&mut usuario);
cumplir_años(&mut usuario);
println!("Usuario: {} ({}), activo: {}",
usuario.nombre, usuario.edad, usuario.activo);
// Imprime: "Usuario: Carlos (29), activo: false"
}
Referencias mutables vs. inmutables: elección de diseño
La elección entre referencias mutables e inmutables es una decisión de diseño importante en Rust:
- Referencias inmutables: Favorecen la concurrencia y la seguridad, permitiendo múltiples lectores.
- Referencias mutables: Permiten modificaciones pero con exclusividad, evitando condiciones de carrera.
fn main() {
let mut datos = vec![1, 2, 3];
// Enfoque funcional (inmutable)
let duplicados: Vec<i32> = datos.iter()
.map(|x| x * 2)
.collect();
// Enfoque imperativo (mutable)
let mut triplicados = Vec::new();
for &num in &datos {
triplicados.push(num * 3);
}
println!("Original: {:?}", datos);
println!("Duplicados: {:?}", duplicados);
println!("Triplicados: {:?}", triplicados);
}
Conversión entre tipos de referencias
No podemos convertir directamente una referencia inmutable a mutable, pero podemos tener diferentes tipos de referencias en diferentes momentos:
fn main() {
let mut valor = String::from("hola");
// Primero usamos referencias inmutables
let r1 = &valor;
let r2 = &valor;
println!("r1: {}, r2: {}", r1, r2);
// r1 y r2 ya no se usan después de este punto
// Ahora podemos usar una referencia mutable
let r3 = &mut valor;
r3.push_str(" mundo");
println!("r3: {}", r3);
}
Este código compila correctamente porque las referencias inmutables r1
y r2
ya no se utilizan después del primer println!
, lo que permite crear la referencia mutable r3
.
Detección de errores en tiempo de compilación
Una de las grandes ventajas de Rust es que estas reglas se verifican en tiempo de compilación, evitando errores en tiempo de ejecución:
fn main() {
let mut v = vec![1, 2, 3];
let primer_elemento = &v[0]; // Referencia inmutable
v.push(4); // ERROR: no podemos modificar v mientras existe una referencia inmutable
println!("El primer elemento es: {}", primer_elemento);
}
Este código no compilará porque el compilador detecta que estamos intentando modificar v
mientras existe una referencia inmutable a uno de sus elementos, lo que podría causar problemas si la modificación requiere reasignar memoria para el vector.
Las referencias mutables son una herramienta poderosa que nos permite modificar valores prestados de forma segura, manteniendo las garantías de seguridad de memoria que caracterizan a Rust. La restricción de exclusividad puede parecer limitante al principio, pero es fundamental para prevenir condiciones de carrera y garantizar la integridad de los datos.
Reglas de borrowing
El sistema de borrowing en Rust está gobernado por un conjunto de reglas que el compilador verifica rigurosamente. Estas reglas son fundamentales para garantizar la seguridad de memoria sin necesidad de un recolector de basura, permitiendo que Rust ofrezca tanto rendimiento como seguridad.
La regla fundamental del borrowing puede resumirse así:
- En cualquier momento dado, puedes tener una única referencia mutable O cualquier número de referencias inmutables a un valor, pero nunca ambas simultáneamente.
Esta regla puede parecer restrictiva al principio, pero es la clave para que Rust prevenga problemas comunes como data races y accesos a memoria inválida en tiempo de compilación.
La regla de exclusividad
Veamos cómo funciona esta regla en la práctica:
fn main() {
let mut valor = String::from("hola");
// Caso 1: Múltiples referencias inmutables - PERMITIDO
let r1 = &valor;
let r2 = &valor;
let r3 = &valor;
println!("{}, {}, {}", r1, r2, r3);
// Caso 2: Una única referencia mutable - PERMITIDO
let r4 = &mut valor;
r4.push_str(" mundo");
println!("{}", r4);
// Caso 3: Referencia mutable e inmutable simultáneas - PROHIBIDO
// let r5 = &valor;
// let r6 = &mut valor; // Error de compilación
// println!("{}, {}", r5, r6);
}
El caso 3 no compilaría porque viola la regla fundamental: no podemos tener referencias inmutables y mutables al mismo tiempo.
Ámbito de las referencias y liberación temprana
El compilador de Rust es inteligente respecto al ámbito de las referencias. Una referencia deja de ser válida cuando se usa por última vez, no necesariamente al final del bloque:
fn main() {
let mut s = String::from("hola");
let r1 = &s; // referencia inmutable
let r2 = &s; // otra referencia inmutable
println!("{} y {}", r1, r2);
// r1 y r2 ya no se usan después de este punto
let r3 = &mut s; // Esto es válido porque r1 y r2 ya no están en uso
r3.push_str(" mundo");
println!("{}", r3);
}
Este código compila correctamente porque el compilador detecta que r1
y r2
no se utilizan después del primer println!
, lo que permite crear la referencia mutable r3
.
Prevención de data races
Un data race ocurre cuando:
- Dos o más punteros acceden a los mismos datos simultáneamente
- Al menos uno de ellos está escribiendo
- No hay sincronización entre ellos
Las reglas de borrowing previenen exactamente este escenario:
fn main() {
let mut contador = 0;
// Esto no compilaría:
// let r1 = &contador;
// let r2 = &mut contador;
//
// println!("Contador: {}, modificando a través de: {}", r1, *r2 + 1);
// En su lugar, debemos hacer esto:
{
let r1 = &contador;
println!("Contador actual: {}", r1);
} // r1 sale del ámbito aquí
// Ahora podemos modificar de forma segura
let r2 = &mut contador;
*r2 += 1;
println!("Nuevo contador: {}", r2);
}
Borrowing en funciones
Las reglas de borrowing se aplican igualmente cuando pasamos referencias a funciones:
fn leer_datos(datos: &Vec<i32>) {
println!("Datos: {:?}", datos);
}
fn modificar_datos(datos: &mut Vec<i32>) {
datos.push(100);
}
fn main() {
let mut numeros = vec![1, 2, 3];
leer_datos(&numeros); // Préstamo inmutable
leer_datos(&numeros); // Podemos tener múltiples préstamos inmutables
modificar_datos(&mut numeros); // Préstamo mutable
// No podríamos hacer esto:
// let referencia = &numeros;
// modificar_datos(&mut numeros); // Error: no podemos tomar préstamo mutable
// println!("Referencia: {:?}", referencia);
println!("Después de modificar: {:?}", numeros);
}
Borrowing y estructuras de datos
Las reglas de borrowing se aplican a campos individuales de estructuras, no solo a valores completos:
struct Persona {
nombre: String,
edad: u32,
}
fn main() {
let mut persona = Persona {
nombre: String::from("Ana"),
edad: 30,
};
// Podemos tomar préstamos de diferentes campos simultáneamente
let nombre = &persona.nombre;
let edad_mut = &mut persona.edad;
// Esto es válido porque son campos diferentes
println!("Nombre: {}", nombre);
*edad_mut += 1;
// Sin embargo, esto no compilaría:
// let nombre_ref = &persona.nombre;
// let persona_mut = &mut persona; // Error: no podemos tomar préstamo mutable
// println!("Nombre: {}", nombre_ref);
}
Borrowing y ownership en colecciones
Cuando trabajamos con colecciones, las reglas de borrowing pueden ser sutiles:
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
// Esto no compilaría:
// let primer = &v[0];
// v.push(6); // Podría causar reubicación del vector en memoria
// println!("El primer elemento es: {}", primer);
// Solución: limitar el ámbito del préstamo
{
let primer = &v[0];
println!("El primer elemento es: {}", primer);
} // primer sale del ámbito aquí
// Ahora podemos modificar el vector
v.push(6);
println!("Vector modificado: {:?}", v);
}
El compilador rechaza el primer enfoque porque v.push(6)
podría reubicar el vector en memoria si necesita más espacio, invalidando la referencia primer
.
Borrowing y métodos encadenados
Las reglas de borrowing también se aplican cuando encadenamos llamadas a métodos:
fn main() {
let mut nombres = vec!["Ana", "Carlos", "Berta"];
// Esto funciona bien: todas son operaciones inmutables
let total_caracteres = nombres.iter()
.map(|s| s.len())
.sum::<usize>();
println!("Total de caracteres: {}", total_caracteres);
// Esto también funciona: operaciones mutables encadenadas
nombres.sort();
nombres.dedup();
println!("Nombres ordenados: {:?}", nombres);
// Esto NO compilaría:
// let referencia = &nombres;
// nombres.push("David"); // Error: no podemos mutar mientras existe una referencia
// println!("Referencia: {:?}", referencia);
}
Detección de errores en tiempo de compilación
El sistema de borrowing de Rust detecta problemas potenciales en tiempo de compilación, evitando errores en tiempo de ejecución:
fn main() {
let mut datos = vec![1, 2, 3];
// Intento de modificar mientras iteramos (anti-patrón común en otros lenguajes)
// for i in &datos {
// if *i == 2 {
// datos.push(4); // Error: no podemos modificar mientras iteramos
// }
// }
// Solución: recolectar primero, modificar después
let elementos_a_modificar: Vec<_> = datos.iter()
.filter(|&&x| x == 2)
.collect();
if !elementos_a_modificar.is_empty() {
datos.push(4);
}
println!("Datos finales: {:?}", datos);
}
Borrowing y concurrencia
Las reglas de borrowing son especialmente importantes para la concurrencia segura:
use std::thread;
fn main() {
let datos = vec![1, 2, 3];
// Esto no compilaría:
// let handle = thread::spawn(|| {
// println!("Datos desde el hilo: {:?}", datos); // Error: datos podría no vivir lo suficiente
// });
// Solución: transferir ownership al hilo
let handle = thread::spawn(move || {
println!("Datos desde el hilo: {:?}", datos);
});
// Ya no podemos usar datos aquí porque su ownership se movió al hilo
// println!("Datos desde el hilo principal: {:?}", datos); // Error
handle.join().unwrap();
}
Beneficios de las reglas de borrowing
Las reglas de borrowing de Rust proporcionan beneficios significativos:
- Prevención de data races: El compilador garantiza que no haya accesos concurrentes no sincronizados.
- Seguridad de memoria: No hay punteros colgantes ni accesos a memoria liberada.
- Concurrencia sin miedo: Podemos escribir código concurrente con confianza.
- Optimizaciones del compilador: El compilador puede realizar optimizaciones agresivas sabiendo que no hay aliasing mutable.
Estas reglas pueden parecer restrictivas al principio, pero con la práctica se convierten en una segunda naturaleza, permitiéndonos escribir código más seguro y mantenible. El compilador de Rust actúa como un asistente riguroso que nos ayuda a evitar errores comunes antes de que ocurran.
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 References y Borrowing 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 qué son las referencias inmutables y cómo se usan para acceder a datos sin transferir propiedad.
- Aprender a crear y utilizar referencias mutables para modificar datos prestados.
- Entender las reglas fundamentales del borrowing que garantizan exclusividad y seguridad.
- Saber cómo funcionan los ámbitos de vida (lifetimes) de las referencias y su impacto en la validez del código.
- Reconocer cómo el sistema de borrowing previene errores comunes como data races y accesos inválidos en tiempo de compilación.