Rust

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ícate

Referencias 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:

  1. Dos o más punteros acceden a los mismos datos simultáneamente
  2. Al menos uno de ellos está escribiendo
  3. 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.

Aprende Rust online

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.

Accede GRATIS a Rust y certifícate

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.