Borrowing e Lifetime em Rust através de exemplos

1. Visão geral

Borrowing e lifetime são os dois conceitos que mais fazem iniciantes em Rust “lutar” contra o compilador. Este artigo explica borrowing (&T, &mut T) e lifetime annotation ('a) através de exemplos com domínios familiares como Order e Customer. Após ler, você será capaz de entender as mensagens de erro do borrow checker, saber quando precisa escrever lifetime annotations e quando o compilador consegue inferir automaticamente.

2. Ownership - uma rápida revisão

Antes de falar sobre borrowing, é importante lembrar: cada valor em Rust tem apenas um owner. Quando você passa um valor por valor, a ownership é movida.

struct Customer {
    name: String,
}

fn print_customer(customer: Customer) {
    println!("Cliente: {}", customer.name);
}

fn main() {
    let customer = Customer { name: String::from("Nguyen Van A") };
    print_customer(customer);
    // println!("{}", customer.name); // erro: customer foi movido
}

print_customer recebe Customer por valor, então customer é movido para dentro da função. A linha comentada, se descomentada, resultará em erro:

error[E0382]: borrow of moved value: `customer`

Borrowing surgiu para resolver exatamente esse problema: permitir que outras funções “emprestem” dados sem precisar tomar a ownership.

3. Borrowing imutável: &T

Use &T quando apenas precisa ler dados, sem modificar.

struct Order {
    items: Vec<f64>,
}

fn calculate_total(order: &Order) -> f64 {
    order.items.iter().sum()
}

fn main() {
    let order = Order { items: vec![100.0, 250.0, 50.0] };
    let total = calculate_total(&order);

    println!("Total: {}", total);
    println!("Quantidade de itens: {}", order.items.len()); // order ainda pode ser usado
}
Total: 400
Quantidade de itens: 3

calculate_total recebe &Order - uma referência imutável. order ainda pertence a main, a função apenas “empresta temporariamente” para ler. Portanto, após chamar a função, main ainda pode usar order normalmente. Você pode criar múltiplas &Order simultaneamente, desde que não haja nenhuma &mut Order no meio.

4. Borrowing mutável: &mut T

Quando precisa modificar dados através de uma referência, use &mut T.

struct Order {
    items: Vec<f64>,
    discount_percent: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.discount_percent = percent;
}

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
    apply_discount(&mut order, 10.0);

    println!("Desconto: {}%", order.discount_percent);
}
Desconto: 10%

Observe dois pontos importantes: a variável order deve ser declarada como mut, e a chamada da função deve passar &mut order. Diferentemente de &T, em um determinado momento existe apenas uma &mut T, e não pode haver nenhuma outra &T viva ao mesmo tempo.

5. Borrow checker: a regra “um dono, múltiplos leitores ou um escritor”

A regra fundamental: com um dado, em um determinado momento você pode ter múltiplas &T ou uma &mut T, não pode misturar as duas.

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };

    for item in &order.items {
        order.items.push(*item); // erro: order.items está sendo emprestado imutavelmente para o loop
    }
}

for item in &order.items cria uma referência imutável que vive durante todo o loop. Dentro do loop, order.items.push(...) precisa emprestar o mesmo dado como mutável → conflito. O compilador reporta:

error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable

Uma forma comum de corrigir: coletar os valores a adicionar em um Vec temporário, depois fazer extend após o loop terminar - ou seja, separar os dois empréstimos em momentos diferentes.

Nota: O borrow checker analisa por “escopo de vida real” (non-lexical lifetimes), não por blocos {}. Se dois empréstimos não se sobrepõem no tempo, são válidos, mesmo que estejam no mesmo bloco.

6. O que é Lifetime

Lifetime é a forma como Rust descreve “por quanto tempo uma referência vive”, para garantir que você nunca use uma referência após o dado original ter sido descartado (dropped). Na maioria das vezes você não precisa escrever lifetime - o compilador infere automaticamente. Mas quando uma função recebe múltiplas referências e retorna uma referência, o compilador precisa que você deixe claro de qual parâmetro a referência retornada está sendo “emprestada”.

6.1. Lifetime annotation em funções

fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let customer1 = String::from("Nguyen Van A");
    let customer2 = String::from("Tran Thi B mais comprido");

    let longest = longest_name(&customer1, &customer2);
    println!("Nome mais longo: {}", longest);
}
Nome mais longo: Tran Thi B mais comprido

'a não cria um novo tempo de vida, é apenas um nome para vincular: a, b e o valor retornado devem todos viver pelo menos por 'a. O compilador não sabe em runtime se a função retorna a ou b, então força que ambas as entradas e a saída usem o mesmo lifetime para garantir segurança em qualquer cenário.

Se você escrever errado - retornando uma referência não relacionada ao lifetime declarado:

fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
    let local = String::from("temporário");
    &local // erro: local é descartado ao final da função
}
error[E0515]: cannot return value referencing local variable `local`

6.2. Lifetime em structs

Structs que contêm referências também precisam de lifetime annotation, porque Rust deve garantir que a struct não viva mais tempo do que o dado que ela empresta.

struct Customer {
    name: String,
}

struct OrderSummary<'a> {
    customer: &'a Customer,
    total: f64,
}

fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
    OrderSummary { customer, total }
}

fn main() {
    let customer = Customer { name: String::from("Le Van C") };
    let summary = build_summary(&customer, 500.0);

    println!("{} - total: {}", summary.customer.name, summary.total);
}

OrderSummary<'a> não possui Customer, apenas mantém uma referência. O lifetime 'a força que summary não possa viver mais tempo do que customer. Se você deixar customer ser descartado antes de usar summary, o compilador bloqueia no compile-time - nenhum dangling pointer consegue passar para runtime.

6.3. Quando não precisa escrever lifetime (elision)

Rust tem 3 regras para inferir lifetime automaticamente (lifetime elision), aplicáveis na maioria dos casos:

  1. Cada parâmetro que é referência recebe seu próprio lifetime.
  2. Se a função tem exatamente um lifetime de entrada, ele é atribuído à saída.
  3. Se há &self ou &mut self, o lifetime de self é atribuído à saída.
fn first_word(text: &str) -> &str {
    // regra 2: apenas 1 lifetime de entrada -> atribuído automaticamente à saída
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

Esta função não precisa escrever 'a porque tem apenas um parâmetro referência - o compilador infere automaticamente que a saída empresta de text. Você só precisa escrever lifetime manualmente quando tem mais de uma referência de entrada e a saída não cai nos casos 2 ou 3.

7. Exemplo prático: parser retornando slice

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

fn main() {
    let order_note = String::from("entregar rápido no mesmo dia");
    let word = first_word(&order_note);

    println!("Primeira palavra: {}", word);
}
Primeira palavra: entregar

first_word não cria uma nova String, apenas retorna um slice (&str) dentro da memória de order_note. Este é um padrão muito comum em parsers Rust: evitar alocações extras, apenas “apontar” para os dados originais. O lifetime garante que word não possa viver mais tempo do que order_note.

Tente escrever de forma errada para ver o compilador protegendo você:

fn first_word_broken() -> &str {
    let text = String::from("entregar rápido");
    first_word(&text) // text é descartado ao final da função
}
error[E0106]: missing lifetime specifier

O compilador recusa compilar porque não há forma de atribuir um lifetime válido à saída - text certamente morrerá antes do valor retornado ser usado no local que chamou.

Testes para first_word

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn retorna_primeira_palavra() {
        let note = "entregar rápido no mesmo dia";
        assert_eq!(first_word(note), "entregar");
    }

    #[test]
    fn retorna_tudo_quando_sem_espaco() {
        let note = "envio";
        assert_eq!(first_word(note), "envio");
    }
}

Execute cargo test:

running 2 tests
test tests::retorna_primeira_palavra ... ok
test tests::retorna_tudo_quando_sem_espaco ... ok

8. Erros comuns

  • Retornar referência para variável local: a variável é descartada ao final da função, mas a referência ainda “vive” no local que chamou → compilador bloqueia com erro de lifetime, nunca deixa passar para runtime.
  • Manter &T por muito tempo e depois emprestar &mut T: comum quando você guarda o resultado de .iter() em uma variável e depois muta a struct original no mesmo escopo. Forma de corrigir: reduzir o escopo do empréstimo imutável usando um bloco {}, ou separar lógica de leitura/escrita em duas etapas.
  • Abuso de clone() para contornar borrow checker: clone() sempre compila, mas custa copiar dados. Use apenas quando realmente precisar de uma cópia independente, não para “apagar o fogo” de um erro de borrow.
  • Escrever lifetime annotation extra: se a função tem apenas uma referência de entrada, não escreva 'a manualmente - as regras de elision já resolvem, escrever a mais só deixa o código confuso.

Nota: Se você se vir precisando adicionar 'static para o código compilar, pare e reconsidere o design. 'static força dados a viverem o programa todo, geralmente é sinal de que você está evitando o problema real de ownership ao invés de resolvê-lo.

9. Conclusão

Borrowing permite emprestar dados sem tomar ownership, com a regra “múltiplos leitores ou um escritor” verificada pelo borrow checker em tempo de compilação. Lifetime é a linguagem para você e o compilador combinarem entendimento sobre quanto tempo uma referência vive, especialmente importante quando a função retorna uma referência ou uma struct mantém uma referência internamente. Na maioria dos casos o compilador infere lifetime através das regras de elision; você só precisa escrever manualmente quando tem mais de uma referência de entrada com ambiguidade sobre a origem da saída.

rustborrowinglifetimeownershipmemory-safety