Ownership — A Ideia que Muda Tudo

Ownership — A Ideia que Muda Tudo

O artigo central da série: as três regras que decidem quem é dono de cada valor e quando ele é liberado. Aqui aparecem o move que invalida a variável anterior, o clone que duplica de propósito, os tipos que copiam sozinhos, e a razão de tudo isso existir sem coletor de lixo.
Rust

12 min de leitura

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

Comentários

Mais em Rust

Parsing e Processamento de Texto — nom, pest e Expressões Regulares
Parsing e Processamento de Texto — nom, pest e Expressões Regulares

Split resolve formato com delimitador, regex resolve padrão que varia, e…

O Ecossistema Rust — Cargo, crates.io e as Bibliotecas Essenciais
O Ecossistema Rust — Cargo, crates.io e as Bibliotecas Essenciais

Cargo é bem mais que gerenciador de pacotes, e o ecossistema em volta explica…

Funções, Expressões e Como Rust Pensa Diferente sobre Retorno de Valores
Funções, Expressões e Como Rust Pensa Diferente sobre Retorno de Valores

Rust distingue o que executa do que produz valor, e essa distinção decide como…