Pular para o conteúdo

Padrões de projeto em Rust

Um padrão de projeto (design pattern) é um jeito comprovado de resolver um problema que aparece de novo e de novo. Padrões não são bibliotecas que você instala. Eles são mais como receitas: “quando você tiver este problema, organize o seu código deste jeito”.

Muitos padrões do Java ou do C# ficam diferentes em Rust, ou nem são necessários. O Rust tem os seus próprios favoritos, construídos sobre o seu sistema de tipos forte, os enums, as traits e o ownership (posse). A grande ideia por trás da maioria deles é a mesma: deixe o compilador pegar os erros por você, para que eles nunca cheguem aos seus usuários. Cada seção abaixo começa pelo problema e depois mostra o padrão que o resolve.

Newtype: significados diferentes, tipos diferentes

Seção intitulada “Newtype: significados diferentes, tipos diferentes”

O problema. Um f64 puro pode significar qualquer coisa: metros, pés, segundos, euros. Se uma função recebe um f64 em metros e você passa pés, o compilador não tem como ajudar, porque os dois são só f64. (Foi justamente uma confusão de unidades desse tipo que fez a NASA perder a sonda Mars Climate Orbiter em 1999.)

O padrão. Embrulhe o valor em uma struct minúscula, com um único campo. Agora Meters e Feet são tipos diferentes, e o Rust não deixa você confundi-los:

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("A torre está segura? {}", safe_altitude(tower));
println!("900 pés são {:.1} metros", plane.to_meters().0);
println!("O avião está seguro? {}", safe_altitude(plane.to_meters()));
}

Saída:

A torre está segura? true
900 pés são 274.3 metros
O avião está seguro? false
▶ Experimente você mesmo

Se você esquecer de converter e escrever safe_altitude(plane), o programa nem compila:

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

O compilador diz que os tipos não batem: ele esperava Meters e encontrou Feet. Um newtype não custa nada em tempo de execução: na memória, Meters é exatamente um f64. Use esse padrão para IDs (UserId(u32) versus OrderId(u32)), unidades e strings já validadas. Ele também serve para contornar a regra do órfão (orphan rule); veja Traits avançadas.

O problema. Algumas structs têm muitos campos, e a maioria é opcional. Um construtor como Email::new("[email protected]", "Oi", "", vec![], false) é difícil de ler: qual false é qual? E se o endereço estiver faltando ou errado?

O padrão. Uma struct separada, o builder (construtor), junta as configurações com métodos pequenos e com nomes claros. Cada método retorna o builder, então você pode encadear as chamadas. No final, um build() confere tudo e retorna um Result:

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("um e-mail precisa de um destinatário")?;
if !to.contains('@') {
return Err(format!("'{}' não é um endereço de e-mail", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(sem assunto)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("Pronto: para={} assunto={} corpo={:?} urgente={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("Erro: {}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("Almoço?")
.body("Pizza ao meio-dia?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("Olá").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

Saída:

Pronto: [email protected] assunto=Almoço? corpo="Pizza ao meio-dia?" urgente=true
Pronto: [email protected] assunto=(sem assunto) corpo="" urgente=false
Erro: um e-mail precisa de um destinatário
Erro: 'ana.example.com' não é um endereço de e-mail
▶ Experimente você mesmo

Cada setter recebe mut self (ou seja, toma posse do builder), muda um campo e devolve o builder. É isso que faz o encadeamento funcionar. Quem chama só define o que lhe interessa, os nomes explicam cada valor, e o build() é o único lugar que confere as regras. Dentro dele, ok_or transforma o None do destinatário ausente em um Err, e o ? retorna esse erro na hora. Você vai ver builders em muitos crates, por exemplo em std::thread::Builder e std::process::Command (veja Ambiente e processos).

O problema. Um builder dá bastante código. Se a sua struct não tem regras para conferir, só valores iniciais razoáveis, ele é exagero.

O padrão. Implemente Default com bons valores iniciais e depois use a sintaxe de atualização de struct (struct update) .. para mudar só os campos que interessam:

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// Parte de outra configuração em vez do padrão:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

Saída:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ Experimente você mesmo

..algo quer dizer “pegue de algo todos os campos que eu não listei”. Por que prod.clone()? A atualização de struct move os campos restantes, e host é uma String. Sem o clone, prod ficaria parcialmente movida e você não conseguiria imprimi-la depois. Uma boa regra: use Default + .. quando qualquer combinação de valores estiver ok, e um builder quando algumas combinações forem inválidas.

Typestate: uma máquina de estados verificada em tempo de compilação

Seção intitulada “Typestate: uma máquina de estados verificada em tempo de compilação”

O problema. Algumas coisas precisam acontecer em ordem. Uma requisição web precisa de uma URL antes de ser enviada. Com uma struct normal, você conferiria em tempo de execução (“a URL foi definida? se não, retorne um erro”) e torceria para que todo mundo que chama tratasse esse erro.

O padrão. Faça o estado virar parte do tipo (typestate, ou “estado no tipo”). Um Request<NoUrl> simplesmente não tem o método send. Só definir a URL o transforma em um Request<HasUrl>, que tem:

// Os dois estados. Eles não guardam dados (ou só o que aquele estado precisa).
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// Muda o tipo: Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// Disponível em qualquer estado
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// Só disponível depois que a URL foi definida
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {} com {} header(s)", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

Saída:

GET https://example.com com 2 header(s)
▶ Experimente você mesmo

Agora tente enviar sem URL, com Request::new().send(), e o compilador impede você:

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

A mensagem diz que não existe um método send em Request<NoUrl>, e lembra que ele existe em Request<HasUrl>. Esse bug não tem como existir em um programa que compila. Não há verificação em tempo de execução nem custo extra: NoUrl ocupa zero bytes. Como url() recebe self por valor, o antigo Request<NoUrl> é consumido, então ninguém consegue continuar usando o estado “de antes” por engano. Isso usa genéricos de um jeito esperto; vale a pena fazer uma pequena mudança no Playground para sentir como funciona.

RAII e guards com Drop: uma limpeza que você não esquece

Seção intitulada “RAII e guards com Drop: uma limpeza que você não esquece”

O problema. Recursos precisam ser liberados: fechar o arquivo, destravar o lock, apagar a pasta temporária, imprimir “pronto”. Se a limpeza é uma função que você precisa lembrar de chamar, alguém vai esquecer, principalmente em um return antecipado ou em um erro.

O padrão. Coloque a limpeza no Drop. O Rust chama drop automaticamente quando um valor sai de escopo, por qualquer caminho de saída: o fim normal, um return antecipado, um ? e até um panic. Isso se chama RAII (“Resource Acquisition Is Initialization”, ou “aquisição de recurso é inicialização”: obter o recurso e ser dono de um valor são o mesmo passo). Um valor cujo único trabalho é fazer a limpeza se chama guard (guarda):

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("começando: {}", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("limpando: {}", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("tarefa arriscada");
if fail {
return Err(String::from("algo deu errado")); // o guard limpa mesmo assim
}
println!("tarefa concluída");
Ok(())
}
fn main() {
{
let _block = Guard::new("bloco");
println!("dentro do bloco");
} // _block é descartado aqui
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

Saída:

começando: bloco
dentro do bloco
limpando: bloco
começando: tarefa arriscada
tarefa concluída
limpando: tarefa arriscada
Ok(())
começando: tarefa arriscada
limpando: tarefa arriscada
Err("algo deu errado")
▶ Experimente você mesmo

Você já usa esse padrão o tempo todo. Um File se fecha sozinho quando é descartado. Um MutexGuard (que vem de mutex.lock(), veja Concorrência) destrava o mutex quando é descartado. Um Box libera a sua memória quando é descartado (Smart pointers). É por isso que código Rust quase nunca tem chamadas de “close” ou “free”.

From e Into: argumentos de função mais amigáveis

Seção intitulada “From e Into: argumentos de função mais amigáveis”

O problema. A sua função recebe uma String, então quem tem um &str precisa escrever String::from("Ana") ou "Ana".to_string() toda vez. Ou a sua função recebe um UserId, e quem chama tem um u32 puro. É pouca coisa, mas espalha ruído pelo código todo.

O padrão. Implemente From para os seus tipos e aceite impl Into<T> nas suas funções. Assim, quem chama pode passar qualquer coisa que se converta:

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 e &str
let ben = User::new(UserId(2), String::from("Ben")); // já nos tipos certos
println!("{:?}", ana);
println!("{:?}", ben);
}

Saída:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ Experimente você mesmo

Implemente From, nunca Into diretamente: você ganha o Into de graça. O mesmo truque move o tratamento de erros: o ? chama From::from no erro, então implementar From<io::Error> for MyError faz o ? converter os erros para você (veja Erros personalizados). Use isso para conversões que não podem falhar. Se uma conversão pode falhar, use TryFrom, que retorna um Result.

O problema. Um pedido vai de “no carrinho” para “pago” e depois para “enviado”. Uma primeira tentativa costuma ficar assim:

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

Mas isso permite absurdos: shipped: true com paid: false, ou um código de rastreio em um pedido que ainda não foi enviado. Toda função precisa conferir essas combinações impossíveis.

O padrão. Torne os estados ilegais impossíveis de representar. Use um enum com uma variante por estado, e dê a cada variante só os dados que aquele estado realmente tem. As transições são métodos que recebem o estado antigo e retornam o novo:

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // só um carrinho pode ser pago; o resto continua igual
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // tarde demais para cancelar
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("carrinho com {} item(ns)", items.len()),
Order::Paid { items, amount } => format!("pago {} por {} item(ns)", amount, items.len()),
Order::Shipped { tracking } => format!("enviado, rastreio {}", tracking),
Order::Cancelled => String::from("cancelado"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("livro"), String::from("caneta")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // ainda não foi pago: nada acontece
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // já foi enviado
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

Saída:

carrinho com 2 item(ns)
carrinho com 2 item(ns)
enviado, rastreio TRACK-42
enviado, rastreio TRACK-42
cancelado
▶ Experimente você mesmo

Um pedido enviado não tem como ficar sem código de rastreio: a variante Shipped sempre tem um. Um carrinho não tem como ter código de rastreio: não existe campo para isso. E como o match precisa cobrir todas as variantes, adicionar um estado novo (digamos, Refunded, reembolsado) faz o compilador apontar cada lugar que você precisa atualizar.

Enums ou trait objects? Linguagens orientadas a objetos costumam usar uma interface “State” com uma classe por estado. Em Rust, daria para fazer isso com trait objects (Box<dyn State>), e o Rust Book mostra como. Veja como escolher:

  • Enum: você conhece todos os estados de antemão. Você ganha a verificação completa do match, e toda a lógica fica em um lugar só. É a escolha mais comum em Rust.
  • Trait objects: outras pessoas (ou outros crates) precisam adicionar estados novos sem mudar o seu código.
  • Typestate (visto antes): você quer que chamadas na ordem errada sejam erros de compilação, e não só ignoradas em tempo de execução. É mais rígido, mas o estado precisa ser conhecido em tempo de compilação.

Escrever let _ = guard em vez de let _guard = guard. Parecem quase iguais, mas se comportam de um jeito diferente. Um nome que começa com sublinhado (como _guard) mantém o valor vivo até o fim do escopo. Um _ sozinho não é uma variável, então o valor é descartado na hora:

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard descartado");
}
}
fn main() {
let _ = Guard;
println!("trabalho com `let _`");
let _g = Guard;
println!("trabalho com `let _g`");
}

Saída:

guard descartado
trabalho com `let _`
trabalho com `let _g`
guard descartado
▶ Experimente você mesmo

Com let _, a limpeza rodou antes do trabalho, o que é exatamente o contrário do que você quer para um lock ou um cronômetro. Em guards, sempre dê um nome à variável.

Usar um padrão só porque ele existe. Um builder para uma struct de dois campos, ou um typestate para algo que nunca é usado errado, adiciona código sem deixar nada mais seguro. Comece simples. Recorra a um padrão quando você sentir o problema que ele resolve.

  • Newtype (struct Meters(f64)) dá tipos diferentes para os mesmos dados, e o compilador pega as confusões, sem nenhum custo em tempo de execução.
  • Builder oferece uma configuração legível e encadeada, com um único build() que valida e retorna um Result. Default + .. é a escolha mais leve quando não há nada para conferir.
  • Typestate coloca o estado no tipo (Request<NoUrl> versus Request<HasUrl>), então chamar as coisas na ordem errada não compila.
  • Guards RAII / Drop fazem a limpeza automaticamente por qualquer caminho de saída de um escopo.
  • From/Into permitem que funções aceitem vários tipos de entrada com impl Into<T>.
  • Enums modelam máquinas de estado e tornam os estados ilegais impossíveis de representar; use trait objects só quando outras pessoas precisarem adicionar estados.

Teste o que aprendeu

1. O que o newtype struct Meters(f64) oferece em relação a um f64 puro?

2. No padrão builder desta página, o que build() retorna?

3. No exemplo de typestate, por que não dá para chamar send() em Request::new()?

4. Quando a limpeza de um guard com Drop roda?

5. O que let _ = Guard; faz?

Pontuação: 0 / 5