O que são non-lexical lifetimes? Conhecendo a principal melhoria do Rust
\n\nTodo mundo que escreveu em Rust por pelo menos algumas semanas já passou por uma situação: o código é absolutamente lógico, seguro, mas o compilador (ou melhor, seu rigoroso borrow checker) se recusa a compilá-lo. Você olha para o erro e pensa: "Mas eu não estou mais usando esta referência! Por que ele está reclamando?".
\n\nAté a versão Rust 1.31 (edição 2018), a resposta era simples: "Porque o tempo de vida da variável (lifetime) é determinado pelo escopo — lexicalmente". Isso são os lexical lifetimes. A partir do Rust 2018, o compilador ficou mais inteligente. Ele ganhou os non-lexical lifetimes (NLL) — tempos de vida não lexicais.
\n\nNeste artigo, vamos entender o que é essa ferramenta, por que seu surgimento foi uma revolução para os desenvolvedores Rust, e como ela torna o código mais limpo e intuitivo. Não vamos nos aprofundar nos meandros da teoria dos tipos, mas sim nos concentrar em exemplos práticos.
\n\nO problema dos lexical lifetimes: por que o borrow checker era muito rigoroso
\n\nVamos lembrar como funciona o empréstimo (borrowing) em Rust. Temos as regras:
\n- \n
- A qualquer momento, pode haver uma referência mutável (&mut T) ou qualquer número de referências imutáveis (&T). \n
- As referências devem ser sempre válidas (não se pode referenciar dados deletados). \n
Antes do NLL, o compilador determinava o tempo de vida de uma referência pelo seu escopo léxico. Ou seja, de { até }, onde a variável é declarada. Se uma referência é criada dentro de um bloco, ela "vive" até o final desse bloco, mesmo que você não a use mais.
Veja este exemplo clássico que não compilava nas versões antigas do Rust:
\n\nfn main() {\n let mut data = vec![1, 2, 3];\n\n let x = &data[0]; // referência imutável\n\n data.push(4); // tentativa de empréstimo mutável\n\n println!("{}", x); // uso da referência imutável\n}\n\nDo ponto de vista humano: criamos uma referência x, depois modificamos o vetor, e depois usamos x. Mas o vetor data ao chamar push poderia realocar a memória, e a referência x se tornaria inválida. O compilador está certo — isso é perigoso.
Mas e se usarmos a referência antes da modificação? Eis a verdadeira dor:
\n\nfn main() {\n let mut name = String::from("Alice");\n\n let first_char = &name[0..1]; // referência imutável\n println!("First char: {}", first_char); // usamos ela\n\n name.push_str(" Johnson"); // modificamos a string\n println!("Full name: {}", name);\n}\n\nEste código é seguro. Usamos a referência first_char, a exibimos, e só depois modificamos a string. Mas antes do Rust 2018, o compilador gerava um erro! Por quê? Porque o tempo de vida léxico da referência first_char durava até o final da função main, ou seja, até a chave de fechamento. E um empréstimo mutável (push_str) dentro desse intervalo é proibido.
Os desenvolvedores tinham que usar gambiarras: introduzir blocos adicionais {} para limitar o tempo de vida da referência manualmente:
fn main() {\n let mut name = String::from("Alice");\n\n {\n let first_char = &name[0..1];\n println!("First char: {}", first_char);\n } // a referência first_char morre aqui\n\n name.push_str(" Johnson");\n println!("Full name: {}", name);\n}\n\nIsso funcionava, mas era inconveniente, poluía o código com chaves extras e dificultava a leitura. Especialmente em funções complexas com vários empréstimos.
\n\nNon-lexical lifetimes (NLL): como funciona
\n\nA ideia do NLL é genial em sua simplicidade: o tempo de vida de uma referência não é determinado pelo escopo, mas sim por onde ela é realmente usada.