Padrões de Design em Rust
Padrões de design em Rust não são uma tradução do catálogo Gang of Four para uma nova sintaxe.
Eles são uma resposta diferente para a mesma pergunta subjacente: como organizar o código de forma que o uso correto seja fácil e o uso incorreto seja difícil.
Em linguagens construídas em torno de classes e herança, essa resposta geralmente envolve polimorfismo de subtipo, despacho virtual e verificações em tempo de execução.
Em Rust, propriedade, semântica de movimentação e um verificador de tipos rigoroso em tempo de compilação já estão fazendo um trabalho estrutural para você, então os padrões que valem a pena são aqueles que se apoiam nessa maquinaria em vez de contorná-la.
Esta página é a âncora conceitual para o restante da seção rust-patterns.
Ela explica por que padrões como o wrapper newtype, o builder, a máquina typestate e os guardas RAII têm a aparência que têm, e fornece uma estrutura para decidir quando cada um vale sua complexidade adicionada.
Resumo
- Padrões Rust empurram invariantes de tempo de execução (IDs válidos, transições de estado válidas, recursos liberados) para o compilador, usando propriedade e o sistema de tipos em vez de herança ou verificações em tempo de execução.
- Por que Importa: Bugs capturados em tempo de compilação nunca chegam à produção, e o modelo de propriedade de Rust torna várias categorias de bugs (uso após liberação, limpeza esquecida, identificadores misturados) verificáveis estaticamente de uma forma que linguagens com coleta de lixo e baseadas em classes não conseguem replicar facilmente.
- Conceitos Chave: tipagem nominal, abstração de custo zero, propriedade e semântica de movimentação, o padrão typestate, RAII (Resource Acquisition Is Initialization - Aquisição de Recurso é Inicialização), estados ilegais não representáveis.
- Quando Usar: Procure por esses padrões quando uma classe de bug se recorre em uma base de código (IDs misturados, structs parcialmente configuradas, recursos vazados em um retorno antecipado, métodos chamados na ordem errada).
- Limitações / Trocas: Cada padrão aqui troca alguma usabilidade ou flexibilidade em tempo de compilação pela garantia que oferece, e o uso excessivo de qualquer um deles adiciona cerimônia sem um retorno correspondente.
- Tópicos Relacionados: propriedade e empréstimo, o sistema de traits, tratamento de erros com
Result, design de API em Rust.
Fundamentos
Um "padrão" no sentido orientado a objetos é uma solução nomeada e reutilizável para um problema de design recorrente, e o catálogo original Gang of Four assume uma linguagem com classes, herança e referências nulas.
Rust não tem nenhuma dessas três coisas da forma que programadores de C++ ou Java esperam.
Não há herança de implementação, então padrões como Template Method ou Decorator clássico não se mapeiam diretamente, e não há nulo, então padrões construídos em torno de defesa contra ele são simplesmente desnecessários.
O que Rust tem é um sistema de tipos incomumente expressivo, um compilador que rastreia quem possui cada valor e por quanto tempo, e um sistema de traits para comportamento compartilhado sem implementação compartilhada.
Padrões de design Rust são o que acontece quando você pergunta "como obtenho essa garantia" usando apenas essas ferramentas, e os quatro padrões em que esta seção se concentra respondem a uma pergunta específica e comum com uma garantia específica imposta pelo compilador.
O padrão newtype envolve um tipo existente (geralmente um primitivo como u64 ou String) em uma struct de um campo para criar um novo tipo não relacionado no nível do compilador.
struct UserId(u64);
struct OrderId(u64);
// um UserId nunca pode ser passado onde um OrderId é esperado,
// mesmo que ambos sejam apenas um u64 por baixoEsta é a tipagem nominal: dois tipos com layout idêntico ainda são incompatíveis se tiverem nomes diferentes, e Rust impõe isso a custo zero de tempo de execução porque o wrapper desaparece após a compilação.
O padrão builder separa o ato de construir um valor do próprio valor, o que é importante em uma linguagem sem argumentos padrão ou construtores telescópicos.
O padrão typestate codifica o estado de um objeto (aberto, fechado, rascunho, enviado) como um parâmetro de tipo em vez de um campo, de modo que o compilador se recusa a compilar uma chamada que só é válida em um estado diferente.
RAII vincula a limpeza de recursos ao tempo de vida de um valor: quando um valor que detém um lock, handle de arquivo ou conexão sai do escopo, o trait Drop de Rust é executado automaticamente, sem coletor de lixo e sem a necessidade de um bloco finally.
Mecânicas e Interações
Esses quatro padrões não são truques independentes.
Eles são quatro expressões de um mecanismo: o verificador de tipos de Rust trata cada tipo distinto como um fato em tempo de compilação, e o tempo de vida de cada valor como um escopo sobre o qual o compilador pode raciocinar estaticamente.
Newtype é o caso mais simples: ele cria um novo fato ("este u64 é especificamente um UserId") verificado em cada local de chamada gratuitamente, porque o wrapper é apagado em tempo de compilação e o código gerado corresponde exatamente à versão bruta de u64.
Typestate generaliza isso de "este valor tem um significado" para "este valor só permite certas chamadas de método", tornando o estado parte do próprio tipo, geralmente por meio de um tipo marcador de tamanho zero e PhantomData.
Como os métodos Rust são implementados por tipo concreto, um método definido apenas em Connection<Open> simplesmente não existe em Connection<Closed>, então chamá-lo é um erro de compilação em vez de um pânico em tempo de execução ou uma verificação if esquecida.
Transições de Typestate quase sempre consomem o valor antigo (self, não &self) e retornam um novo, espelhando semântica de movimentação: uma vez que .connect() foi chamado, o Connection<Disconnected> antigo não existe mais, então não há handle obsoleto para reutilizar acidentalmente.
Builder usa essa mesma mecânica baseada em movimentação de forma mais suave.
Cada setter recebe self por valor e retorna self, então uma cadeia como Client::builder().timeout(..).retries(..).build() é realmente uma sequência de movimentações em vez de mutações de um objeto compartilhado.
RAII fica uma camada abaixo dos outros três, porque é o mecanismo que torna a propriedade confiável para gerenciamento de recursos em primeiro lugar.
A implementação Drop de uma struct é executada deterministicamente no momento em que seu escopo proprietário termina, na ordem inversa em que os campos foram declarados, o que permite que tipos de guarda (um MutexGuard, um guarda de arquivo temporário, um timer de métricas) limpem corretamente em todos os caminhos de saída, incluindo retornos antecipados e, em uma compilação de desenrolamento, pânicos.
entrar no escopo
adquirir recurso -> construir Guarda
... fazer trabalho, talvez retornar antecipadamente, talvez entrar em pânico ...
sair do escopo (qualquer caminho)
Guard::drop() executa automaticamente -> recurso liberadoUma falha comum de raciocínio é esperar que essas garantias sobrevivam a limites async ou FFI sem modificação.
Drop não tem variante async, então a limpeza que requer um .await não pode ser executada automaticamente quando um future é descartado, e os marcadores typestate geralmente precisam ser apagados para um enum simples em um limite FFI ou de serialização, já que chamadores externos e serde não entendem tipos fantasmas de tamanho zero.
Considerações Avançadas e Aplicações
A verdadeira habilidade não é conhecer a mecânica; é julgar quando a garantia vale a cerimônia, já que cada um desses padrões adiciona uma camada que um leitor precisa aprender.
A fadiga do wrapper newtype é um custo real: envolver cada primitivo transforma aritmética simples em acesso ao campo .0 e implementações de trait manuais, então o padrão ganha seu lugar nas fronteiras do domínio (APIs públicas, IDs de banco de dados, dinheiro) em vez de em todos os lugares onde um u64 aparece.
Typestate tem a curva de custo mais acentuada.
Cada estado adicional multiplica o número de blocos impl, e o parâmetro de tipo de estado vaza para cada assinatura de função que o toca, então é melhor reservá-lo para protocolos onde uma sequência de chamadas ilegal seria um incidente real, como handshakes de conexão, e não para o estado de alternância da UI.
A complexidade do Builder escala de forma diferente: é barato adicionar, mas fácil de aplicar em excesso a uma struct de três ou quatro campos, onde um literal de struct simples ou um Default derive seria tão seguro e mais legível.
As falhas do RAII são mais sutis porque só aparecem sob condições de tempo de execução específicas.
A lógica de limpeza que assume desenrolamento silenciosamente não é executada sob panic = "abort", e uma implementação Drop que entra em pânico durante outro pânico aborta o processo em vez de apenas vazar.
Arquiteturalmente, esses padrões se agrupam: um ciclo de vida de conexão typestate é geralmente construído através de um builder para sua configuração inicial, seus estados intermediários são frequentemente newtypes ou structs marcadoras, e os recursos que ele detém são liberados através de guardas RAII.
Esse agrupamento é uma lente de revisão útil: enums de estado feitos à mão com verificações de tempo de execução dispersas de "transição inválida" são um sinal de que typestate moveria essas verificações para o tempo de compilação, e a limpeza duplicada em todos os ramos de retorno antecipado é um sinal de que um guarda de escopo a colapsaria em um único lugar.
| Abordagem | Força | Fraqueza | Melhor Ajuste |
|---|---|---|---|
| Newtype | Tipagem nominal de custo zero, centraliza validação | Boilerplate do wrapper, "fadiga" se usado em excesso | IDs de domínio, strings validadas, dinheiro, wrappers seguros para FFI |
| Builder | Construção legível de valores complexos, validação é executada uma vez | Adiciona um segundo tipo por produto, fácil de aplicar em excesso a structs pequenas | Muitos campos opcionais, objetos de configuração, construção de cliente |
| Typestate | Sequências de chamadas ilegais se tornam erros de compilação, sem verificações de estado em tempo de execução | Explosão genérica, estranho em limites de serialização/FFI | Ciclos de vida de protocolo/conexão, transações de várias etapas |
| RAII / Guardas Drop | Limpeza determinística em todos os caminhos de saída, sem necessidade de GC | Drop não assíncrono, falha sob panic = "abort" sem cuidado | Locks, arquivos, recursos temporários, timers, transações de rollback-on-drop |
Concepções Equivocadas Comuns
- "Padrões Rust são apenas os padrões GoF com sintaxe diferente" - a maioria dos padrões GoF existe para contornar herança, despacho virtual e nulo; o modelo de propriedade de Rust e a falta de herança tornam vários deles desnecessários ou sem sentido, enquanto produzem um punhado de padrões (typestate, newtype-como-tipagem-nominal) que não têm um análogo GoF direto.
- "O padrão typestate requer
unsafe" - ele é construído inteiramente a partir de genéricos comuns, structs marcadoras ePhantomData, todos os quais são Rust seguro;unsafenão faz parte do padrão em si. - "RAII é apenas o trait
Drop" -Dropé o mecanismo, mas RAII é a disciplina mais ampla de vincular o tempo de vida de cada recurso ao escopo de propriedade de um valor, que também envolve projetar construtores que sempre deixam um valor em um estado válido e pronto para limpeza. - "Um newtype é apenas uma struct wrapper, nada de especial" - o significado é a tipagem nominal imposta pelo compilador a custo zero de tempo de execução, que é uma garantia mais forte e mais barata do que um comentário ou uma convenção de nomenclatura podem fornecer.
- "Builder pattern sempre precisa de um macro derive ou crate externo" - um builder escrito à mão com um punhado de setters que consomem
selfem cadeia é muitas vezes mais claro do que um gerado, e crates comotyped-builderoubonsão conveniências para structs grandes, não um requisito do padrão.
FAQs
O que torna um padrão "idiomático Rust" em vez de um padrão emprestado de outra linguagem?
Um padrão idiomático Rust se apoia em propriedade, semântica de movimentação e no sistema de traits para impor uma garantia em tempo de compilação, em vez de depender de herança, verificações de tipo em tempo de execução ou tratamento defensivo de nulos que o sistema de tipos de Rust torna desnecessário.
Por que Rust não tem padrões clássicos baseados em herança como Template Method ou Decorator em sua forma original?
Rust não tem herança de implementação entre structs, apenas comportamento compartilhado baseado em traits e composição, então padrões construídos em torno de sobrescrever um método de classe pai são tipicamente retrabalhados como métodos padrão de trait, genéricos ou composição explícita em vez disso.
Como o padrão newtype realmente previne bugs em tempo de compilação?
Ele cria um tipo nominal distinto para o compilador verificar, então uma função que espera um UserId rejeitará um argumento OrderId mesmo que ambos envolvam um u64, e essa verificação ocorre durante a verificação de tipos, antes que o programa seja executado.
Como o padrão typestate transforma um erro de tempo de execução em um erro de compilação?
Ao codificar o estado como um parâmetro de tipo, métodos válidos apenas em um estado são implementados apenas para o tipo desse estado, então chamar .query() em Connection<Disconnected> não é um método que existe para ser chamado, e o compilador relata "nenhum método encontrado" em vez de o programa entrar em pânico ou se comportar mal em tempo de execução.
RAII substitui a necessidade de um coletor de lixo em Rust?
Para limpeza de recursos, sim: RAII libera deterministicamente memória, locks, arquivos e handles no momento em que seu valor proprietário sai do escopo, que é por que Rust não precisa de um coletor de lixo para correção, embora seja um mecanismo diferente de um GC e tenha modos de falha diferentes (como Drop não assíncrono).
Quando um literal de struct simples é melhor do que o padrão builder?
Quando um tipo tem um conjunto de campos pequeno e majoritariamente obrigatório (aproximadamente três ou menos) e nenhuma etapa de validação significativa, um literal de struct ou um Default derive com algumas substituições é mais simples de ler e manter do que um tipo builder separado.
Os padrões newtype e typestate podem ser combinados?
Sim, e é comum: marcadores typestate são frequentemente structs zero-sized semelhantes a newtype (struct Open;, struct Closed;) usadas puramente como parâmetros de tipo, então os dois padrões compartilham o mesmo mecanismo subjacente de tipagem nominal.
Qual é o maior risco de usar em excesso o padrão typestate?
Explosão de parâmetros genéricos: cada estado adicional multiplica o número de blocos impl necessários, e o parâmetro de tipo de estado tende a vazar para todas as assinaturas de função que tocam o valor, o que pode tornar a API mais difícil de ler do que a verificação de tempo de execução que ela substituiu.
Por que `Drop` não pode ser `async`?
Drop::drop é um método síncrono sem forma de ceder a um executor, então a limpeza que genuinamente requer esperar por uma operação (como um fechamento de rede assíncrono) não pode ocorrer automaticamente quando um valor é descartado e deve ser acionada explicitamente antes que o valor saia do escopo.
A "abstração de custo zero" é realmente de custo zero para esses padrões?
Para newtype e typestate, sim no caso comum: ambos são apagados inteiramente em tempo de compilação, então o código compilado é idêntico a escrever a versão não envolvida e não verificada à mão; RAII tem o custo comum da chamada Drop em si, que é o mesmo custo de escrever essa chamada de limpeza manualmente.
Qual é a diferença entre um alias de tipo e o padrão newtype?
Um alias de tipo (type UserId = u64;) é apenas outro nome para o mesmo tipo exato, então um UserId e um u64 bruto são livremente intercambiáveis; um newtype (struct UserId(u64);) cria um tipo genuinamente distinto que o compilador não trocará silenciosamente com u64.
Como decido qual desses quatro padrões, se algum, um pedaço de código realmente precisa?
Pergunte qual bug o padrão evitaria e com que frequência essa classe de bug realmente ocorre na base de código: misturas de identificadores apontam para newtype, objetos parcialmente configurados apontam para builder, ordenação de chamadas ilegais aponta para typestate, e recursos vazados ou liberados duas vezes apontam para RAII, e se nenhum desses bugs for um problema recorrente, uma abordagem mais simples é geralmente a melhor opção.
Relacionados
- Noções Básicas de Padrões Rust - o guia prático que esta página ancora, com dez exemplos executáveis em toda a seção.
- O Padrão Newtype - um mergulho completo em estilo de receita sobre tipagem nominal com structs wrapper.
- Padrão Builder - ergonomia de construção para structs com muitos campos opcionais.
- Padrão Typestate - codificando máquinas de estado no sistema de tipos em detalhes.
- RAII & Guardas Drop - limpeza de recursos vinculada ao escopo e tipos de guarda.
- Noções Básicas de Propriedade - a base de propriedade e semântica de movimentação em que cada padrão nesta página se baseia.
Versões de Stack: Esta página é conceitual e não está vinculada a uma versão específica de stack, embora os trechos ilustrativos sigam as convenções de Rust 1.97.0 (edição 2024) usadas em toda esta seção.