Ownership em Rust: segurança de memória sem GC

1. Visão geral

Ownership é o mecanismo central que permite que Rust garanta segurança de memória sem necessidade de garbage collector (GC) como em Java ou Go, e sem precisar fazer malloc/free manualmente como em C. Este artigo explica as três regras de ownership, o conceito de borrowing, lifetime, e os erros de compile-time mais comuns. Depois de ler, você entenderá por que Rust é “exigente” na hora de compilar, mas em troca não tem dangling pointer, double free ou data race em tempo de execução.

2. O problema: gerenciamento de memória sem GC

Linguagens como Java usam GC para fazer limpeza de lixo — é conveniente mas consome CPU e causa pausas imprevisíveis. C/C++ te dão total controle mas é fácil cometer double free, use-after-free, memory leak. Rust escolhe um terceiro caminho: o compilador acompanha quem “possui” quais dados, e libera automaticamente quando o proprietário sai do escopo. Sem overhead de runtime, sem necessidade de thread de GC.

fn main() {
    let s = String::from("Non La Dev");
    println!("{s}");
} // s sai do escopo aqui, Rust automatically calls drop(s) para liberar heap memory

A linha String::from aloca memória no heap. Quando a variável s sai do escopo }, Rust insere automaticamente uma chamada a drop — similar a finally { free(s) } mas gerado pelo compilador, você não precisa escrever manualmente.

3. As três regras de Ownership

Rust tem exatamente três regras, decorar elas é dominar 80% do mecanismo:

  1. Cada valor em Rust tem uma variável chamada owner.
  2. Em um dado momento, existe apenas um owner único.
  3. Quando o owner sai do escopo, o valor é drop (liberado).

3.1. Semântica de Move

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // ownership faz "move" de s1 para s2

    println!("{s2}");
    // println!("{s1}"); // erro compile: s1 foi movido
}

Diferentemente de Java (atribuir uma variável apenas copia a referência, ambas apontam para o mesmo objeto, GC cuida do resto), Rust vê let s2 = s1 como transferência de propriedade. Depois dessa linha, s1 não é mais válido. Se descomentar a última linha, você receberá um erro:

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:5:20
   |
2  |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `String`
3  |     let s2 = s1;
   |              -- value moved here
4  |
5  |     println!("{s1}");
   |                ^^^^ value borrowed here after move

Isso previne completamente o erro de double free: se tanto s1 quanto s2 mantivessem um pointer para a mesma região de heap, quando ambos saíssem do escopo, Rust tentaria fazer drop duas vezes — causando crash. O compilador bloqueia isso na hora da compilação.

3.2. Clone quando você quer manter ambos

Se você realmente precisa de duas cópias independentes, chame .clone() explicitamente:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // deep copy da região de heap

    println!("{s1} - {s2}"); // ambos são válidos
}

.clone() aloca uma nova região de heap e copia o conteúdo. O custo de runtime é explícito, e Rust exige que você chame isso para evitar que alguém clone acidentalmente uma estrutura de dados gigante sem perceber — diferente de muitas linguagens que fazem cópia silenciosa.

3.3. Trait Copy para tipos de dados simples

fn main() {
    let x = 5;
    let y = x; // copy, não é move

    println!("x = {x}, y = {y}"); // ambos ainda são usáveis
}

Tipos primitivos como i32, bool, char, ou tuples que contêm apenas esses tipos implementam a trait Copy. Como têm tamanho fixo e vivem completamente na stack, copiar é mais barato que fazer move, então Rust copia automaticamente em vez de invalidar a variável original.

Nota: um tipo não pode implementar tanto Copy quanto Drop. Se o tipo tem recursos que precisam ser limpos (heap, file handle…), ele não pode ser Copy.

4. Ownership ao passar em funções

Passar uma variável para uma função segue a mesma semântica de move que atribuição de variável:

fn print_order(order: String) {
    println!("Order: {order}");
} // order é dropped aqui, não no caller

fn main() {
    let order = String::from("ORDER-001");
    print_order(order);
    // println!("{order}"); // erro: order foi movido para a função
}

Quando você chama print_order(order), a propriedade de order é transferida para o parâmetro da função. Quando a função termina, o valor é dropado dentro dela — main não tem mais o direito de usar order. Essa é a razão pela qual borrowing (próxima seção) é quase sempre necessário quando você quer apenas “pedir emprestado” para ler dados sem transferir propriedade.

5. Borrowing: emprestar sem tomar propriedade

5.1. Referência imutável (&T)

fn print_order(order: &String) {
    println!("Order: {order}");
}

fn main() {
    let order = String::from("ORDER-001");
    print_order(&order); // empresta, não move
    println!("Ainda pode usar: {order}");
}

O símbolo & cria uma referência — empresta dados sem tomar a propriedade. A função print_order apenas lê, não possui order. Depois que a função termina, main ainda tem total direito de usar a variável original.

5.2. Referência mutável (&mut T)

fn add_discount(total: &mut f64) {
    *total -= 10.0;
}

fn main() {
    let mut total = 100.0;
    add_discount(&mut total);
    println!("Total: {total}"); // Total: 90
}

&mut permite emprestar para modificar. Para conseguir chamar, a variável original deve ser declarada como mut. O símbolo * é usado para dereference e atribuir um novo valor à região de memória que a referência aponta.

5.3. Regras de borrowing

Rust aplica duas leis de borrowing em tempo de compilação, coletivamente chamadas de borrow checker:

  • Em um dado momento, pode existir uma referência mutável, OU múltiplas referências imutáveis — não misturadas.
  • Uma referência nunca pode viver mais que o dado original (evitar dangling reference).
fn main() {
    let mut total = 100.0;

    let r1 = &total;
    let r2 = &total; // OK: múltiplas referências imutáveis ao mesmo tempo
    println!("{r1} {r2}");

    let r3 = &mut total; // erro se r1, r2 forem usados depois deste ponto
    println!("{r3}");
}

Se r1, r2 não forem usados depois da primeira linha println!, o compilador (graças ao Non-Lexical Lifetimes) permite que r3 borrow como mutável abaixo. Mas se você tentar usar r1 depois que r3 aparece, receberá:

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

Essa regra elimina data race completamente em tempo de compilação: é impossível ter alguém lendo e alguém escrevendo ao mesmo tempo na mesma região de memória.

6. Lifetime: garantir que referências não apontam para “nada”

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let order1 = String::from("ORDER-001");
    let order2 = String::from("ORDER-002-LONG");

    let result = longest(order1.as_str(), order2.as_str());
    println!("Mais longo: {result}");
}

A notação 'a é uma lifetime annotation, dizendo ao compilador: “a referência retornada vive não mais que x e y”. Rust não consegue deduzir a relação de vida entre múltiplas referências neste caso, então você deve declarar explicitamente. Na maioria das vezes Rust deduz lifetime automaticamente (lifetime elision) então você raramente tem que escrever 'a manualmente — apenas quando a função recebe múltiplas referências e retorna uma delas.

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |                                 ^ expected named lifetime parameter

Se faltar 'a, o compilador reporta o erro acima porque não sabe se a referência retornada está associada com x ou y.

7. Quando precisa de múltiplos owners: Rc e RefCell

Ownership de um único proprietário às vezes é muito rígido, por exemplo ao construir estruturas de dados tipo árvore ou grafo onde múltiplos nós precisam referenciar um mesmo valor.

<!-- Não precisa de dependency, Rc/RefCell estão no std -->
use std::cell::RefCell;
use std::rc::Rc;

struct Customer {
    name: String,
    balance: RefCell<f64>,
}

fn main() {
    let customer = Rc::new(Customer {
        name: String::from("Nón Lá"),
        balance: RefCell::new(0.0),
    });

    let c1 = Rc::clone(&customer);
    let c2 = Rc::clone(&customer);

    *c1.balance.borrow_mut() += 50.0;
    *c2.balance.borrow_mut() += 30.0;

    println!("{} balance = {}", customer.name, customer.balance.borrow());
    println!("Número de referências: {}", Rc::strong_count(&customer));
}

Rc<T> (Reference Counted) permite múltiplos owners apontarem para o mesmo dado imutável, conta referências em runtime, faz drop automaticamente quando a contagem chega a 0. RefCell<T> permite mutable borrow em tempo de execução em vez de compile-time (interior mutability) — em troca, se você violar as regras de borrowing (por exemplo, dois borrow_mut() ao mesmo tempo), o programa vai panic em vez de reportar erro de compilação.

Nón Lá balance = 80
Número de referências: 3

Nota: Rc/RefCell deve ser usado apenas para single-thread. Se precisa compartilhar dados entre múltiplas threads, use Arc (atomic reference count) combinado com Mutex ou RwLock. Não abuse de Rc<RefCell<T>> — isso sacrifica a vantagem de “borrow checker pegando erros em tempo de compilação” que é o ponto forte do Rust.

8. Escrever testes para verificar o comportamento de ownership

#[derive(Clone)]
struct Order {
    id: String,
    amount: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.amount -= order.amount * percent / 100.0;
}

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

    #[test]
    fn should_keep_original_order_untouched_when_cloned() {
        let original = Order { id: "ORD-1".into(), amount: 100.0 };
        let mut copy = original.clone();

        apply_discount(&mut copy, 10.0);

        assert_eq!(original.amount, 100.0); // original não muda
        assert_eq!(copy.amount, 90.0);
    }

    #[test]
    fn should_mutate_through_mutable_borrow() {
        let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
        apply_discount(&mut order, 25.0);

        assert_eq!(order.amount, 150.0);
    }
}

Rode cargo test, resultado:

running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

O primeiro teste demonstra que .clone() cria uma cópia independente — modificar copy não afeta original. O segundo teste confirma que &mut realmente modifica os dados originais através da referência.

9. Erros comuns

  • Use after move: tentar usar uma variável depois que ela foi movida para outro lugar (função, variável nova, struct). Corrija com .clone() se precisar de duas cópias, ou mude para passar uma referência (&T).
  • Cannot borrow as mutable more than once: mantém um &mut mas tenta criar outro & ou &mut ao mesmo tempo. Reduza o escopo da referência antiga (use um bloco {} ou deixe-a “morrer” mais cedo) para resolver.
  • Dangling reference: retorna uma referência apontando para uma variável local que foi dropada quando a função terminou. Rust bloqueia completamente este erro em tempo de compilação — você verá o erro missing lifetime specifier ou borrowed value does not live long enough.
  • Abusar de .clone() para evitar o borrow checker: é válido em termos de compilação mas desperdiça performance desnecessariamente. Veja cada .clone() como uma pergunta: “preciso realmente de uma cópia independente, ou estou só evitando entender ownership?”

10. Quando ownership rígido não é necessário

Se você escreve scripts curtos, prototipos rápidos, ou dados sempre pequenos sem requisitos altos de performance/segurança, o custo de aprender e seguir o borrow checker pode não valer — linguagens como Python ou Go te darão velocidade de desenvolvimento mais rápida. Ownership brilha mais em sistemas que precisam de performance alta e segurança de memória rigorosa: sistemas operacionais, embedded, game engines, ou serviços backend com requisitos de baixa latência que não toleram GC pauses.

11. Conclusão

Ownership é a base que permite que Rust alcance algo aparentemente paradoxal: segurança de memória como linguagens com GC, mas performance comparável a C/C++. As três regras owner-move-drop, o mecanismo de borrowing com a regra um-mutável-ou-múltiplos-imutáveis, e lifetime annotations trabalham juntos para o compilador pegar erros memory-unsafe na compilação em vez de deixar crashes em tempo de execução. No início o borrow checker pode fazer você “discutir” bastante com o compilador, mas depois que aprende, você escreve código com muito menos bugs sem nenhum runtime de coleta de lixo guardando as costas.

rustownershipmemory-safetysystems-programmingborrow-checker