Lifetimes: introdução
Esta aula introduz o conceito de lifetimes em Rust, explicando como o compilador gerencia a validade de referências para garantir segurança de memória. Aborda anotações básicas com 'a, regras de elisão e situações em que o compilador exige anotações explícitas.
Lifetimes são um dos conceitos mais distintivos do Rust, responsáveis por garantir que referências nunca sejam inválidas durante a execução. Eles permitem que o compilador verifique, em tempo de compilação, que toda referência aponta para um dado ainda vivo, eliminando categorias inteiras de bugs como dangling pointers. Nesta aula, vamos entender o que são lifetimes, como anotá-los, as regras de elisão que simplificam o código e quando o compilador exige que você os explicite.
Diferente de outras linguagens que usam garbage collection ou gerenciamento manual de memória, Rust adota um sistema de ownership e lifetimes que assegura a segurança sem custo em tempo de execução. Lifetimes são uma ferramenta poderosa, mas podem parecer complexas no início; com exemplos práticos, você verá que elas seguem uma lógica consistente.
O que são lifetimes
Lifetimes (tempos de vida) são uma construção do sistema de tipos do Rust que descrevem o escopo durante o qual uma referência é válida. Toda referência em Rust tem um lifetime, que é a região do código onde o valor referenciado existe. O compilador usa essas informações para garantir que você nunca use uma referência após o valor original ter sido liberado.
Por exemplo, considere o seguinte código:
let r;
{
let x = 5;
r = &x;
}
println!("{}", r); // erro: x não vive o suficienteO compilador rejeita esse código porque r referencia x, mas x é destruído ao sair do bloco interno. O lifetime de x é menor que o de r. Lifetimes são inferidos automaticamente na maioria dos casos, mas às vezes precisamos anotá-los para ajudar o compilador.
Lifetimes não alteram o comportamento do programa; eles apenas permitem que o compilador verifique a validade das referências. Eles são uma ferramenta de verificação estática, sem custo em tempo de execução.
Anotações básicas ('a)
As anotações de lifetime usam apóstrofo seguido de um nome, geralmente letras minúsculas como 'a, 'b, etc. Elas são colocadas após o & em tipos de referência e em assinaturas de funções que envolvem referências.
Exemplo de função com lifetime anotado:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}Aqui, <'a> declara um parâmetro de lifetime. A função recebe duas referências a str com o mesmo lifetime 'a e retorna uma referência também com o mesmo lifetime 'a. Isso significa que a referência retornada será válida enquanto ambas as entradas forem válidas (na verdade, pelo menor dos lifetimes).
Outro exemplo comum é em structs que guardam referências:
struct Trecho<'a> {
parte: &'a str,
}
impl<'a> Trecho<'a> {
fn comprimento(&self) -> usize {
self.parte.len()
}
}O 'a no struct garante que a referência parte não sobreviva ao dado original. Ao implementar métodos, também precisamos repetir a anotação.
Elisão
Elisão (lifetime elision) são regras que permitem omitir anotações de lifetime em funções quando o compilador pode inferi-las automaticamente. Essas regras foram criadas para tornar o código mais limpo, já que a maioria das funções segue padrões comuns.
As regras de elisão são:
- Cada parâmetro que é uma referência ganha seu próprio lifetime.
- Se houver exatamente um lifetime de entrada, ele é atribuído a todos os lifetimes de saída.
- Se houver múltiplos lifetimes de entrada, mas um deles for
&selfou&mut self(em métodos), o lifetime deselfé atribuído a todos os lifetimes de saída.
Exemplo de função que não precisa de anotação:
fn primeira_palavra(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}Pela primeira regra, s ganha um lifetime 'a. Pela segunda, o retorno também recebe 'a. Então o compilador infere fn primeira_palavra<'a>(s: &'a str) -> &'a str.
Já a função longest que vimos antes tem dois parâmetros de entrada, então a segunda regra não se aplica (há mais de um lifetime de entrada). Como não é um método, a terceira regra também não se aplica. Portanto, o compilador não consegue inferir o lifetime do retorno e exige anotação explícita.
Quando o compilador exige
O compilador exige anotações de lifetime quando não consegue determinar, por si só, como os lifetimes das referências de entrada se relacionam com os de saída. Isso ocorre principalmente em funções que recebem múltiplas referências e retornam uma delas, ou em structs que armazenam referências.
Exemplo que exige anotação:
fn maior(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}O compilador mostra o erro: missing lifetime specifier. Isso porque não sabe se o retorno deve ter o lifetime de x ou de y. A solução é unificá-los com um lifetime comum, como vimos.
Outro caso comum é em structs com referências:
struct Pessoa {
nome: &str, // erro: missing lifetime specifier
}Structs que contêm referências precisam de um parâmetro de lifetime para que o compilador saiba por quanto tempo a struct é válida.
Também em implementações de traits, às vezes é necessário anotar lifetimes para especificar como as referências se relacionam. Por exemplo, ao implementar From:
impl<'a> From<&'a str> for String {
fn from(s: &'a str) -> String {
s.to_string()
}
}Em resumo, sempre que houver ambiguidade sobre a relação entre lifetimes de entrada e saída, o compilador pedirá anotação. A prática leva à intuição de quando é necessário.
Boas práticas
- Comece omitindo lifetimes sempre que possível; o compilador indicará quando precisar.
- Use nomes descritivos como
'ctxou'tempquando a semântica for importante, mas'aé suficiente na maioria dos casos. - Lembre-se que lifetimes são apenas verificações em tempo de compilação; não afetam o desempenho.
- Em structs, prefira owned types (
Stringem vez de&str) a menos que você precise de referências por razões de desempenho ou semântica.
Referências
- The Rust Programming Language - Lifetime Syntax
- Rust by Example - Lifetimes
- Rust Reference - Lifetime Elision
- The Rustonomicon - Lifetimes
- PhantomData - for advanced lifetime patterns
Exercícios
O código abaixo compila? Se não, corrija-o.
let s1 = String::from("hello"); let r1 = &s1; let s2 = String::from("world"); let r2 = &s2; let result = longest(r1, r2); println!("{}", result); fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } }✓ Resposta: Não compila, falta anotação de lifetime. Corrigido:fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }Adicione a anotação de lifetime necessária para a struct abaixo:
struct Livro { titulo: &str, }✓ Resposta:struct Livro<'a> { titulo: &'a str, }Por que a função abaixo NÃO precisa de anotação explícita de lifetime?
fn primeira(s: &str) -> &str { &s[..1] }✓ Resposta: Porque as regras de elisão se aplicam: há um único parâmetro de entrada, então o lifetime de saída é inferido como o mesmo do parâmetro.Escreva uma função que receba duas referências a
i32e retorne a referência ao maior valor. Anote os lifetimes corretamente.✓ Resposta:fn maior<'a>(x: &'a i32, y: &'a i32) -> &'a i32 { if x > y { x } else { y } }O código a seguir compila? Explique.
let x = 10; let r; { let y = 20; r = maior(&x, &y); } println!("{}", r); fn maior<'a>(x: &'a i32, y: &'a i32) -> &'a i32 { if x > y { x } else { y } }✓ Resposta: Não compila. O lifetime'aé unificado com o menor tempo de vida entrexey, que é o dey(dentro do bloco). Portanto, o retorno tem o lifetime dey, que termina ao sair do bloco, masré usado depois. Erro:ynão vive o suficiente.