🤯 Como fiquei confuso — e aprendi a gostar do modelo de ownership

Quando comecei a estudar Rust, eu já era familiarizado com os dois extremos do espectro de gerenciamento de memória.

De um lado, o modelo de alocação e liberação manual do C, onde cada malloc exige um free correspondente.

Do outro, linguagens com garbage collection, como Java ou TypeScript, nas quais o runtime gerencia a memória sem precisar da minha intervenção direta.

Rust, porém, não se encaixava em nenhum desses dois mundos. Seu sistema de ownership e borrowing parecia um terceiro modelo — algo que, ao mesmo tempo, era automático e completamente determinístico.

Essas diferenças despertaram algumas perguntas:

  • O drop() de Rust seria uma forma de ARC?
  • O ARC seria apenas um GC simplificado?
  • E se o modelo de Rust é tão eficiente, por que nem todas as linguagens modernas o adotam?

Tal curiosidade me levou a explorar mais profundamente o tema, incluindo a leitura do artigo A Unified Theory of Garbage Collection, de David F. Bacon, Perry Cheng e V.T. Rajan — que argumenta que ARC e GC são, na verdade, duas formas de garbage collection, unificadas sob a mesma teoria.

Este post é um resumo dessa jornada: uma tentativa de entender as três principais abordagens de gerenciamento automático de memória.


🧮 Reference Counting (ARC)

O ARC (Automatic Reference Counting) é utilizado em Objective-C, Swift e também aparece em linguagens como Rust (com Rc e Arc) e C++ (com shared_ptr). Cada objeto tem um contador de referências associado, que é incrementado quando novas referências são criadas e decrementado quando essas referências saem de escopo.

Nos bastidores, isso é feito automaticamente por chamadas de retain e release, inseridas pelo compilador em pontos estratégicos do código. Essas operações aumentam ou diminuem o contador de referências sem que o desenvolvedor precise chamá-las manualmente.

Com o programa em execução, quando o contador chega a zero, o objeto é desalocado imediatamente.

Características

  • Determinístico: a memória é liberada exatamente no momento em que não há mais referências.
  • Sem pausas globais: não há varredura periódica da heap.
  • Latência previsível: ideal para aplicativos de interface ou tempo real.

Limitação — referências cíclicas

O ARC não tem uma visão global da heap. Se dois objetos se referenciam mutuamente, seus contadores nunca chegam a zero — eles “mantêm um ao outro vivos”:

1class Node {
2    var next: Node?
3}
4
5let a = Node()
6let b = Node()
7a.next = b
8b.next = a // ciclo de referência

Para evitar esse tipo de retenção circular, o ARC oferece referências fracas (weak) e não proprietárias (unowned), que não incrementam o contador. Essas estratégias reduzem os ciclos de referência, mas não os eliminam completamente — o programador ainda precisa projetar cuidadosamente as relações entre objetos e capturas em closures para evitar vazamentos ou ponteiros inválidos.


♻️ Tracing Garbage Collector (Java, C#, Go)

O tracing garbage collector usado em Java, C# e Go funciona de maneira diferente. Ele não conta referências. Em vez disso, periodicamente varre todos os objetos alcançáveis, partindo de um conjunto de raízes (variáveis locais, globais e registradores). Tudo o que for inalcançável é considerado lixo e liberado em lote.

Características

  • Totalmente automático: sem retain ou release.
  • Imune a ciclos: detecta objetos inalcançáveis, mesmo com referências circulares.
  • Bom desempenho agregado: ideal para ambientes com muitas alocações de curta duração.

Desvantagens

  • Não determinístico: não é possível prever quando a limpeza ocorrerá.
  • Pausas ocasionais: o coletor precisa suspender as threads para inspecionar a memória.
  • Maior uso de memória: a heap tende a crescer até o próximo ciclo de coleta.

Coletoras modernas, como G1GC, ZGC e o GC concorrente do Go, reduzem essas pausas e melhoram a previsibilidade ao rodar de forma incremental e paralela.


⚗️ Abordagens híbridas (Python, JavaScript, etc.)

Algumas linguagens combinam técnicas de contagem de referências e tracing:

  • Python usa reference counting para a maioria dos objetos, com um GC que roda ocasionalmente para eliminar loops inalcançáveis.
  • JavaScript (V8, SpiderMonkey) e Ruby adotam variantes híbridas ou incrementais do tracing GC, otimizadas para responsividade.
  • Mesmo o Swift complementa o ARC com heurísticas internas para liberar objetos ociosos mais cedo quando possível.

Esses modelos híbridos buscam equilibrar determinismo (como o ARC) e simplicidade (como o GC), aceitando pequenos compromissos de ambos os lados.


🦀 Ownership e Borrow Checker (Rust)

O Rust segue um caminho completamente diferente. Ele garante segurança de memória em tempo de compilação, sem contadores nem threads de coleta.

Cada valor tem exatamente um dono, e o compilador insere chamadas de drop() quando esse dono sai de escopo:

1fn main() {
2    let s = String::from("hello");
3    println!("{}", s);
4} // drop(s) é inserido automaticamente aqui

Tudo isso é feito por meio do sistema de ownership e das regras de empréstimo (borrowing), verificadas pelo compilador — não pelo runtime.

Características

  • Zero custo em tempo de execução: não há contadores nem varredura da heap.
  • Determinístico: a liberação ocorre no fim do escopo.
  • Seguro por construção: evita leaks, use-after-free e double free.
  • Desempenho previsível: ideal para jogos, sistemas embarcados e servidores de alta performance.

Como resolver quando é necessário compartilhar a posse de um valor

O modelo padrão de Rust permite apenas um dono por valor. Quando é preciso que que várias partes do código compartilhem a posse do mesmo valor, Rust fornece tipos explícitos com contagem de referências, de uso optativo:

1use std::rc::Rc;
2let a = Rc::new(String::from("hello"));
3let b = Rc::clone(&a); // contador +1

Esse tipo de compartilhamento de ownership é sempre explícito — o custo de contagem de referências só existe quando você opta por ele.


⚖️ Comparando os três modelos

Aspecto ARC (Reference Counting) GC (Tracing) Rust (Ownership)
Liberação de memória Imediata (contador = 0) Periódica, em lotes No fim do escopo
Determinismo ✅ Sim ❌ Não ✅ Sim
Custo em runtime Constante e previsível Variável e amortizado Zero
Pausas globais Nenhuma Possíveis Nenhuma
Referências cíclicas Vazamento se não houver weak Detectados automaticamente Impossíveis por design
Ideal para Apps móveis, UI, tempo real Servidores, linguagens dinâmicas Sistemas, código de baixo nível
Exemplos Swift, Obj-C, Rust (Rc/Arc) Java, C#, Go Rust (modelo padrão)

Por que nem todas as linguagens usam o modelo de Rust

À primeira vista, o modelo de Rust parece ter apenas vantagens — sem GC, sem pausas, sem vazamentos. Mas ele traz custos reais, principalmente na experiência do desenvolvedor e na flexibilidade do ecossistema:

  • Curva de aprendizado íngreme: o sistema de ownership e borrowing é rígido, e erros do borrow checker são comuns no início.
  • Menor flexibilidade: estruturas complexas (como grafos ou caches compartilhados) exigem soluções explícitas com Rc, Arc ou mutabilidade interna.
  • Dificuldade em linguagens dinâmicas: linguagens com reflexão, tipagem dinâmica ou carregamento de código em tempo de execução (como Python ou JavaScript) não podem aplicar as garantias estáticas de Rust.
  • Custo de compilação: o compilador realiza análises pesadas para provar segurança de memória, o que aumenta o tempo de build.

O modelo de Rust troca conveniência por controle. É perfeito para código de baixo nível e sistemas críticos, mas não para todos os contextos.


💡 Conclusão: uma visão unificada

Como apontam Bacon et al., o ARC e o tracing GC são duas formas de garbage collection dentro do mesmo arcabouço teórico:

“Reference counting is a form of garbage collection which maintains liveness information incrementally.”

Em resumo, o artigo descreve o ARC como uma abordagem local e incremental, e o tracing GC como uma abordagem global e periódica. A esse quadro, podemos adicionar o modelo de ownership do Rust como uma terceira abordagem estática, que desloca a segurança de memória completamente para o tempo de compilação.

Os três modelos buscam o mesmo objetivo: reaproveitar automaticamente a memória não utilizada, equilibrando de formas diferentes controle, simplicidade e custo em tempo de execução. Se incluirmos também o modelo manual, temos:

Manual (C) → Ownership (Rust) → Reference Counting (Swift) → Tracing GC (Java)
  • Manual (C) oferece controle total, ao custo da segurança.
  • ARC privilegia previsibilidade.
  • Tracing GC privilegia simplicidade e produtividade.
  • Rust privilegia segurança e custo zero em runtime — ao preço de regras mais rígidas e compilações mais pesadas.

Cada linguagem adota o modelo que mais reflete sua filosofia.

O essencial é compreender os trade-offs por trás de cada abordagem e reconhecer que Rust, Swift e Java representam três respostas distintas (e elegantes) para a mesma pergunta fundamental: como liberar memória de forma segura?