Rust

Rust

Tutorial Rust: Lifetimes básicos

Aprende los conceptos fundamentales de lifetimes en Rust para evitar referencias dangling y usar anotaciones de forma segura y eficiente.

Aprende Rust y certifícate

Referencias dangling

Las referencias dangling (o "referencias colgantes") representan uno de los problemas más comunes y peligrosos en lenguajes que permiten manipulación directa de memoria. Una referencia dangling ocurre cuando un programa mantiene una referencia a un recurso de memoria que ya ha sido liberado o ha salido de su ámbito de vida, quedando la referencia "apuntando a la nada".

En Rust, el sistema de ownership está diseñado específicamente para prevenir este tipo de errores en tiempo de compilación. Veamos cómo se manifiestan estas referencias problemáticas y cómo Rust nos protege de ellas.

¿Qué es una referencia dangling?

Una referencia dangling es aquella que apunta a una ubicación de memoria que ya no contiene los datos válidos que esperamos encontrar. Esto puede ocurrir cuando:

  • La variable original a la que apuntaba la referencia ha sido liberada
  • La variable ha salido de su ámbito (scope)
  • La memoria ha sido reasignada para otro propósito

Veamos un ejemplo de código que intentaría crear una referencia dangling y cómo Rust lo previene:

fn main() {
    let referencia_dangling = crear_dangling();
}

fn crear_dangling() -> &String {  // ERROR: falta parámetro de lifetime
    let s = String::from("hola mundo");
    &s  // Intentamos devolver una referencia a s
}  // Aquí s sale del ámbito y se destruye, pero intentamos devolver una referencia a ella

Este código no compilará. El compilador de Rust mostrará un error similar a:

error[E0106]: missing lifetime specifier
 --> src/main.rs:5:24
  |
5 | fn crear_dangling() -> &String {
  |                        ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from

¿Por qué Rust rechaza este código?

Rust detecta que estamos intentando devolver una referencia a un valor que será destruido al finalizar la función. Si este código funcionara:

  1. Creamos s dentro de crear_dangling()
  2. Devolvemos una referencia a s
  3. Al finalizar la función, s se destruye (porque Rust libera automáticamente la memoria al salir del ámbito)
  4. La referencia devuelta apuntaría a memoria liberada (¡dangling!)
  5. Usar esta referencia causaría comportamiento indefinido

Soluciones al problema

Hay varias formas de resolver este problema:

1. Transferir la propiedad en lugar de prestar una referencia:

fn main() {
    let string_segura = crear_string();
    println!("String: {}", string_segura);
}

fn crear_string() -> String {
    let s = String::from("hola mundo");
    s  // Devolvemos la propiedad de s, no una referencia
}

2. Pasar la referencia desde fuera:

fn main() {
    let string_original = String::from("hola mundo");
    let referencia = obtener_referencia(&string_original);
    println!("Referencia: {}", referencia);
}

fn obtener_referencia(s: &String) -> &String {
    s  // Devolvemos la misma referencia que recibimos
}

El papel de los lifetimes

Para entender por qué ocurren las referencias dangling, necesitamos comprender el concepto de lifetime (tiempo de vida). Cada referencia en Rust tiene un lifetime, que es el ámbito durante el cual la referencia es válida.

El compilador de Rust utiliza un analizador de préstamos (borrow checker) que compara los ámbitos para determinar si todas las referencias son válidas en tiempo de compilación.

Veamos un ejemplo sencillo donde el borrow checker identifica un problema potencial:

fn main() {
    let r;                // Declaramos r sin inicializar
    {
        let x = 5;        // x entra en ámbito
        r = &x;           // r toma una referencia a x
    }                     // x sale de ámbito y se destruye
    println!("r: {}", r); // ERROR: r es una referencia dangling
}

Este código no compilará porque Rust detecta que r contendría una referencia a x después de que x haya sido destruido.

Prevención de referencias dangling en estructuras

Las referencias dangling también pueden ocurrir cuando almacenamos referencias en estructuras. Veamos un ejemplo:

struct Extracto<'a> {
    texto: &'a str,  // Esta referencia necesita una anotación de lifetime
}

fn main() {
    let extracto;
    {
        let novela = String::from("Llamada una mañana de primavera...");
        extracto = Extracto {
            texto: &novela[0..10],
        };
    }  // novela sale de ámbito aquí
    
    // ERROR: esto no compilará porque novela ya no existe
    println!("Extracto: '{}'", extracto.texto);
}

El compilador rechazará este código porque detecta que extracto contendría una referencia a novela después de que esta haya sido destruida.

Reglas para evitar referencias dangling

Para evitar referencias dangling en Rust, sigue estas reglas:

  • No devuelvas referencias a datos creados dentro de una función
  • Asegúrate de que los datos referenciados vivan al menos tanto como la referencia
  • Cuando almacenes referencias en estructuras, usa anotaciones de lifetime
  • Considera transferir la propiedad en lugar de usar referencias cuando sea apropiado

Detección en tiempo de compilación

Una de las grandes ventajas de Rust es que estos problemas se detectan en tiempo de compilación, no en tiempo de ejecución. Esto significa que nunca experimentarás fallos por referencias dangling en un programa Rust compilado correctamente.

fn main() {
    let string1 = String::from("larga cadena es larga");
    let resultado;
    {
        let string2 = String::from("xyz");
        resultado = mas_larga(&string1, &string2);
    }
    println!("La cadena más larga es: {}", resultado);
}

fn mas_larga<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

En este ejemplo, el compilador verifica que string1 vive lo suficiente para que resultado sea una referencia válida. Si intentáramos usar string2 (que tiene un ámbito más corto) como fuente de la referencia devuelta, el compilador lo rechazaría.

Las referencias dangling son un problema fundamental que Rust resuelve mediante su sistema de ownership y lifetimes, proporcionando seguridad de memoria sin necesidad de un recolector de basura. En la siguiente sección, exploraremos cómo especificar explícitamente estos lifetimes mediante anotaciones cuando el compilador no puede inferirlos automáticamente.

Anotaciones básicas

Las anotaciones de lifetime son una característica fundamental del sistema de préstamos de Rust que permite al compilador validar que las referencias son siempre válidas. Aunque el compilador puede inferir muchos lifetimes automáticamente, hay situaciones donde necesitamos proporcionar estas anotaciones de forma explícita.

Una anotación de lifetime se escribe con un apóstrofo (') seguido de un identificador, típicamente una letra minúscula como 'a, 'b o 'c. Estas anotaciones no modifican cuánto tiempo viven los valores, sino que describen las relaciones entre los lifetimes de múltiples referencias.

Sintaxis de las anotaciones

La sintaxis básica para anotar lifetimes se ve así:

&'a T       // Una referencia con lifetime 'a a un valor de tipo T
&'a mut T   // Una referencia mutable con lifetime 'a a un valor de tipo T

Estas anotaciones aparecen después del símbolo & y antes del tipo referenciado.

Anotaciones en funciones

Cuando una función acepta o devuelve referencias, a veces necesitamos especificar sus lifetimes. Veamos un ejemplo:

fn mas_larga<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

En este ejemplo:

  1. <'a> declara un parámetro de lifetime llamado 'a
  2. Ambos parámetros x e y tienen el mismo lifetime 'a
  3. El valor de retorno también tiene el lifetime 'a

Esto le dice al compilador que el valor devuelto vivirá al menos tanto como el más corto de los lifetimes de los parámetros de entrada.

Anotaciones en estructuras

Cuando una estructura contiene referencias, debemos anotar sus lifetimes:

struct Cita<'a> {
    texto: &'a str,
    autor: &'a str,
}

fn main() {
    let texto = String::from("La programación en Rust es segura y eficiente.");
    let autor = String::from("Comunidad Rust");
    
    let cita = Cita {
        texto: &texto,
        autor: &autor,
    };
    
    println!("\"{}\" - {}", cita.texto, cita.autor);
}

En este ejemplo, la estructura Cita tiene un parámetro de lifetime 'a que se aplica a ambas referencias. Esto garantiza que la estructura no sobrevivirá a ninguno de los datos referenciados.

Múltiples lifetimes

A veces necesitamos especificar diferentes relaciones de lifetime:

fn primer_palabra<'a, 'b>(s: &'a str, _separador: &'b str) -> &'a str {
    match s.find(' ') {
        Some(pos) => &s[0..pos],
        None => s,
    }
}

Aquí tenemos dos lifetimes diferentes:

  • 'a para el string de entrada y el valor de retorno
  • 'b solo para el parámetro separador

Esto indica que el valor devuelto está relacionado con el lifetime de s, pero no con el de _separador.

Reglas de elisión de lifetimes

El compilador de Rust puede inferir automáticamente muchos lifetimes siguiendo tres reglas:

  1. Regla 1: Cada parámetro de referencia obtiene su propio parámetro de lifetime.
  2. Regla 2: Si hay exactamente un parámetro de entrada con lifetime, ese lifetime se asigna a todos los parámetros de salida.
  3. Regla 3: Si hay múltiples parámetros de entrada pero uno de ellos es &self o &mut self, el lifetime de self se asigna a todos los parámetros de salida.

Gracias a estas reglas, muchas funciones no necesitan anotaciones explícitas:

// No necesita anotaciones gracias a la regla 2
fn primera_linea(s: &str) -> &str {
    match s.find('\n') {
        Some(pos) => &s[0..pos],
        None => s,
    }
}

// Equivalente a:
// fn primera_linea<'a>(s: &'a str) -> &'a str { ... }

Implementaciones con lifetimes

También necesitamos especificar lifetimes cuando implementamos métodos para estructuras con referencias:

struct Parrafo<'a> {
    contenido: &'a str,
}

impl<'a> Parrafo<'a> {
    fn nueva_linea_pos(&self) -> Option<usize> {
        self.contenido.find('\n')
    }
    
    fn primera_linea(&self) -> &str {
        match self.nueva_linea_pos() {
            Some(pos) => &self.contenido[..pos],
            None => self.contenido,
        }
    }
}

Observa cómo declaramos el parámetro de lifetime 'a después de impl y lo usamos para especificar a qué versión de Parrafo estamos implementando métodos.

Casos de uso comunes

Hay varios patrones comunes donde las anotaciones de lifetime son necesarias:

1. Devolver una referencia de una colección:

fn obtener_elemento<'a>(vector: &'a Vec<String>, indice: usize) -> Option<&'a String> {
    vector.get(indice)
}

2. Almacenar referencias en una estructura:

struct Usuario<'a> {
    nombre: &'a str,
    email: &'a str,
}

fn crear_usuario<'a>(nombre: &'a str, email: &'a str) -> Usuario<'a> {
    Usuario { nombre, email }
}

3. Métodos que devuelven referencias a campos:

struct Libro<'a> {
    titulo: &'a str,
    autor: &'a str,
    contenido: &'a str,
}

impl<'a> Libro<'a> {
    fn titulo(&self) -> &str {
        self.titulo
    }
    
    fn resumen(&self) -> &str {
        if self.contenido.len() > 100 {
            &self.contenido[..100]
        } else {
            self.contenido
        }
    }
}

Cuándo usar anotaciones

Necesitarás usar anotaciones de lifetime explícitas cuando:

  • Una función devuelve una referencia y hay ambigüedad sobre su origen
  • Una estructura almacena referencias
  • Implementas métodos para estructuras con referencias
  • Necesitas expresar relaciones complejas entre lifetimes de diferentes parámetros

El compilador de Rust te guiará con mensajes de error claros cuando las anotaciones sean necesarias, indicando dónde y cómo añadirlas.

Beneficios de las anotaciones de lifetime

Las anotaciones de lifetime proporcionan varios beneficios importantes:

  • Seguridad de memoria garantizada en tiempo de compilación
  • Documentación implícita sobre las relaciones entre referencias
  • Flexibilidad para expresar diferentes restricciones de lifetime
  • Prevención de errores sutiles relacionados con referencias

Con las anotaciones de lifetime, Rust logra un equilibrio único entre seguridad y control sobre la memoria, permitiéndote trabajar con referencias de forma segura sin necesidad de un recolector de basura.

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 Lifetimes básicos 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 dangling y por qué son peligrosas.
  • Entender cómo Rust previene referencias dangling con su sistema de ownership y borrow checker.
  • Aprender la sintaxis y uso básico de las anotaciones de lifetime en funciones y estructuras.
  • Conocer las reglas de elisión de lifetimes que permiten inferencia automática.
  • Saber cuándo y cómo usar anotaciones explícitas para garantizar la validez de las referencias.