Rust

Lifetimes — Quando o Compilador Precisa de Mais Informação Já leu

13 min de leitura

Lifetimes — Quando o Compilador Precisa de Mais Informação
  Chegamos ao conceito que mais intimida quem está aprendendo Rust. Lifetimes aparecem em mensagens de erro crípticas, em assinaturas de fun

 

Chegamos ao conceito que mais intimida quem está aprendendo Rust. Lifetimes aparecem em mensagens de erro crípticas, em assinaturas de funções cheias de apóstrofos, e parecem arbitrários à primeira vista. Mas não são — são a formalização de algo que você já sabe intuitivamente: uma referência não pode sobreviver ao valor que ela referencia.

A boa notícia é que o compilador infere lifetimes automaticamente na grande maioria dos casos. Você só precisa anotá-los explicitamente quando o compilador não tem informação suficiente para fazê-lo sozinho. E quando isso acontece, a anotação não muda o comportamento do programa — ela apenas comunica sua intenção ao compilador.


O problema que lifetimes resolve

Vamos começar com um exemplo que não compila — e entender por quê:

fn maior_string(s1: &str, s2: &str) -> &str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

fn main() {
    let string1 = String::from("longa string aqui");
    let resultado;

    {
        let string2 = String::from("xyz");
        resultado = maior_string(&string1, &string2);
    } // string2 destruída aqui

    println!("{resultado}"); // ERRO: string2 pode ter sido usada aqui
}

O compilador recusa esse código com um erro sobre lifetimes. O problema: a função retorna uma referência que pode ser s1 ou s2, dependendo do valor em tempo de execução. O compilador não sabe qual será retornada — e portanto não pode garantir que a referência retornada seja válida após string2 ser destruída.

Para resolver isso, precisamos informar ao compilador a relação entre os lifetimes dos parâmetros e do retorno.


Sintaxe de lifetime

Lifetimes são anotados com apóstrofo seguido de um nome, por convenção começando com letras minúsculas: 'a, 'b, 'resultado. O nome mais comum é simplesmente 'a:

fn maior_string<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

A anotação 'a não define quanto tempo as referências vivem — ela descreve a relação entre elas. Aqui estamos dizendo: "a referência retornada viverá pelo menos tanto quanto a menor das duas referências de entrada." O compilador usa essa informação para verificar que o uso é seguro.

Agora o compilador pode rejeitar o uso inseguro do exemplo anterior — ele sabe que o retorno está vinculado ao lifetime de string2, que é destruída antes de resultado ser usado.

A versão segura:

fn main() {
    let string1 = String::from("longa string aqui");
    let string2 = String::from("xyz");

    let resultado = maior_string(&string1, &string2);
    println!("A maior string é: {resultado}");
    // Ambas string1 e string2 ainda existem aqui — seguro
}

Lifetimes não mudam duração de vida

Este ponto merece repetição: anotações de lifetime não mudam quanto tempo um valor vive. Elas apenas descrevem relações para que o compilador possa verificar a segurança.

Pense assim: lifetimes são para referências o que tipos são para valores. Um tipo como i32 não muda o que um valor é — descreve o que ele pode fazer. Um lifetime não muda quando uma referência expira — descreve a relação entre referências.


Lifetimes em structs

Quando uma struct contém referências, você precisa anotar o lifetime dessas referências:

#[derive(Debug)]
struct Trecho<'a> {
    conteudo: &'a str,
}

impl<'a> Trecho<'a> {
    fn novo(texto: &'a str, inicio: usize, fim: usize) -> Self {
        Trecho {
            conteudo: &texto[inicio..fim],
        }
    }

    fn exibir(&self) {
        println!("Trecho: '{}'", self.conteudo);
    }

    fn tamanho(&self) -> usize {
        self.conteudo.len()
    }
}

fn main() {
    let texto = String::from("Rust é uma linguagem incrível");

    let trecho = Trecho::novo(&texto, 0, 4);
    trecho.exibir(); // Trecho: 'Rust'
    println!("Tamanho: {}", trecho.tamanho()); // 4

    // trecho não pode sobreviver a texto
    // o compilador garante isso
}

Trecho<'a> diz: "uma instância de Trecho não pode sobreviver à referência conteudo que ela contém." O compilador verifica isso em todo uso de Trecho.


Regras de elisão de lifetime

Você deve estar pensando: "Mas escrevo funções com referências o tempo todo sem anotar lifetimes." É verdade — o compilador infere lifetimes automaticamente em situações comuns, seguindo três regras chamadas regras de elisão:

Regra 1: Cada parâmetro de referência recebe seu próprio lifetime implícito.

// Você escreve:
fn tamanho(s: &str) -> usize

// Compilador lê como:
fn tamanho<'a>(s: &'a str) -> usize

Regra 2: Se há exatamente um parâmetro de referência, seu lifetime é atribuído ao retorno.

// Você escreve:
fn primeira_palavra(s: &str) -> &str

// Compilador lê como:
fn primeira_palavra<'a>(s: &'a str) -> &'a str

Regra 3: Se há um parâmetro &self ou &mut self, seu lifetime é atribuído ao retorno.

// Você escreve:
impl Struct {
    fn metodo(&self) -> &str
}

// Compilador lê como:
impl<'a> Struct<'a> {
    fn metodo(&'a self) -> &'a str
}

Quando nenhuma dessas regras resolve a ambiguidade — como no caso de maior_string com dois parâmetros de referência — o compilador pede que você anote explicitamente.


Lifetimes em métodos

Métodos de structs com lifetimes seguem a regra 3 na maioria dos casos:

#[derive(Debug)]
struct Documento<'a> {
    titulo: &'a str,
    conteudo: &'a str,
}

impl<'a> Documento<'a> {
    fn novo(titulo: &'a str, conteudo: &'a str) -> Self {
        Documento { titulo, conteudo }
    }

    // Regra 3 se aplica — retorno tem lifetime de &self
    fn titulo(&self) -> &str {
        self.titulo
    }

    // Aqui precisamos ser explícitos — retornamos 'a, não lifetime de &self
    fn conteudo(&self) -> &'a str {
        self.conteudo
    }

    fn resumo(&self, tamanho: usize) -> &str {
        let fim = tamanho.min(self.conteudo.len());
        &self.conteudo[..fim]
    }
}

fn main() {
    let titulo = String::from("Introdução ao Rust");
    let conteudo = String::from(
        "Rust é uma linguagem de programação de sistemas \
         focada em segurança e desempenho."
    );

    let doc = Documento::novo(&titulo, &conteudo);

    println!("Título: {}", doc.titulo());
    println!("Resumo: {}", doc.resumo(30));
    println!("{:?}", doc);
}

Múltiplos lifetimes

Às vezes você precisa de lifetimes diferentes para parâmetros diferentes:

// s1 e s2 podem ter lifetimes diferentes
// o retorno tem o lifetime do MENOR dos dois — 'b
fn selecionar<'a, 'b>(s1: &'a str, _s2: &'b str, usar_primeiro: bool) -> &'a str
where
    'b: 'a, // 'b sobrevive pelo menos tanto quanto 'a
{
    if usar_primeiro { s1 } else { s1 } // simplificado
}

A notação 'b: 'a é um lifetime bound — significa que 'b deve viver pelo menos tanto quanto 'a. Isso é análogo a trait bounds, mas para lifetimes.

Na prática, múltiplos lifetimes distintos são raros em código de aplicação. Aparecem mais em bibliotecas de baixo nível.


O lifetime especial 'static

'static é um lifetime especial que significa "válido durante toda a execução do programa":

fn main() {
    // Literais de string têm lifetime 'static
    // Elas estão embutidas no binário do programa
    let s: &'static str = "Eu vivo para sempre";

    // Mensagens de erro em Box<dyn Error> frequentemente requerem 'static
    let mensagem: &'static str = "erro crítico do sistema";

    println!("{s}");
    println!("{mensagem}");
}

Literais de string como "hello" têm tipo &'static str porque estão compiladas diretamente no binário — elas existem enquanto o programa existir.

Você verá 'static frequentemente em mensagens de erro e em tipos que precisam ser enviados entre threads. Não use 'static como solução rápida para problemas de lifetime — geralmente indica que algo deve ser String (com ownership) em vez de &str (referência).


Um programa completo: analisador de texto

Vamos construir um analisador que extrai informações de um texto usando referências com lifetimes explícitos:

#[derive(Debug)]
struct Analise<'a> {
    texto_original: &'a str,
    palavras: Vec<&'a str>,
}

impl<'a> Analise<'a> {
    fn nova(texto: &'a str) -> Self {
        let palavras = texto
            .split_whitespace()
            .collect();

        Analise {
            texto_original: texto,
            palavras,
        }
    }

    fn total_palavras(&self) -> usize {
        self.palavras.len()
    }

    fn total_caracteres(&self) -> usize {
        self.texto_original.len()
    }

    fn palavras_com_tamanho(&self, tamanho: usize) -> Vec<&str> {
        self.palavras
            .iter()
            .filter(|&&p| p.len() == tamanho)
            .copied()
            .collect()
    }

    fn palavra_mais_longa(&self) -> Option<&str> {
        self.palavras
            .iter()
            .max_by_key(|&&p| p.len())
            .copied()
    }

    fn palavra_mais_curta(&self) -> Option<&str> {
        self.palavras
            .iter()
            .min_by_key(|&&p| p.len())
            .copied()
    }

    fn contem_palavra(&self, busca: &str) -> bool {
        self.palavras.iter().any(|&p| {
            p.to_lowercase() == busca.to_lowercase()
        })
    }

    fn frequencia(&self, palavra: &str) -> usize {
        self.palavras
            .iter()
            .filter(|&&p| p.to_lowercase() == palavra.to_lowercase())
            .count()
    }

    fn exibir_relatorio(&self) {
        println!("── Relatório de Análise ─────────────────");
        println!("Total de palavras  : {}", self.total_palavras());
        println!("Total de caracteres: {}", self.total_caracteres());

        if let Some(longa) = self.palavra_mais_longa() {
            println!("Palavra mais longa : '{longa}' ({} chars)", longa.len());
        }

        if let Some(curta) = self.palavra_mais_curta() {
            println!("Palavra mais curta : '{curta}' ({} chars)", curta.len());
        }

        let media = self.total_caracteres() as f64 / self.total_palavras() as f64;
        println!("Tamanho médio      : {media:.1} chars/palavra");
    }
}

fn primeira_linha<'a>(texto: &'a str) -> &'a str {
    match texto.find('\n') {
        Some(pos) => &texto[..pos],
        None      => texto,
    }
}

fn palavras_em_comum<'a>(
    analise1: &'a Analise,
    analise2: &'a Analise,
) -> Vec<&'a str> {
    analise1.palavras
        .iter()
        .filter(|&&p| analise2.contem_palavra(p))
        .copied()
        .collect()
}

fn main() {
    let texto1 = "Rust é uma linguagem de programação de sistemas \
                  Rust oferece segurança sem coletor de lixo \
                  programação em Rust é diferente mas vale a pena";

    let texto2 = "Programação de sistemas requer cuidado \
                  segurança de memória é essencial em sistemas críticos \
                  Rust resolve esse problema de forma elegante";

    let analise1 = Analise::nova(texto1);
    let analise2 = Analise::nova(texto2);

    println!("=== Texto 1 ===");
    analise1.exibir_relatorio();

    println!("\nPalavras com 4 letras: {:?}",
        analise1.palavras_com_tamanho(4));

    println!("'Rust' aparece {} vezes",
        analise1.frequencia("Rust"));

    println!("\n=== Texto 2 ===");
    analise2.exibir_relatorio();

    println!("\n=== Comparação ===");
    let comuns = palavras_em_comum(&analise1, &analise2);
    let mut comuns_unicos: Vec<&str> = comuns.clone();
    comuns_unicos.dedup();
    println!("Palavras em comum: {:?}", comuns_unicos);
}

Saída:

=== Texto 1 ===
── Relatório de Análise ─────────────────
Total de palavras  : 22
Total de caracteres: 130
Palavra mais longa : 'programação' (11 chars)
Palavra mais curta : 'é' (2 chars)
Tamanho médio      : 5.9 chars/palavra

Palavras com 4 letras: ["vale", "pena"]
'Rust' aparece 3 vezes

=== Texto 2 ===
── Relatório de Análise ─────────────────
Total de palavras  : 16
Total de caracteres: 101
Palavra mais longa : 'Programação' (11 chars)
Palavra mais curta : 'de' (2 chars)
Tamanho médio      : 6.3 chars/palavra

=== Comparação ===
Palavras em comum: ["de", "sistemas", "segurança", "de", "de", "Rust"]

A intuição por trás de lifetimes

Depois de tudo isso, vale consolidar a intuição:

Lifetimes são a maneira de Rust garantir que referências nunca apontem para memória inválida. O compilador precisa provar, antes de executar qualquer linha, que toda referência aponta para algo que ainda existe.

Na maioria do código, ele consegue fazer isso sozinho — elisão de lifetime cuida disso. Mas quando você tem múltiplas referências de entrada e uma de saída, o compilador não consegue adivinhar qual entrada "alimenta" a saída. Você precisa dizer.

Com o tempo, anotar lifetimes vai parecer natural — como declarar tipos. Você não vai lutar contra o compilador; vai usá-lo como parceiro para expressar claramente as garantias do seu código.


Generics, Traits e Lifetimes juntos

Os três conceitos que estudamos nos últimos artigos — generics, traits e lifetimes — frequentemente aparecem juntos em assinaturas de funções mais avançadas:

use std::fmt::Display;

fn exibir_maior<'a, T>(lista: &'a [T], contexto: &str) -> &'a T
where
    T: PartialOrd + Display,
{
    let mut maior = &lista[0];
    for item in lista {
        if item > maior {
            maior = item;
        }
    }
    println!("[{contexto}] Maior: {maior}");
    maior
}

fn main() {
    let numeros = vec![10, 45, 23, 78, 12];
    let maior = exibir_maior(&numeros, "inteiros");
    println!("Referência ao maior: {maior}");

    let palavras = vec!["banana", "abacaxi", "caju", "manga"];
    exibir_maior(&palavras, "frutas");
}

Saída:

[inteiros] Maior: 78
Referência ao maior: 78
[frutas] Maior: manga

Essa função tem um parâmetro de lifetime 'a, um parâmetro de tipo T com dois trait bounds, e usa a cláusula where para legibilidade. É o padrão mais completo que você encontrará em código Rust avançado.


Fontes e leituras recomendadas

Comentários

Mais em Rust

Funções, Expressões e Como Rust Pensa Diferente sobre Retorno de Valores
Funções, Expressões e Como Rust Pensa Diferente sobre Retorno de Valores

Se voc&ecirc; vem de Python, JavaScript ou Java, j&aacute; sabe o que &eacute...

Iteradores e Closures — O Estilo Funcional de Rust
Iteradores e Closures — O Estilo Funcional de Rust

&nbsp; Nos artigos anteriores usamos&nbsp;for para percorrer cole&ccedil;&ot...

Smart Pointers — Box, Rc e RefCell
Smart Pointers — Box, Rc e RefCell

&nbsp; At&eacute; agora trabalhamos com dados na stack e refer&ecirc;ncias s...