Default e o padrão Builder
Nesta aula, exploramos o trait Default e o padrão Builder em Rust, ferramentas essenciais para a construção ergonômica de valores. Você aprenderá a implementar Default, a criar builders para configurar objetos de forma flexível e como combinar essas técnicas para melhorar a legibilidade e segurança do seu código.
Na construção de software, frequentemente precisamos criar instâncias de tipos com valorespadrão ou com configurações customizadas. Rust oferece o trait Default para fornecer valores padrão e o padrão Builder para construir objetos passo a passo, de forma flexível e ergonômica. Nesta aula, vamos explorar ambos, entendendo suas aplicações e como combiná-los para escrever código mais limpo e expressivo.
O padrão Builder é amplamente utilizado quando um tipo tem muitos campos opcionais ou quando a ordem de configuração importa. Combinado com Default, podemos simplificar a implementação e oferecer uma experiência agradável para quem usa nossa API. Vamos mergulhar em exemplos práticos e ver como essas técnicas se complementam.
Trait Default
O trait Default é definido na biblioteca padrão do Rust e fornece um método default() que retorna uma nova instância do tipo com valores padrão. É uma forma de fornecer um valor inicial sensato para um tipo, sem exigir que o usuário especifique todos os campos.
Por exemplo, tipos numéricos têm Default retornando zero, String retorna uma string vazia, e Vec retorna um vetor vazio. Para nossos próprios tipos, podemos implementar Default manualmente ou derivá-lo com #[derive(Default)] se todos os campos implementarem Default.
#[derive(Debug, Default)]
struct Config {
host: String,
port: u16,
timeout: u32,
}
fn main() {
let cfg = Config::default();
println!("{:?}", cfg); // Config { host: "", port: 0, timeout: 0 }
}Derivar Default é conveniente, mas às vezes queremos valores padrão diferentes de zero ou vazio. Nesses casos, implementamos manualmente. Por exemplo, um servidor pode ter um host padrão "localhost" e porta 8080.
struct ServerConfig {
host: String,
port: u16,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: String::from("localhost"),
port: 8080,
}
}
}O trait Default é especialmente útil quando combinado com o padrão Builder, porque permite inicializar o builder com valores padrão e depois modificar apenas os campos desejados.
Builder pattern
O padrão Builder é um padrão de projeto criacional que separa a construção de um objeto complexo de sua representação. Em Rust, é frequentemente implementado com um struct separado que coleta configurações e um método build() que valida e retorna o objeto final.
Isso é útil quando um tipo tem muitos parâmetros, alguns opcionais, e queremos evitar longas listas de argumentos ou múltiplas sobrecargas de construtores. O Builder também permite encadeamento de métodos, tornando o código legível e fluente.
Vamos criar um exemplo de um Car com vários campos opcionais. Primeiro, definimos o struct Car e depois o CarBuilder.
#[derive(Debug, Clone)]
struct Car {
brand: String,
model: String,
year: u16,
color: String,
sunroof: bool,
}
struct CarBuilder {
brand: String,
model: String,
year: Option<u16>,
color: Option<String>,
sunroof: bool,
}
impl CarBuilder {
fn new(brand: &str, model: &str) -> Self {
Self {
brand: brand.to_string(),
model: model.to_string(),
year: None,
color: None,
sunroof: false,
}
}
fn year(mut self, year: u16) -> Self {
self.year = Some(year);
self
}
fn color(mut self, color: &str) -> Self {
self.color = Some(color.to_string());
self
}
fn sunroof(mut self, sunroof: bool) -> Self {
self.sunroof = sunroof;
self
}
fn build(self) -> Result<Car, String> {
let year = self.year.unwrap_or(2024); // valor padrão
let color = self.color.unwrap_or_else(|| String::from("white"));
Ok(Car {
brand: self.brand,
model: self.model,
year,
color,
sunroof: self.sunroof,
})
}
}
fn main() {
let car = CarBuilder::new("Toyota", "Corolla")
.year(2022)
.color("blue")
.sunroof(true)
.build()
.unwrap();
println!("{:?}", car);
}O builder permite que o usuário especifique apenas os campos que deseja, e os demais ficam com valores padrão. Isso melhora a legibilidade e evita erros de argumentos trocados.
Construção ergonômica
Podemos tornar a construção ainda mais ergonômica combinando Default com o Builder. Em vez de inicializar manualmente cada campo no builder, podemos começar com Self::default() ou Self::new() que usa valores padrão, e então sobrescrever apenas os campos necessários.
Uma abordagem comum é implementar Default para o builder, preenchendo todos os campos com valores padrão, e depois fornecer métodos que alteram esses campos. Isso reduz a repetição e garante que nenhum campo fique sem valor.
#[derive(Clone)]
struct CarBuilder {
brand: String,
model: String,
year: u16,
color: String,
sunroof: bool,
}
impl Default for CarBuilder {
fn default() -> Self {
Self {
brand: "Unknown".to_string(),
model: "Unknown".to_string(),
year: 2024,
color: "white".to_string(),
sunroof: false,
}
}
}
impl CarBuilder {
fn new() -> Self {
Self::default()
}
fn brand(mut self, brand: &str) -> Self {
self.brand = brand.to_string();
self
}
// ... outros métodos
fn build(self) -> Car {
Car {
brand: self.brand,
model: self.model,
year: self.year,
color: self.color,
sunroof: self.sunroof,
}
}
}Outra técnica é usar o padrão builder com tipo fantasma para garantir que campos obrigatórios sejam preenchidos em tempo de compilação. Isso aumenta a segurança, mas adiciona complexidade. Para a maioria dos casos, um builder simples com valores padrão já é suficiente.
Além disso, podemos usar o crate derive_builder para gerar automaticamente o código do builder a partir de um struct, economizando tempo e reduzindo erros. No entanto, é importante entender a implementação manual antes de usar ferramentas automáticas.
Exemplos
Vamos ver um exemplo completo que combina Default e Builder para configurar um servidor HTTP simples. Teremos um struct ServerConfig com vários campos e um ServerBuilder.
use std::time::Duration;
#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: usize,
timeout: Duration,
tls: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 8080,
workers: 4,
timeout: Duration::from_secs(30),
tls: false,
}
}
}
#[derive(Clone)]
struct ServerConfigBuilder {
config: ServerConfig,
}
impl ServerConfigBuilder {
fn new() -> Self {
Self { config: ServerConfig::default() }
}
fn host(mut self, host: &str) -> Self {
self.config.host = host.to_string();
self
}
fn port(mut self, port: u16) -> Self {
self.config.port = port;
self
}
fn workers(mut self, workers: usize) -> Self {
self.config.workers = workers;
self
}
fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self
}
fn tls(mut self, tls: bool) -> Self {
self.config.tls = tls;
self
}
fn build(self) -> ServerConfig {
self.config
}
}
fn main() {
let config = ServerConfigBuilder::new()
.host("0.0.0.0")
.port(3000)
.tls(true)
.build();
println!("{:?}", config);
}Neste exemplo, o builder começa com os valores padrão e permite sobrescrever apenas os campos desejados. Isso torna a construção clara e flexível.
Outra aplicação comum é em testes, onde precisamos criar muitos objetos com pequenas variações. O Builder facilita a criação de fixtures.
#[derive(Debug, Default)]
struct User {
name: String,
age: u8,
email: String,
}
fn main() {
// Usando Default
let user = User::default();
println!("{:?}", user);
// Usando um builder simples
let user = UserBuilder::new()
.name("Alice")
.age(30)
.build();
println!("{:?}", user);
}
struct UserBuilder {
name: String,
age: u8,
email: String,
}
impl UserBuilder {
fn new() -> Self {
Self {
name: String::new(),
age: 0,
email: String::new(),
}
}
fn name(mut self, name: &str) -> Self {
self.name = name.to_string();
self
}
fn age(mut self, age: u8) -> Self {
self.age = age;
self
}
fn email(mut self, email: &str) -> Self {
self.email = email.to_string();
self
}
fn build(self) -> User {
User {
name: self.name,
age: self.age,
email: self.email,
}
}
}Observe que no builder manual precisamos inicializar todos os campos, mesmo que sejam vazios. Usando Default para o builder, podemos simplificar ainda mais.
Boas práticas e observações finais
- Use
Defaultquando houver um valor padrão natural para o tipo. Isso torna o código mais conciso e permite combinar com o Builder. - No Builder, forneça métodos que consomem
self(por valor) para permitir encadeamento. Isso é ergonômico e evita clonagem desnecessária. - Considere validar os dados no método
build()retornandoResultem vez de sempre retornar um valor válido. Isso permite tratar erros de configuração. - Se o seu builder tiver muitos campos, considere usar o crate
derive_builderpara gerar a implementação automaticamente, mas entenda o que ele faz por baixo dos panos. - Use tipos fantasma se precisar garantir que campos obrigatórios sejam preenchidos em tempo de compilação, mas esteja ciente da complexidade adicional.
Exercícios
- Implemente o trait
Defaultpara um structPontocom camposxey(f32) de forma que o padrão seja a origem (0.0, 0.0). Teste com um exemplo. - Crie um builder para o struct
Pessoacom camposnome(String),idade(u8) eemail(String). O builder deve permitir encadeamento e fornecer valores padrão para os campos não especificados. - Modifique o builder do exercício anterior para usar
Defaultno builder, definindo valores padrão como nome vazio, idade 0 e email vazio. Teste a criação sem especificar nenhum campo. - No exemplo do servidor, adicione uma validação no método
build()para garantir que a porta não seja 0. Se for, retorne umResultcom uma mensagem de erro. - Pesquise sobre o crate
derive_buildere escreva um exemplo usando#[derive(Builder)]para um struct simples. Mostre como o código gerado se compara com um builder manual.
#[derive(Debug)]
struct Ponto {
x: f32,
y: f32,
}
impl Default for Ponto {
fn default() -> Self {
Self { x: 0.0, y: 0.0 }
}
}
fn main() {
let p = Ponto::default();
println!("{:?}", p); // Ponto { x: 0.0, y: 0.0 }
}#[derive(Debug)]
struct Pessoa {
nome: String,
idade: u8,
email: String,
}
struct PessoaBuilder {
nome: String,
idade: u8,
email: String,
}
impl PessoaBuilder {
fn new() -> Self {
Self {
nome: String::new(),
idade: 0,
email: String::new(),
}
}
fn nome(mut self, nome: &str) -> Self {
self.nome = nome.to_string();
self
}
fn idade(mut self, idade: u8) -> Self {
self.idade = idade;
self
}
fn email(mut self, email: &str) -> Self {
self.email = email.to_string();
self
}
fn build(self) -> Pessoa {
Pessoa {
nome: self.nome,
idade: self.idade,
email: self.email,
}
}
}
fn main() {
let p = PessoaBuilder::new()
.nome("João")
.idade(25)
.build();
println!("{:?}", p);
}#[derive(Clone)]
struct PessoaBuilder {
nome: String,
idade: u8,
email: String,
}
impl Default for PessoaBuilder {
fn default() -> Self {
Self {
nome: String::new(),
idade: 0,
email: String::new(),
}
}
}
impl PessoaBuilder {
fn new() -> Self {
Self::default()
}
fn nome(mut self, nome: &str) -> Self {
self.nome = nome.to_string();
self
}
fn idade(mut self, idade: u8) -> Self {
self.idade = idade;
self
}
fn email(mut self, email: &str) -> Self {
self.email = email.to_string();
self
}
fn build(self) -> Pessoa {
Pessoa {
nome: self.nome,
idade: self.idade,
email: self.email,
}
}
}
fn main() {
let p = PessoaBuilder::new().build();
println!("{:?}", p); // Pessoa { nome: "", idade: 0, email: "" }
}impl ServerConfigBuilder {
// ... outros métodos
fn build(self) -> Result<ServerConfig, String> {
if self.config.port == 0 {
Err("Porta deve ser diferente de zero".to_string())
} else {
Ok(self.config)
}
}
}
fn main() {
let config = ServerConfigBuilder::new()
.port(0)
.build();
match config {
Ok(c) => println!("{:?}", c),
Err(e) => eprintln!("Erro: {}", e),
}
}use derive_builder::Builder;
#[derive(Debug, Builder)]
struct Ponto {
x: f64,
y: f64,
}
fn main() {
let p = PontoBuilder::default()
.x(1.0)
.y(2.0)
.build()
.unwrap();
println!("{:?}", p);
}O crate gera um builder automaticamente, com métodos para cada campo e um método build() que retorna Result. Isso reduz muito o código manual, mas você perde controle sobre validações personalizadas, a menos que adicione atributos específicos.