Um programa em C que libera um buffer e continua lendo dele ainda compila. Depois corrompe a memória, às vezes na hora, às vezes três requisições depois, numa parte do código que nunca tocou aquele buffer. O Rust rejeita esse programa já no cargo build, sem nenhum garbage collector envolvido. O compilador rastreia quem é o owner de cada valor e quem pode acessá-lo. Esse rastreamento é ownership e borrowing.

Quais são as regras de ownership do Rust?

O modelo de ownership do Rust se resume a três regras:

  1. Todo valor tem exatamente um owner.
  2. Só pode haver um owner por vez.
  3. Quando o owner sai do seu scope, o valor é dropado.

“Dropado” significa que o destrutor roda e a memória é liberada na hora, não quando um garbage collector resolver cuidar disso. Esse é o acordo que o Rust propõe: nenhum runtime decidindo quando recuperar memória, em troca do compilador aplicar essas regras em cada linha que você escreve.

A regra 2 é a que mais confunde no começo. Atribua uma String a uma segunda variável e a primeira para de funcionar:

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}
1
2
3
4
5
6
7
8
9
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

let s2 = s1 não copia os bytes da string. String guarda um ponteiro para um buffer na heap, um tamanho e uma capacidade. Copiar essa struct bit a bit deixaria dois owners apontando para o mesmo buffer, e quando os dois saíssem do scope, o buffer seria liberado duas vezes. O Rust evita isso transformando a atribuição em um move: s2 assume a posse do valor, e s1 deixa de ser válida. Passar uma String para uma função faz a mesma coisa: a função assume a posse, e a variável que você passou fica inválida do lado de quem chamou.

Por que inteiros não são movidos no Rust?

Inteiros não têm esse problema:

1
2
3
4
5
6
fn main() {
    let x = 5;
    let y = x;

    println!("{x} and {y}");
}

Isso compila. i32 implementa a trait Copy, então a atribuição duplica o valor em vez de movê-lo. Não há nada na heap para ser liberado duas vezes, só quatro bytes na stack. Todo tipo inteiro, bool, todo tipo de ponto flutuante, char, e tuplas formadas só por tipos Copy ((i32, bool), não (i32, String)) se comportam da mesma forma. Um tipo só pode implementar Copy se nenhuma de suas partes implementar Drop, que é como o Rust deixa claro que, se a limpeza importa, a duplicação precisa ser explícita.

Quando você quer uma segunda cópia independente de uma String ou de um Vec, você torna isso explícito com .clone(). É uma alocação extra na heap, e é exatamente por isso que o Rust não faz isso silenciosamente em toda atribuição.

Como funciona o borrowing no Rust?

Se toda função que precisasse ler um valor tivesse que assumir a posse dele, você acabaria devolvendo valores de cada função dentro de uma tupla só para continuar usando-os. O borrowing elimina isso: pegue uma referência com &, use o valor, e a ownership nunca muda de mãos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

s1 continua válida depois da chamada, porque calculate_length apenas tomou ela de empréstimo. O tipo do parâmetro &String é uma referência: ela aponta para os dados de s1 sem ser a owner, então não há nada para dropar quando o borrow termina.

Duas regras mantêm isso seguro:

  • Em um dado momento, você pode ter ou uma única referência mutável a um valor, ou qualquer quantidade de referências imutáveis, nunca as duas.
  • Uma referência nunca pode viver mais do que o dado para o qual ela aponta.

Quebre a primeira e o compilador te para:

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{r1}, {r2}, and {r3}");
}
1
2
3
4
5
6
7
8
9
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

Duas referências mutáveis para o mesmo valor ao mesmo tempo também são rejeitadas (error[E0499]: cannot borrow `s` as mutable more than once at a time). Nenhum dos dois erros é arbitrário. Uma referência imutável é uma promessa ao resto do programa de que o valor não vai mudar por debaixo dela, e uma referência mutável precisa de exclusividade para manter essa promessa. O bug clássico de thread-safety em C ou Go, em que dois pedaços de código escrevem na mesma memória enquanto um terceiro a lê, é exatamente o que essas regras tornam impossível de compilar.

Isso não significa que o Rust capture toda race condition que você possa escrever. O Rust seguro ainda pode travar num mutex ou errar a ordem das operações, e blocos unsafe podem pular essas verificações completamente. O que o borrow checker elimina é uma forma específica e muito comum do bug: aliasing de memória mutável, detectado antes de o programa rodar.

O que significam anotações de lifetime como ‘a?

Toda referência tem um lifetime: o scope em que ela é válida. Normalmente o compilador resolve isso sozinho. Ele precisa da sua ajuda quando uma função retorna uma referência e há mais de uma referência de entrada em jogo, porque não há como saber a qual entrada a saída está ligada.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

'a é um parâmetro de lifetime, lido como “tick-a”. É um nome, não uma duração. Escrever &'a str nos dois parâmetros e no tipo de retorno diz ao compilador: a referência que eu devolvo vive, no máximo, tanto quanto a mais curta entre x e y. A anotação não muda quanto tempo nada vive. Ela descreve uma relação que o compilador já precisa verificar, o que permite rejeitar uma chamada em que a referência devolvida viveria mais do que uma de suas entradas.

Você não escreve 'a em todo lugar, porque o Rust elide isso nos casos comuns. Uma função com exatamente um parâmetro de referência recebe esse lifetime atribuído ao tipo de retorno automaticamente, e um método que recebe &self atribui o lifetime de self ao tipo de retorno. É por isso que fn first_word(s: &str) -> &str compila sem nenhuma anotação: uma entrada, uma saída, nada ambíguo. longest recebe dois parâmetros de referência independentes e nenhum self, então a elisão não tem onde se apoiar, e você escreve o lifetime manualmente.

O que fazer quando o borrow checker trava seu design

As reclamações do borrow checker parecem hostis nas primeiras semanas. E às vezes a versão direta de uma estrutura de dados simplesmente não encaixa no modelo de um único owner por vez: uma árvore com ponteiros para o pai, ou um cache que dois subsistemas precisam modificar. As saídas pragmáticas:

  • .clone() o valor em vez de lutar contra um borrow. Custa uma alocação, e é uma correção de uma linha que você pode otimizar depois, quando o resto do código já estiver funcionando.
  • Rc<T> (ou Arc<T> entre threads) para ownership compartilhada, quando mais de uma parte do programa realmente precisa manter um valor vivo.
  • RefCell<T> (ou Mutex<T> entre threads) quando você precisa modificar algo através de uma referência compartilhada. Isso move a verificação do borrow do tempo de compilação para o tempo de execução, trocando um erro do compilador por um panic se você errar.

Você não precisa se envergonhar de nenhuma dessas saídas. Elas também mostram por que esse modelo não é certo para todo projeto. Um script descartável que lê um arquivo e imprime um relatório não ganha nada rastreando ownership byte a byte, e uma linguagem com garbage collector e goroutines te leva a um programa concorrente funcionando mais rápido se a segurança de memória em tempo de compilação não for um requisito. O Rust compensa quando um use-after-free ou uma data race em produção é o tipo de falha que você não pode se dar o luxo de ter, e você prefere pagar o preço antecipadamente em erros de compilador.

Na próxima vez que o rustc rejeitar seu código com E0382 ou E0502, leia o erro antes de recorrer ao .clone() por reflexo. Ele nomeia os dois borrows em conflito e onde cada um começa, e corrigir o problema de scope que ele aponta geralmente exige menos código do que disfarçá-lo. Essas mensagens são a forma mais rápida de aprender o modelo; elas são mais precisas do que a maioria das explicações sobre ele, essa incluída.

Artigos relacionados