Smart pointers em Rust: Box, Rc e Arc

1. Visão geral

Por padrão, cada valor em Rust tem apenas um proprietário, e Rust precisa conhecer o tamanho do valor no momento da compilação. Muitas situações práticas não satisfazem essas duas condições: tipos de dados recursivos, listas contendo múltiplos tipos diferentes, ou dados que precisam de múltiplos proprietários. Este artigo apresenta os três smart pointers mais comuns: Box<T>, Rc<T> e Arc<T>. Ao final, você saberá qual tipo escolher para cada situação, como combiná-los com Weak, RefCell, Mutex, e quais erros são mais comuns.

2. O que é um smart pointer?

Um smart pointer é uma struct que encapsula um ponteiro e implementa dois traits importantes:

  • Deref: permite usar o smart pointer como uma referência comum (*ptr, chamar métodos diretamente).
  • Drop: libera automaticamente o recurso quando o smart pointer sai do escopo.

String e Vec<T> também são smart pointers. Box, Rc e Arc diferem por focarem em como possuem dados na heap. Os três estão na biblioteca padrão. Você não precisa adicionar nenhuma dependência ao Cargo.toml.

3. Box<T>: um proprietário, dados na heap

3.1. Alocação básica

fn main() {
    let order_total = Box::new(250_000u64);
    println!("Total: {}", order_total);

    let doubled = *order_total * 2;
    println!("Dobrado: {}", doubled);
}

Box::new aloca o valor 250_000 na heap. Na stack, apenas o ponteiro permanece, com tamanho fixo. println! funciona diretamente porque Box implementa Display quando T implementa Display. O operador * extrai o valor interno graças à Deref. Quando order_total sai de main, a memória na heap é liberada automaticamente.

Total: 250000
Dobrado: 500000

Embrulhar um u64 em Box como no exemplo acima não traz nenhum benefício. Este código apenas ilustra a sintaxe. Box é realmente útil nos dois casos abaixo.

3.2. Tipos de dados recursivos

Suponha que você descreva um processo de pedido como uma sequência de etapas:

#[derive(Debug)]
enum OrderStep {
    Done,
    Next(String, Box<OrderStep>),
}

use OrderStep::{Done, Next};

fn count(step: &OrderStep) -> usize {
    match step {
        Done => 0,
        Next(_, rest) => 1 + count(rest),
    }
}

fn main() {
    let flow = Next(
        "Fazer pedido".into(),
        Box::new(Next(
            "Pagamento".into(),
            Box::new(Next("Entrega".into(), Box::new(Done))),
        )),
    );
    println!("Número de etapas: {}", count(&flow));
}

Se você escrever Next(String, OrderStep) sem Box, o compilador vai relatar um erro:

error[E0072]: recursive type `OrderStep` has infinite size

O motivo é que um OrderStep contém outro OrderStep, que contém outro, e assim por diante, resultando em um tamanho infinito. Box<OrderStep> sempre tem o tamanho de um ponteiro, então o tamanho do enum se torna finito. Na função count, rest tem tipo &Box<OrderStep>. Rust a converte automaticamente em &OrderStep graças à deref coercion.

Número de etapas: 3

3.3. Trait object

Box<dyn Trait> permite armazenar múltiplos tipos diferentes na mesma coleção:

trait PaymentMethod {
    fn pay(&self, amount: u64) -> String;
}

struct Cash;
struct Card {
    last4: String,
}

impl PaymentMethod for Cash {
    fn pay(&self, amount: u64) -> String {
        format!("Dinheiro: {amount}")
    }
}

impl PaymentMethod for Card {
    fn pay(&self, amount: u64) -> String {
        format!("Cartão ****{}: {amount}", self.last4)
    }
}

fn main() {
    let methods: Vec<Box<dyn PaymentMethod>> = vec![
        Box::new(Cash),
        Box::new(Card { last4: "4242".into() }),
    ];
    for m in &methods {
        println!("{}", m.pay(100_000));
    }
}

Cash e Card têm tamanhos diferentes, então não podem ser colocados diretamente no mesmo Vec. Ao embrulhá-los em Box<dyn PaymentMethod>, todos os elementos têm o mesmo tamanho (um fat pointer: ponteiro de dados mais ponteiro de vtable). A chamada para pay é resolvida em tempo de execução.

Dinheiro: 100000
Cartão ****4242: 100000

Nota: Se todos os elementos tiverem o mesmo tipo, use generics (impl PaymentMethod ou <T: PaymentMethod>) em vez de Box<dyn …>. Generics usam static dispatch, sem alocação de heap e sem chamadas através de vtable.

4. Rc<T>: múltiplos proprietários em uma thread

4.1. Contagem de referências

Suponha que múltiplos Order pertencem ao mesmo Customer. Você não quer copiar as informações do cliente em cada pedido, e também não consegue determinar qual pedido “viverá” mais tempo para ser o proprietário. Este é o momento de usar Rc (Reference Counted):

use std::rc::Rc;

struct Customer {
    name: String,
}

struct Order {
    id: u32,
    customer: Rc<Customer>,
}

fn main() {
    let alice = Rc::new(Customer { name: "Alice".into() });
    println!("Após criar: {}", Rc::strong_count(&alice));

    let o1 = Order { id: 1, customer: Rc::clone(&alice) };
    {
        let o2 = Order { id: 2, customer: Rc::clone(&alice) };
        println!("Dentro do bloco: {}", Rc::strong_count(&alice));
        println!("Pedido {} de {}", o2.id, o2.customer.name);
    }
    println!("Após o bloco: {}", Rc::strong_count(&alice));
    println!("Pedido {} de {}", o1.id, o1.customer.name);
}

Rc::clone não copia Customer. Apenas incrementa o strong count em 1. Quando o2 é descartado no final do bloco, a contagem diminui em 1. Customer é liberado apenas quando a contagem chega a 0.

Após criar: 1
Dentro do bloco: 3
Pedido 2 de Alice
Após o bloco: 2
Pedido 1 de Alice

Por convenção, escreva Rc::clone(&alice) em vez de alice.clone(). Dessa forma, o leitor vê que esta é uma operação de incremento de contagem barata, não uma cópia profunda.

4.2. Modificar dados com Rc<RefCell<T>>

Rc<T> apenas oferece referências imutáveis (&T). Se múltiplos proprietários precisam modificar dados, combine com RefCell:

use std::cell::RefCell;
use std::rc::Rc;

struct Cart {
    items: Vec<String>,
}

fn main() {
    let cart = Rc::new(RefCell::new(Cart { items: vec![] }));

    let web = Rc::clone(&cart);
    let mobile = Rc::clone(&cart);

    web.borrow_mut().items.push("Camiseta".into());
    mobile.borrow_mut().items.push("Nón lá".into());

    println!("{:?}", cart.borrow().items);
}

RefCell move a verificação das regras de borrow da compilação para tempo de execução. borrow_mut() retorna um guard. O guard é liberado no final da instrução, então a próxima chamada ainda é válida.

["Camiseta", "Nón lá"]

Nota: Se você chamar borrow_mut() enquanto outro borrow() ou borrow_mut() ainda estiver ativo, o programa vai entrar em panic com already borrowed: BorrowMutError. Mantenha o guard pelo tempo mais curto possível. Use try_borrow_mut() se quiser tratar o erro em vez de fazer panic.

4.3. Ciclos de referência e Weak<T>

Se Customer mantém uma lista de Rc<Order>, e cada Order mantém um Rc<Customer>, os dois contadores nunca chegarão a 0. O resultado é vazamento de memória. Rust não consegue prevenir isso na compilação. A solução é usar Weak para uma direção da referência:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Customer {
    name: String,
    orders: RefCell<Vec<Rc<Order>>>,
}

struct Order {
    id: u32,
    customer: Weak<Customer>,
}

fn main() {
    let bob = Rc::new(Customer {
        name: "Bob".into(),
        orders: RefCell::new(vec![]),
    });
    let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
    bob.orders.borrow_mut().push(Rc::clone(&order));

    if let Some(c) = order.customer.upgrade() {
        println!("Pedido {} pertence a {}", order.id, c.name);
    }
    println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));

    drop(bob);
    println!("Cliente ainda existe? {}", order.customer.upgrade().is_some());
}

Rc::downgrade cria um Weak. Weak apenas incrementa o weak count e não mantém os dados vivos. Quando precisa usar, você chama upgrade() para obter um Option<Rc<T>>. O resultado é None se os dados já foram liberados. Após drop(bob), o strong count de Customer chega a 0, então Customer é liberado, mesmo com o Weak ainda presente em order.

Pedido 7 pertence a Bob
strong=1, weak=1
Cliente ainda existe? false

Regra geral: a direção “pai → filho” usa Rc, a direção “filho → pai” usa Weak.

5. Arc<T>: múltiplos proprietários entre múltiplas threads

5.1. Por que Rc não funciona com threads

Rc atualiza o contador com adição comum, não é seguro quando múltiplas threads acessam. Portanto, Rc não implementa o trait Send. Se você passar Rc para thread::spawn, o compilador recusará:

error[E0277]: `Rc<Customer>` cannot be sent between threads safely

Este é um ponto forte do Rust: erros de data race são bloqueados no momento da compilação. Arc (Atomically Reference Counted) tem uma API idêntica a Rc, mas usa operações atômicas para atualizar o contador.

5.2. Compartilhar dados somente leitura

use std::sync::Arc;
use std::thread;

struct ShopConfig {
    currency: String,
    vat_percent: u64,
}

fn main() {
    let config = Arc::new(ShopConfig { currency: "VND".into(), vat_percent: 10 });

    let handles: Vec<_> = [100_000u64, 200_000]
        .into_iter()
        .map(|price| {
            let config = Arc::clone(&config);
            thread::spawn(move || {
                let vat = price * config.vat_percent / 100;
                println!("{} + IVA = {} {}", price, price + vat, config.currency);
            })
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
}

Cada thread recebe sua própria cópia de Arc através de move. Todas apontam para o mesmo ShopConfig. Os dados são apenas lidos, então não há necessidade de locks. A ordem das duas linhas de saída pode variar entre execuções.

100000 + IVA = 110000 VND
200000 + IVA = 220000 VND

5.3. Modificar dados com Arc<Mutex<T>>

RefCell não é seguro entre threads. A versão correspondente para multi-thread é Mutex (ou RwLock):

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let revenue = Arc::new(Mutex::new(0u64));
    let mut handles = Vec::new();

    for amount in [100u64, 250, 400] {
        let revenue = Arc::clone(&revenue);
        handles.push(thread::spawn(move || {
            *revenue.lock().unwrap() += amount;
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("Receita: {}", *revenue.lock().unwrap());
}

lock() retorna um MutexGuard. O lock é automaticamente liberado quando o guard é descartado, aqui no final da instrução. unwrap() trata o caso em que o mutex foi envenenado, ou seja, outra thread sofreu panic enquanto mantinha o lock.

Receita: 750

Nota: Para um simples contador de inteiros, Arc<AtomicU64> é mais rápido que Arc<Mutex<u64>>. Além disso, não use Arc onde há apenas uma thread. Operações atômicas custam mais que adição comum, embora a diferença não seja grande.

6. Escrever testes para comportamento de contagem de referências

Rust tem um framework de testes integrado, sem necessidade de adicionar crates. Coloque os testes abaixo no final de src/lib.rs ou src/main.rs:

#[cfg(test)]
mod tests {
    use std::rc::{Rc, Weak};
    use std::sync::{Arc, Mutex};
    use std::thread;

    struct Customer {
        name: String,
    }

    #[test]
    fn rc_clone_increases_strong_count_and_drop_decreases_it() {
        let alice = Rc::new(Customer { name: "Alice".into() });
        let copy = Rc::clone(&alice);
        assert_eq!(Rc::strong_count(&alice), 2);
        assert!(Rc::ptr_eq(&alice, &copy));

        drop(copy);
        assert_eq!(Rc::strong_count(&alice), 1);
        assert_eq!(alice.name, "Alice");
    }

    #[test]
    fn weak_upgrade_returns_none_after_owner_dropped() {
        let bob = Rc::new(Customer { name: "Bob".into() });
        let weak: Weak<Customer> = Rc::downgrade(&bob);
        assert!(weak.upgrade().is_some());

        drop(bob);
        assert!(weak.upgrade().is_none());
    }

    #[test]
    fn arc_mutex_sums_correctly_across_threads() {
        let revenue = Arc::new(Mutex::new(0u64));
        let handles: Vec<_> = (1..=100)
            .map(|i| {
                let revenue = Arc::clone(&revenue);
                thread::spawn(move || *revenue.lock().unwrap() += i)
            })
            .collect();
        handles.into_iter().for_each(|h| h.join().unwrap());

        assert_eq!(*revenue.lock().unwrap(), 5050);
        assert_eq!(Arc::strong_count(&revenue), 1);
    }
}

O primeiro teste verifica o contador e usa Rc::ptr_eq para confirmar que dois Rc apontam para a mesma região de memória, ou seja, sem cópia profunda. O segundo teste confirma que Weak não mantém os dados vivos. O terceiro executa 100 threads somando valores, o resultado deve ser exatamente 5050. Após todos os threads fazerem join, há apenas uma referência forte.

Execute os testes:

cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok

test result: ok. 3 passed; 0 failed

7. Qual tipo escolher?

Necessidade Escolha
Um proprietário, precisa de heap (tipo recursivo, trait object, dados grandes para move barato) Box<T>
Múltiplos proprietários, uma thread, somente leitura Rc<T>
Múltiplos proprietários, uma thread, precisa modificar Rc<RefCell<T>>
Múltiplos proprietários, múltiplas threads, somente leitura Arc<T>
Múltiplos proprietários, múltiplas threads, precisa modificar Arc<Mutex<T>> / Arc<RwLock<T>>
Referência reversa, cache, evitar ciclos Weak<T>

Alguns erros comuns:

  • Usar smart pointer quando apenas borrow é necessário. Se a função apenas lê dados, receba &Customer em vez de Rc<Customer>. Assim a função é mais flexível e não precisa incrementar nem decrementar o contador.
  • Abuso de Rc<RefCell<T>>. Código repleto deste padrão geralmente indica que você está trazendo mentalidade OOP de Java ou C# para Rust. Considere reorganizar o ownership, ou use índices em Vec (arena pattern).
  • Ciclos de referência. Tanto Rc quanto Arc podem vazar memória quando dois objetos apontam um para o outro usando strong references. Use Weak para uma direção.
  • Manter MutexGuard por muito tempo. Particularmente perigoso ao manter um guard através de .await em código async, ou ao fazer lock de múltiplos mutex em ordem diferente em threads diferentes, levando a deadlock.

8. Conclusão

Box<T> é usado quando você precisa colocar dados na heap com um único proprietário, tipicamente para tipos recursivos e trait objects. Rc<T> permite múltiplos proprietários em uma thread, enquanto Arc<T> faz o mesmo entre múltiplas threads graças ao contador atômico. Os próprios smart pointers apenas permitem leitura. Quando precisa modificar dados, você combina com RefCell (uma thread) ou Mutex/RwLock (múltiplas threads), e usa Weak para evitar ciclos de referência. Comece com borrow comum, e mude para smart pointers apenas quando as regras de ownership padrão realmente não forem suficientes.

rustsmart-pointerownershipconcurrencymemory