Lifetimes: introdução
Esta aula introduz o conceito de lifetimes em Rust, explicando como o compilador rastreia o tempo de vida das referências para garantir segurança de memória. Aborda anotações básicas com 'a, regras de elisão e situações que exigem anotações explícitas.
Lifetimes são uma das características mais distintivas do Rust. Elas garantem que todas as referências sejam válidas enquanto estiverem em uso, prevenindo dangling references e data races sem a necessidade de um garbage collector. Nesta aula, vamos entender o que são lifetimes, como anotá-las, as regras de elisão (lifetime elision) e quando o compilador exige que você as especifique explicitamente.
Lifetimes são a forma que o Rust tem de garantir que referências não vivam mais que os dados aos quais apontam. Cada referência em Rust possui um lifetime, que é o escopo para o qual a referência é válida. Na maioria das vezes, os lifetimes são inferidos automaticamente, mas em algumas situações precisamos anotá-los para ajudar o compilador a entender as relações entre referências.
O que são lifetimes
Lifetimes são uma construção de tempo de compilação que representam o escopo durante o qual uma referência é válida. Eles não afetam o comportamento em tempo de execução; são apenas uma ferramenta para o borrow checker verificar se o código é seguro. O objetivo principal é evitar que uma referência aponte para memória liberada ou inválida.
Considere o seguinte exemplo que não compila:
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("{}", r);
}Aqui, a referência r aponta para x, mas x sai de escopo antes de r ser usado. O compilador rejeita esse código porque o lifetime de r é maior que o de x. O erro indica que x não vive o suficiente.
Anotações básicas ('a)
Para anotar lifetimes, usamos apóstrofos seguidos de um nome, como 'a. As anotações de lifetime são colocadas após o & e antes do tipo, por exemplo: &'a i32. Elas não alteram o tempo de vida real; apenas nomeiam os lifetimes para que o compilador possa verificar as relações.
Um exemplo clássico é uma função que retorna a maior de duas referências:
fn maior<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x > y { x } else { y }
}A anotação 'a indica que o lifetime da referência retornada é o mesmo que o lifetime dos parâmetros de entrada. Isso significa que a referência retornada será válida enquanto ambos os parâmetros forem válidos. Sem a anotação, o compilador não saberia como relacionar os lifetimes.
Elisão
A elisão de lifetimes (lifetime elision) são regras que permitem ao compilador inferir lifetimes em funções sem anotações explícitas. As regras são aplicadas em três casos comuns:
- Cada parâmetro que é uma referência ganha seu próprio lifetime.
- Se houver exatamente um lifetime de entrada, ele é atribuído a todas as referências de saída.
- Se houver múltiplos lifetimes de entrada, mas um deles for
&selfou&mut self, o lifetime deselfé atribuído a todas as referências de saída.
Por exemplo, a função fn primeiro(x: &str, y: &str) -> &str não compila porque o compilador não consegue determinar qual lifetime de entrada usar para a saída. Já funções como fn nome(&self) -> &str funcionam porque a regra de elisão aplica o lifetime de self à saída.
fn exemplo(x: &str) -> &str {
x // Funciona: apenas um lifetime de entrada
}
fn exemplo2(x: &str, y: &str) -> &str {
x // ERRO: não sabe qual lifetime usar
}Quando o compilador exige
O compilador exige anotações de lifetime explícitas quando as regras de elisão não são suficientes. Isso acontece principalmente em funções que recebem múltiplas referências e retornam uma referência, e não há um self para guiar. Também é necessário em structs que contêm referências, pois o lifetime da struct deve ser anotado.
Exemplo de struct com lifetime:
struct Trecho<'a> {
parte: &'a str,
}
fn main() {
let texto = String::from("Olá");
let trecho = Trecho { parte: &texto };
}Aqui, a struct Trecho tem um campo que é uma referência, então precisa de um lifetime 'a para indicar que a instância de Trecho não pode viver mais que a string referenciada.
Outro caso comum é em implementações de traits ou em closures que capturam referências. Em geral, sempre que você tem um tipo que contém uma referência ou uma função que retorna uma referência baseada em múltiplas entradas, provavelmente precisará anotar lifetimes.
Referências
- The Rust Programming Language: Lifetime Syntax
- Rust by Example: Lifetimes
- Rust Reference: Lifetime Elision
- The Rustonomicon: Lifetimes
- Rust Book (First Edition): Lifetimes
Exercícios
Explique por que o código a seguir não compila e como corrigi-lo com lifetimes:
fn maior(x: &i32, y: &i32) -> &i32 { if x > y { x } else { y } }✓ Resposta: O código não compila porque a função retorna uma referência, mas não há anotação de lifetime para relacionar a saída com as entradas. A correção é adicionar um lifetime'aa ambos os parâmetros e ao tipo de retorno:fn maior<'a>(x: &'a i32, y: &'a i32) -> &'a i32.O que é elisão de lifetimes? Dê um exemplo de função onde ela se aplica e um onde não se aplica.
✓ Resposta: Elisão é um conjunto de regras que permitem ao compilador inferir lifetimes automaticamente. Exemplo onde se aplica:fn primeiro(x: &str) -> &str { x }funciona sem anotações. Exemplo onde não se aplica:fn maior(x: &str, y: &str) -> &strrequer anotação porque há múltiplos lifetimes de entrada.Escreva uma struct
Livroque contém uma referência a uma string (&str). Anote corretamente o lifetime.✓ Resposta:struct Livro<'a> { titulo: &'a str, }O código a seguir compila? Se não, corrija-o com lifetimes.
fn retorna_ref(x: &i32, y: &i32) -> &i32 { y }✓ Resposta: Não compila. Correção:fn retorna_ref<'a>(x: &'a i32, y: &'a i32) -> &'a i32 { y }.Explique a diferença entre
&'a i32e&i32.✓ Resposta:&'a i32é uma referência com lifetime explícito'a, enquanto&i32é uma referência com lifetime inferido. Na prática, ambos representam o mesmo conceito, mas a versão anotada é necessária quando o compilador não consegue inferir.