Chegamos ao artigo mais importante da série. Tudo que aprendemos até aqui — variáveis, tipos, funções, controle de fluxo — existe em outras linguagens com pequenas variações. Mas ownership é exclusivo de Rust. É o conceito que explica por que Rust é seguro sem coletor de lixo, e é o conceito que mais desafia quem vem de outras linguagens.
Reserve tempo para este artigo. Leia com calma. Volte quantas vezes precisar.
Por que gerenciamento de memória é difícil
Para entender ownership, precisamos primeiro entender o problema que ele resolve.
Todo programa usa memória. Existem basicamente duas regiões que nos interessam:
A stack é rápida, organizada, e funciona como uma pilha de pratos: o último a entrar é o primeiro a sair. Variáveis de tamanho conhecido em tempo de compilação vivem aqui — inteiros, booleanos, floats, tuplas de tamanho fixo. Quando uma função termina, toda a sua stack é liberada automaticamente.
O heap é uma região de memória maior e mais flexível, usada para dados cujo tamanho não se conhece em tempo de compilação — ou que precisam sobreviver além do escopo onde foram criados. Mas o heap tem um custo: você precisa gerenciá-lo manualmente ou ter um sistema que faça isso por você.
Em C, você gerencia manualmente com malloc e free. Esqueça o free e você tem um memory leak. Libere duas vezes e você tem um double free, que corrompe o programa. Use a memória depois de liberar e você tem um use-after-free — porta de entrada para ataques de segurança.
Em Java, Python e Go, um coletor de lixo monitora quais objetos ainda são referenciados e libera os que não são mais usados. Seguro — mas com custo: pausas imprevisíveis, overhead de CPU e memória.
Rust escolheu um terceiro caminho: ownership.
As três regras de Ownership
Ownership em Rust se resume a três regras. O compilador as verifica em tempo de compilação — sem custo em execução:
Regra 1: Cada valor em Rust tem exatamente um dono.
Regra 2: Só pode haver um dono por vez.
Regra 3: Quando o dono sai de escopo, o valor é destruído.
Simples de enunciar. Profundas nas consequências. Vamos explorar cada uma.
Escopo e destruição automática
Comece com o mais simples — a regra 3:
fn main() {
{
let s = String::from("olá"); // s entra em escopo
println!("{s}");
} // s sai de escopo aqui — memória liberada automaticamente
// println!("{s}"); // ERRO: s não existe mais
}
Quando s sai do bloco, Rust chama automaticamente uma função especial chamada drop — que libera a memória do heap. Isso acontece deterministicamente, sempre no mesmo ponto, sem coletor de lixo.
Note que usamos String aqui, não &str. A diferença é importante:
&stré uma referência a uma string de tamanho fixo, geralmente armazenada no binário do programa. Vive na stack.Stringé uma string dinâmica, alocada no heap, que pode crescer e mudar.
Move — a regra 2 em ação
Aqui está onde a maioria das pessoas tropeça pela primeira vez:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 é movido para s2
println!("{s1}"); // ERRO: s1 foi movido!
}
Quando você escreve let s2 = s1, em outras linguagens esperaria uma cópia ou dois ponteiros para o mesmo dado. Em Rust, ocorre um move: a propriedade do valor é transferida de s1 para s2. Após isso, s1 não existe mais — o compilador o invalida.
Por quê? Porque se dois nomes apontassem para o mesmo dado no heap, quando ambos saíssem de escopo, o drop seria chamado duas vezes — o famoso double free. Rust simplesmente não permite que isso aconteça.
Visualmente, o que ocorre:
Antes do move:
s1 --> [ ptr | len=5 | cap=5 ] --> heap: "hello"
Após let s2 = s1:
s1 --> (inválido)
s2 --> [ ptr | len=5 | cap=5 ] --> heap: "hello"
Clone — quando você realmente quer uma cópia
Se você precisa de uma cópia independente do dado no heap, use .clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // cópia profunda do heap
println!("s1 = {s1}"); // funciona!
println!("s2 = {s2}"); // funciona!
}
O clone cria uma segunda cópia completa dos dados no heap. Agora s1 e s2 são independentes, cada um com seu próprio dono. Ambos serão destruídos quando saírem de escopo.
O .clone() é explícito por design — em Rust, operações custosas nunca acontecem nas suas costas. Se você está clonando, está dizendo conscientemente: "Sei que isso tem um custo, e quero fazê-lo assim mesmo."
Tipos que copiam automaticamente
Mas espera — no artigo Variáveis, Tipos e a Arte da Imutabilidade fizemos isso sem problemas:
fn main() {
let x = 5;
let y = x;
println!("{x} {y}"); // funciona!
}
Por que x não foi movido? Porque i32 é um tipo que vive inteiramente na stack, de tamanho fixo e conhecido. Para esses tipos, copiar é tão barato quanto mover — então Rust simplesmente copia. Não há risco de double free porque não há heap envolvido.
Tipos com essa propriedade implementam o trait Copy. São eles: todos os inteiros, floats, bool, char, e tuplas compostas apenas de tipos Copy. String não implementa Copy — ela tem dados no heap.
Ownership e funções
As mesmas regras se aplicam quando você passa valores para funções:
fn consumir(s: String) {
println!("{s}");
} // s é destruído aqui
fn main() {
let minha_string = String::from("mundo");
consumir(minha_string); // ownership transferido para a função
// println!("{minha_string}"); // ERRO: foi movido!
}
Passar uma String para uma função é um move. A função se torna a nova dona. Quando a função termina, a string é destruída.
E quando a função retorna um valor, o ownership é transferido de volta:
fn criar_string() -> String {
let s = String::from("novo valor");
s // ownership transferido para quem chamou
}
fn main() {
let minha = criar_string();
println!("{minha}");
} // minha é destruída aqui
O problema que isso cria
Imagine que você quer usar uma string em uma função mas ainda precisa dela depois:
fn tamanho(s: String) -> usize {
s.len()
}
fn main() {
let s = String::from("hello");
let tam = tamanho(s);
// println!("{s}"); // ERRO! s foi movido para tamanho()
println!("Tamanho: {tam}");
}
Uma solução seria retornar a string de volta junto com o resultado:
fn tamanho(s: String) -> (String, usize) {
let len = s.len();
(s, len) // devolve a string junto com o resultado
}
fn main() {
let s = String::from("hello");
let (s, tam) = tamanho(s);
println!("{s} tem {tam} caracteres");
}
Funciona — mas é verboso e inconveniente. Ter que devolver toda variável que você usa em uma função seria insuportável em programas reais.
É exatamente para resolver isso que Rust introduz o conceito de borrowing — referências que permitem usar um valor sem tomar posse dele.
Um vislumbre do borrowing
O borrowing usa o símbolo & para criar uma referência:
fn tamanho(s: &String) -> usize {
s.len()
}
fn main() {
let s = String::from("hello");
let tam = tamanho(&s); // emprestamos s, não movemos
println!("{s} tem {tam} caracteres"); // s ainda é válida!
}
Com &s, estamos emprestando a string para a função — ela pode usá-la, mas não é a dona. Quando a função termina, ela devolve o empréstimo automaticamente. s continua válida no main.
Essa é a essência do borrowing, tratada a fundo em Borrowing e Referências — Usando sem Possuir.
Resumo visual das regras
// MOVE: ownership transferido
let s1 = String::from("a");
let s2 = s1; // s1 inválido
// CLONE: cópia independente
let s1 = String::from("a");
let s2 = s1.clone(); // s1 e s2 válidos
// COPY: tipos simples copiam automaticamente
let x: i32 = 5;
let y = x; // x e y válidos
// BORROW (preview): referência sem transferir ownership
let s1 = String::from("a");
let tam = tamanho(&s1); // s1 ainda válido
O compilador como professor
Ownership é a parte de Rust que mais gera erros nos primeiros dias. Mas cada erro do compilador é uma lição — ele te diz exatamente o que aconteceu e frequentemente sugere a correção. Não tente contornar os erros. Tente entendê-los.
Com o tempo, você vai internalizar as regras a ponto de escrevê-las naturalmente. E quando isso acontecer, vai perceber que está escrevendo código que simplesmente não tem certos bugs — não porque você é mais cuidadoso, mas porque o compilador tornou esses bugs impossíveis.
Exercícios
Exercício 1
Enuncie as três regras de ownership e explique o que acontece com a memória quando o dono sai de escopo.
Ver resposta
✓ Resposta: (1) Cada valor tem um dono. (2) Só pode haver um dono por vez. (3) Quando o dono sai de escopo, o valor é descartado. No fim do escopo o compilador insere a chamada de drop, que libera os recursos do valor — para uma String, a memória alocada no heap. Isso ocorre em ponto determinado em tempo de compilação, sem coletor de lixo e sem free manual.
Exercício 2
O código não compila. Explique o erro com precisão e mostre duas correções com efeitos diferentes.
fn main() {
let s1 = String::from("olá");
let s2 = s1;
println!("{s1}");
}
Ver resposta
✓ Resposta: A atribuição let s2 = s1 é um move: a posse do buffer passa para s2 e s1 deixa de ser válida, para que os dois não tentem liberar a mesma memória. O erro é borrow of moved value: s1. Primeira correção, duplicando os dados no heap:
let s2 = s1.clone();
println!("{s1}");
Segunda, emprestando em vez de mover:
let s2 = &s1;
println!("{s1} {s2}");
A diferença é de custo: clone aloca e copia o conteúdo; a referência não copia nada, mas exige que s1 continue viva enquanto s2 existir.
Exercício 3
Por que let x = 5; let y = x; println!("{x}"); compila, enquanto o equivalente com String não?
Ver resposta
✓ Resposta: Porque i32 implementa o trait Copy. Tipos Copy vivem inteiramente na pilha e têm tamanho fixo conhecido, então duplicá-los é copiar alguns bytes — barato e sem dono a disputar. A atribuição faz uma cópia e o original continua válido. A String tem um ponteiro para o heap: copiar os bytes do cabeçalho criaria dois donos do mesmo buffer, e a liberação dupla que isso causaria é exatamente o que o move evita. São Copy os inteiros, os reais, bool, char e as tuplas cujos elementos sejam todos Copy.
Exercício 4
Escreva uma função que receba uma String, conte seus caracteres e devolva a contagem — de modo que quem chamou ainda possa usar a string depois. Mostre a chamada.
Ver resposta
✓ Resposta: A forma idiomática é emprestar em vez de tomar posse:
fn contar(s: &String) -> usize {
s.chars().count()
}
fn main() {
let texto = String::from("olá mundo");
let n = contar(&texto);
println!("{texto} tem {n} caracteres");
}
Se a função tomasse String por valor, o texto seria movido para dentro dela e ficaria inacessível no main — a saída seria devolver a string junto com a contagem, numa tupla, o que é desajeitado. Na prática, o parâmetro melhor é &str, que aceita tanto String quanto literal. Note também chars().count() em vez de len(): len() devolve bytes, e em UTF-8 um caractere acentuado ocupa mais de um.
Exercício 5
Um colega diz que ownership existe para economizar memória. Corrija a afirmação, dizendo qual classe de bugs o sistema de fato elimina.
Ver resposta
✓ Resposta: Ownership não economiza memória — um programa em Rust usa aproximadamente o mesmo que o equivalente em C. O que ele elimina, em tempo de compilação, é uma classe inteira de bugs: uso depois da liberação, liberação dupla, ponteiro pendurado e o esquecimento de liberar. Em C esses erros compilam e falham em execução, muitas vezes longe da causa; em Rust não passam do compilador. O ganho é de correção e previsibilidade, não de consumo — e vem sem o custo de tempo de execução de um coletor de lixo.
Fontes e leituras recomendadas
- The Rust Programming Language, Cap. 4 — Understanding Ownership — https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- Rust by Example — Ownership and moves — https://doc.rust-lang.org/rust-by-example/scope/move.html
- Visualizing Memory Layout of Rust's Data Types — Raph Levien — https://docs.google.com/presentation/d/1q-c7UAyrUlM-eZyTo1pd8SZ0qwA_wYxmPZVOQkoDmH4
- Jon Gjengset — Crust of Rust: Ownership — aula em vídeo aprofundada — https://www.youtube.com/watch?v=8M0QfLUDaaA
- The Rust Book — o que é ownership: https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html