Pular para o conteúdo

Ponteiros inteligentes em Rust

As regras de ownership (posse) e borrowing (empréstimo) dão conta da maioria das situações, mas alguns casos reais não cabem direitinho na ideia de “exatamente um dono”: um valor cujo tamanho só é conhecido em tempo de execução, dados que realmente precisam de mais de um dono, ou um valor que você precisa alterar através de uma referência compartilhada. Os smart pointers (ponteiros inteligentes) são a resposta do Rust para cada um desses casos.

Por padrão, os valores ficam na stack (pilha), e o compilador precisa saber o tamanho deles de antemão. O Box<T> move um valor para a heap e te dá um ponteiro de tamanho fixo para ele. Isso é útil quando o tamanho de um tipo não pode ser conhecido em tempo de compilação, principalmente em tipos recursivos:

fn main() {
let boxed = Box::new(5);
println!("{}", *boxed); // * para tirar o valor de dentro
}

Saída:

5
▶ Experimente você mesmo

Um exemplo clássico de quando você precisa do Box: um tipo que contém a si mesmo, como o nó de uma lista ligada:

enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
match list {
List::Cons(value, _) => println!("Primeiro valor: {value}"),
List::Nil => println!("Vazia"),
}
}

Saída:

Primeiro valor: 1
▶ Experimente você mesmo

Sem o Box, Cons(i32, List) precisaria de espaço infinito para ser guardado: uma List contendo uma List contendo uma List, para sempre. Já Box<List> é um ponteiro de tamanho fixo, então o compilador consegue calcular quanto espaço precisa.

O ownership diz que um valor tem exatamente um dono. Na maior parte do tempo isso é exatamente o certo, mas às vezes você realmente precisa que várias partes do programa compartilhem os mesmos dados. O Rc<T> (“reference counted”, com contagem de referências) permite isso, mantendo a conta de quantos donos existem no momento:

use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("dados compartilhados"));
let b = Rc::clone(&a); // não é uma cópia profunda, só mais um dono
let c = Rc::clone(&a);
println!("{a} {b} {c}");
println!("Donos: {}", Rc::strong_count(&a));
}

Saída:

dados compartilhados dados compartilhados dados compartilhados
Donos: 3
▶ Experimente você mesmo

O Rc::clone não copia os dados: ele só aumenta um contador e devolve mais uma referência para o mesmo valor. Os dados só são liberados quando todos os donos saem de escopo.

RefCell<T>: alterando através de uma referência compartilhada

Seção intitulada “RefCell<T>: alterando através de uma referência compartilhada”

Normalmente, as regras de borrowing são verificadas em tempo de compilação: ou várias referências só de leitura, ou uma mutável, nunca as duas coisas ao mesmo tempo. O RefCell<T> leva essa mesma verificação para o tempo de execução, o que permite alterar um valor mesmo através do que parece ser uma referência compartilhada, sem mut:

use std::cell::RefCell;
fn main() {
let value = RefCell::new(5);
*value.borrow_mut() += 1;
println!("{}", value.borrow());
}

Saída:

6
▶ Experimente você mesmo

O RefCell costuma andar junto com o Rc: Rc<RefCell<T>> te dá um valor com vários donos que ainda pode ser alterado, coisa que o Rc<T> sozinho não permite.

Não comece pelos smart pointers. Comece com ownership e borrowing comuns e só recorra a eles quando o compilador estiver deixando claro que não vai dar certo: um tipo recursivo (Box), um valor que realmente precisa de vários donos (Rc) ou uma alteração através de uma referência compartilhada que as regras de borrowing não permitem (RefCell).

Use o enum List da seção sobre Box. Monte a lista 1, 2, 3 e escreva uma função fn sum(list: &List) -> i32 que some todos os valores. Dica: faça um match na lista. Para Cons(value, rest), a resposta é value mais a soma de rest. Para Nil, é 0.

Ver solução
enum List {
Cons(i32, Box<List>),
Nil,
}
fn sum(list: &List) -> i32 {
match list {
List::Cons(value, rest) => value + sum(rest),
List::Nil => 0,
}
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
println!("Soma: {}", sum(&list));
}

Saída:

Soma: 6
▶ Experimente você mesmo

A função chama a si mesma com o resto da lista até chegar ao Nil. Repare que dá para passar rest (um &Box<List>) onde se espera um &List. O Rust enxerga através do Box para você.

Crie uma lista de compras (um Vec<String>) que duas pessoas, alice e bob, sejam donas e possam alterar. Cada uma acrescenta um item. Depois imprima a lista e o número de donos. Dica: embrulhe o Vec em Rc<RefCell<...>>, dê um Rc::clone para cada pessoa e chame .borrow_mut().push(...) para acrescentar.

Ver solução
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let list = Rc::new(RefCell::new(Vec::new()));
let alice = Rc::clone(&list);
let bob = Rc::clone(&list);
alice.borrow_mut().push(String::from("leite"));
bob.borrow_mut().push(String::from("pão"));
println!("{:?}", list.borrow());
println!("Donos: {}", Rc::strong_count(&list));
}

Saída:

["leite", "pão"]
Donos: 3
▶ Experimente você mesmo

São três donos: list, alice e bob. Os três apontam para o mesmo Vec, então um item acrescentado por alice também aparece por bob e por list. Nenhuma das variáveis precisa de mut, porque quem cuida da alteração é o RefCell.

  • Box<T> guarda um valor na heap com um ponteiro de tamanho fixo; é necessário em tipos recursivos.
  • Rc<T> permite vários donos para o mesmo valor em código de uma thread só, usando contagem de referências; entre threads, use Arc<T>.
  • RefCell<T> verifica as regras de borrowing em tempo de execução em vez de na compilação, permitindo alterar um valor através de uma referência compartilhada, mas entra em panic se as regras forem quebradas.
  • Use esses tipos só quando ownership e borrowing comuns não servirem para o seu caso.

Teste o que aprendeu

1. Por que um tipo recursivo, como o nó de uma lista ligada, precisa de Box<T>?

2. O que Rc::clone(&a) faz de verdade?

3. Quando o RefCell<T> verifica as regras de borrowing?

4. O que Rc<RefCell<T>> te dá que o Rc<T> sozinho não dá?

Pontuação: 0 / 4