Padrões de projeto idiomáticos
LidoEsta aula explora padrões de projeto idiomáticos em Rust, focando em RAII, Builder, Strategy com traits e Visitor. Você aprenderá a aplicar esses padrões de forma eficiente, aproveitando os recursos exclusivos da linguagem, como posse, empréstimo e traits, e entenderá quando cada padrão é mais adequado.
Bem-vindo à aula sobre padrões de projeto idiomáticos em Rust. Padrões de projeto são soluções reutilizáveis para problemas comuns em design de software. Em Rust, alguns padrões clássicos precisam ser adaptados para aproveitar o modelo de posse (ownership) e o sistema de tipos, resultando em implementações mais seguras e eficientes. Nesta aula, vamos explorar quatro padrões essenciais: RAII, Builder, Strategy com traits e Visitor, mostrando como cada um se manifesta no ecossistema Rust.
Dominar esses padrões é fundamental para escrever código Rust expressivo, seguro e de fácil manutenção. Você verá exemplos práticos e discussões sobre quando cada padrão é apropriado, além de exercícios para consolidar o conhecimento.
RAII
RAII (Resource Acquisition Is Initialization) é um padrão onde a aquisição de recursos (como memória, arquivos, locks) acontece na inicialização de um objeto, e a liberação ocorre automaticamente quando o objeto sai de escopo. Em Rust, esse padrão é nativo através do conceito de Drop e do sistema de posse.
Quando você cria uma variável que possui um recurso, o Rust garante que o destrutor (implementado via trait Drop) será chamado quando a variável sair de escopo, liberando o recurso de forma determinística. Isso elimina a necessidade de gerenciamento manual de memória, como em C++, e evita vazamentos de recursos.
Exemplo clássico é o MutexGuard do módulo std::sync::Mutex: ao chamar lock(), você obtém um guard que libera o lock automaticamente quando sai de escopo, mesmo em caso de pânico.
use std::sync::Mutex;
let mutex = Mutex::new(0);
{
let mut guard = mutex.lock().unwrap();
*guard += 1;
// guard sai de escopo aqui, liberando o lock automaticamente
}
// outro thread pode adquirir o lock agora
Para implementar RAII em seus próprios tipos, basta implementar o trait Drop. O método drop será chamado quando o valor for destruído.
struct FileHandle {
fd: i32,
}
impl FileHandle {
fn open(path: &str) -> Self {
// Simulação de abertura de arquivo
FileHandle { fd: 42 }
}
}
impl Drop for FileHandle {
fn drop(&mut self) {
// Fecha o arquivo
println!("Fechando arquivo com fd {}", self.fd);
}
}
fn main() {
let file = FileHandle::open("dados.txt");
// usa o arquivo...
} // file sai de escopo aqui, drop é chamado automaticamente
RAII é especialmente útil para gerenciar recursos que precisam ser liberados em ordem específica ou que podem causar problemas se não forem liberados. Em Rust, o compilador garante que o drop seja chamado exatamente uma vez, evitando liberação dupla ou uso após liberação.
Builder
O padrão Builder é usado para construir objetos complexos passo a passo, separando a construção da representação final. Em Rust, esse padrão é frequentemente implementado usando structs com campos opcionais e métodos que retornam Self ou &mut Self para encadeamento.
Uma abordagem idiomática é criar uma struct builder separada que acumula parâmetros e um método build() que valida e cria o objeto final. Isso é comum em APIs como std::process::Command e em bibliotecas de construção de consultas SQL.
Exemplo de um builder para configurar uma conexão de rede:
#[derive(Debug)]
struct Connection {
host: String,
port: u16,
timeout: u64,
tls: bool,
}
struct ConnectionBuilder {
host: String,
port: u16,
timeout: u64,
tls: bool,
}
impl ConnectionBuilder {
fn new(host: &str) -> Self {
ConnectionBuilder {
host: host.to_string(),
port: 8080,
timeout: 30,
tls: false,
}
}
fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
fn timeout(mut self, timeout: u64) -> Self {
self.timeout = timeout;
self
}
fn tls(mut self, tls: bool) -> Self {
self.tls = tls;
self
}
fn build(self) -> Connection {
Connection {
host: self.host,
port: self.port,
timeout: self.timeout,
tls: self.tls,
}
}
}
fn main() {
let conn = ConnectionBuilder::new("example.com")
.port(443)
.tls(true)
.build();
println!("{:?}", conn);
}
Uma variação comum usa referências mutáveis (&mut self) para permitir reutilizar o builder, mas isso exige cuidado com empréstimos. A versão que consome self é mais simples e comum.
Outra abordagem é usar o padrão Typestate, onde o tipo do builder muda conforme os passos são executados, garantindo em tempo de compilação que os campos obrigatórios foram definidos. Isso é mais avançado, mas oferece maior segurança.
Strategy com traits
O padrão Strategy define uma família de algoritmos, encapsula cada um e os torna intercambiáveis. Em Rust, traits são a forma natural de implementar esse padrão: você define uma trait que representa a estratégia e então implementa para diferentes tipos.
Isso permite que o código cliente dependa apenas da trait, e não de implementações concretas, promovendo baixo acoplamento e facilitando testes.
Exemplo: um processador de pagamentos com diferentes estratégias de cálculo de imposto.
trait TaxCalculator {
fn calculate(&self, amount: f64) -> f64;
}
struct BrazilTax;
impl TaxCalculator for BrazilTax {
fn calculate(&self, amount: f64) -> f64 {
amount * 0.2
}
}
struct UsaTax;
impl TaxCalculator for UsaTax {
fn calculate(&self, amount: f64) -> f64 {
amount * 0.1
}
}
struct PaymentProcessor {
tax_calculator: T,
}
impl PaymentProcessor {
fn process(&self, amount: f64) -> f64 {
amount + self.tax_calculator.calculate(amount)
}
}
fn main() {
let processor = PaymentProcessor { tax_calculator: BrazilTax };
println!("Total no Brasil: {}", processor.process(100.0));
let processor_usa = PaymentProcessor { tax_calculator: UsaTax };
println!("Total nos EUA: {}", processor_usa.process(100.0));
}
Uma alternativa mais flexível é usar trait objects (Box<dyn TaxCalculator>), permitindo que a estratégia seja escolhida em tempo de execução. Isso é útil quando o conjunto de estratégias é dinâmico.
fn process_with_dynamic(tax: &dyn TaxCalculator, amount: f64) -> f64 {
amount + tax.calculate(amount)
}
fn main() {
let brazil = BrazilTax;
let usa = UsaTax;
println!("{}", process_with_dynamic(&brazil, 100.0));
println!("{}", process_with_dynamic(&usa, 100.0));
}
O uso de traits para Strategy é idiomático em Rust, pois aproveita o sistema de tipos para garantir que todas as estratégias implementem a mesma interface, e permite tanto dispatch estático (monomorfização) quanto dinâmico (trait objects).
Visitor
O padrão Visitor permite adicionar novas operações a uma hierarquia de classes sem modificá-las. Em Rust, o padrão pode ser implementado usando traits e enums, especialmente útil para trabalhar com estruturas de dados algébricas como ASTs (Árvores Sintáticas Abstratas).
Uma abordagem comum é definir um trait Visitor com métodos para cada tipo de nó, e um método accept em cada nó que chama o visitante apropriado. Em Rust, isso pode ser feito com enums e match.
Exemplo: uma expressão matemática simples.
enum Expr {
Num(i32),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}
trait Visitor {
fn visit_num(&mut self, n: i32);
fn visit_add(&mut self, left: &Expr, right: &Expr);
fn visit_mul(&mut self, left: &Expr, right: &Expr);
}
impl Expr {
fn accept(&self, visitor: &mut dyn Visitor) {
match self {
Expr::Num(n) => visitor.visit_num(*n),
Expr::Add(l, r) => {
visitor.visit_add(l, r);
l.accept(visitor);
r.accept(visitor);
}
Expr::Mul(l, r) => {
visitor.visit_mul(l, r);
l.accept(visitor);
r.accept(visitor);
}
}
}
}
struct Evaluator {
result: i32,
}
impl Evaluator {
fn new() -> Self { Evaluator { result: 0 } }
}
impl Visitor for Evaluator {
fn visit_num(&mut self, n: i32) {
self.result = n;
}
fn visit_add(&mut self, left: &Expr, right: &Expr) {
// avaliações serão feitas por chamadas subsequentes
// aqui apenas registramos que é uma adição
// em uma implementação real, precisaríamos de uma pilha
}
fn visit_mul(&mut self, left: &Expr, right: &Expr) {
// idem
}
}
fn main() {
// Exemplo de árvore: (1 + 2) * 3
let expr = Expr::Mul(
Box::new(Expr::Add(Box::new(Expr::Num(1)), Box::new(Expr::Num(2)))),
Box::new(Expr::Num(3)),
);
let mut eval = Evaluator::new();
expr.accept(&mut eval);
// Em uma implementação completa, avaliaríamos a expressão.
}
O exemplo acima é simplificado; em um Visitor real, você precisaria de uma pilha ou de um acumulador para avaliar a expressão. Uma alternativa mais idiomática em Rust é usar o padrão fold (dobra) com match, que muitas vezes é mais simples que o Visitor tradicional.
Outra abordagem é usar o crate visitor ou implementar com closures, mas o padrão clássico ainda é útil em certos contextos, como em compiladores.
Quando aplicar
Cada padrão tem cenários ideais:
- RAII: Use sempre que precisar gerenciar recursos que devem ser liberados automaticamente, como arquivos, locks, conexões de rede. É a base do gerenciamento de recursos em Rust.
- Builder: Use quando o objeto tem muitos parâmetros opcionais ou quando a construção envolve validações complexas. É comum em APIs de configuração e em bibliotecas.
- Strategy com traits: Use quando você tem algoritmos intercambiáveis e deseja selecioná-los em tempo de execução ou compilação. É útil para injeção de dependência e para testar com mocks.
- Visitor: Use quando precisa executar operações em uma estrutura de dados complexa (como árvores) sem modificar as classes dos elementos. Em Rust, muitas vezes é substituído por
matche funções, mas ainda é útil em compiladores e processamento de ASTs.
Em Rust, muitos padrões clássicos podem ser simplificados devido ao sistema de tipos e ao padrão enum + match. Sempre avalie se a complexidade adicional do padrão é justificada. Prefira soluções mais simples, como usar enum em vez de herança, e funções associadas em vez de classes.
Boas práticas
- Prefira
enumematchem vez de herança para modelar variações. - Use
Dropapenas para recursos que precisam de limpeza; não abuse para lógica de negócio. - Em builders, considere usar o padrão Typestate para garantir que campos obrigatórios sejam definidos.
- Ao usar trait objects, lembre-se de que eles têm custo de performance (dynamic dispatch); use genéricos para dispatch estático quando possível.
- Documente os padrões usados no código para facilitar a manutenção.
Referências
- The Rust Book - Drop
- Rust std::ops::Drop
- The Rust Book - Design Patterns
- Rust Design Patterns (unofficial)
- The Rust Book - Traits
- The Rust Book - Trait Objects
Exercícios
- Implemente um tipo
TempFileque cria um arquivo temporário no disco e o remove quando sai de escopo, usando RAII. O tipo deve ter um métodowritepara escrever dados. - Crie um builder para configurar um servidor HTTP com campos: endereço (obrigatório), porta (padrão 8080), timeout (padrão 30s), e TLS (padrão false). Use o padrão Builder consumindo
self. - Implemente o padrão Strategy para ordenação de uma lista de números, com duas estratégias: ordenação crescente e decrescente. Use traits e genéricos.
- Usando o padrão Visitor, implemente um visitante que imprima a expressão aritmética (1 + 2) * 3 em notação infixa. A árvore é a mesma do exemplo.
- Descreva uma situação em que você usaria RAII, Builder, Strategy e Visitor em um mesmo projeto, e justifique cada escolha.
- RAII: para gerenciar conexões de banco de dados, garantindo que sejam fechadas automaticamente.
- Builder: para configurar um objeto de consulta complexo com filtros, ordenações e limites, com muitos parâmetros opcionais.
- Strategy: para escolher o algoritmo de agregação (soma, média, etc.) em tempo de execução, permitindo extensibilidade.
- Visitor: para percorrer a árvore de resultados e gerar relatórios em diferentes formatos (JSON, XML, etc.) sem modificar as classes de dados.
use std::fs;
use std::io::{self, Write};
struct TempFile {
path: std::path::PathBuf,
file: fs::File,
}
impl TempFile {
fn new(prefix: &str) -> io::Result {
let path = std::env::temp_dir().join(format!("{}_{}.tmp", prefix, std::process::id()));
let file = fs::File::create(&path)?;
Ok(TempFile { path, file })
}
fn write(&mut self, data: &[u8]) -> io::Result<()> {
self.file.write_all(data)
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn main() -> io::Result<()> {
let mut temp = TempFile::new("exemplo")?;
temp.write(b"dados")?;
// quando sair do escopo, o arquivo é removido
Ok(())
}
#[derive(Debug)]
struct Server {
address: String,
port: u16,
timeout: u64,
tls: bool,
}
struct ServerBuilder {
address: String,
port: u16,
timeout: u64,
tls: bool,
}
impl ServerBuilder {
fn new(address: &str) -> Self {
ServerBuilder {
address: address.to_string(),
port: 8080,
timeout: 30,
tls: false,
}
}
fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
fn timeout(mut self, timeout: u64) -> Self {
self.timeout = timeout;
self
}
fn tls(mut self, tls: bool) -> Self {
self.tls = tls;
self
}
fn build(self) -> Server {
Server {
address: self.address,
port: self.port,
timeout: self.timeout,
tls: self.tls,
}
}
}
fn main() {
let server = ServerBuilder::new("127.0.0.1")
.port(8081)
.tls(true)
.build();
println!("{:?}", server);
}
trait SortStrategy {
fn sort(&self, data: &mut [i32]);
}
struct Ascending;
impl SortStrategy for Ascending {
fn sort(&self, data: &mut [i32]) {
data.sort();
}
}
struct Descending;
impl SortStrategy for Descending {
fn sort(&self, data: &mut [i32]) {
data.sort_by(|a, b| b.cmp(a));
}
}
struct Sorter {
strategy: T,
}
impl Sorter {
fn new(strategy: T) -> Self { Sorter { strategy } }
fn sort(&self, data: &mut [i32]) {
self.strategy.sort(data);
}
}
fn main() {
let mut v = vec![3, 1, 2];
let sorter = Sorter::new(Ascending);
sorter.sort(&mut v);
println!("{:?}", v);
let sorter_desc = Sorter::new(Descending);
sorter_desc.sort(&mut v);
println!("{:?}", v);
}
enum Expr {
Num(i32),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}
trait Visitor {
fn visit_num(&mut self, n: i32) -> String;
fn visit_add(&mut self, left: &Expr, right: &Expr) -> String;
fn visit_mul(&mut self, left: &Expr, right: &Expr) -> String;
}
impl Expr {
fn accept(&self, visitor: &mut dyn Visitor) -> String {
match self {
Expr::Num(n) => visitor.visit_num(*n),
Expr::Add(l, r) => visitor.visit_add(l, r),
Expr::Mul(l, r) => visitor.visit_mul(l, r),
}
}
}
struct InfixPrinter;
impl Visitor for InfixPrinter {
fn visit_num(&mut self, n: i32) -> String {
n.to_string()
}
fn visit_add(&mut self, left: &Expr, right: &Expr) -> String {
format!("({} + {})", left.accept(self), right.accept(self))
}
fn visit_mul(&mut self, left: &Expr, right: &Expr) -> String {
format!("({} * {})", left.accept(self), right.accept(self))
}
}
fn main() {
let expr = Expr::Mul(
Box::new(Expr::Add(Box::new(Expr::Num(1)), Box::new(Expr::Num(2)))),
Box::new(Expr::Num(3)),
);
let mut printer = InfixPrinter;
println!("{}", expr.accept(&mut printer)); // ((1 + 2) * 3)
}
Em um sistema de processamento de dados, podemos ter:
Observações finais
Padrões de projeto são ferramentas poderosas, mas devem ser aplicados com discernimento. Em Rust, a linguagem já oferece muitas abstrações que podem tornar alguns padrões desnecessários. Sempre busque a solução mais simples que atenda aos requisitos, e lembre-se de que a segurança e a expressividade do Rust podem ser melhor aproveitadas com padrões idiomáticos como enum + match e traits.