O padrão Newtype
Nesta aula, exploramos o padrão Newtype em Rust, que consiste em criar um tipo novo encapsulando um tipo existente. Aprendemos como ele resolve a regra do órfão, permite implementar traits externas em tipos locais e traz segurança de tipos. Também vimos casos de uso práticos e exercícios para fixar o conteúdo.
O padrão Newtype é uma técnica fundamental em Rust para criar tipos novos a partir de tipos existentes, envolvendo-os em uma tupla de um único campo. Esse padrão é amplamente utilizado para dar significado semântico a tipos primitivos, melhorar a segurança de tipos e contornar a regra do órfão (orphan rule). Nesta aula, vamos mergulhar a fundo nesse padrão, entender como ele funciona, por que é tão útil e como aplicá-lo em situações reais.
Imagine que você está trabalhando com valores que representam idade, altura, peso, etc. Usar apenas u32 para todos eles pode levar a erros como somar idade com altura sem perceber. Com o padrão Newtype, criamos tipos distintos como Idade e Altura, que são incompatíveis entre si, prevenindo bugs. Além disso, o padrão permite implementar traits externos em tipos locais, algo que a regra do órfão normalmente proibiria.
Encapsulando tipos
O padrão Newtype consiste em definir uma struct com um único campo, geralmente com visibilidade privada, para encapsular o tipo original. Isso cria um tipo novo que é distinto do tipo base, mas que pode ser convertido de e para ele quando necessário. A sintaxe é simples:
struct Idade(u32);Aqui, Idade é um tipo novo que contém um u32. O campo é privado por padrão, então não podemos acessá-lo diretamente fora do módulo, mas podemos fornecer métodos para manipular o valor.
Uma das principais vantagens é a segurança de tipos: funções que aceitam Idade não podem receber um u32 acidentalmente, forçando o programador a ser explícito. Por exemplo:
fn verificar_idade(idade: Idade) { ... }
// Erro de compilação se passar u32
verificar_idade(25); // erro
verificar_idade(Idade(25)); // okAlém disso, podemos implementar métodos no tipo novo, como get para acessar o valor interno:
impl Idade {
fn get(&self) -> u32 { self.0 }
}O padrão também permite implementar traits como Display, From, Deref, etc., para tornar o tipo mais ergonômico.
Orphan rule
A regra do órfão (orphan rule) é uma restrição do Rust que impede a implementação de uma trait externa (de outra crate) em um tipo externo (de outra crate). Isso garante que as implementações de traits sejam coerentes e evita conflitos entre crates. A regra diz: você pode implementar uma trait para um tipo se, e somente se, ou a trait ou o tipo é local à sua crate.
Por exemplo, se você quiser implementar a trait Display (que é do std) para um tipo Foo da sua crate, isso é permitido porque Foo é local. Mas se você quiser implementar uma trait externa para um tipo externo, como Display para Vec<u32>, isso é proibido.
O padrão Newtype contorna essa limitação: ao criar um tipo local que encapsula o tipo externo, podemos implementar qualquer trait externa para o nosso tipo local. Por exemplo, suponha que você queira implementar Display para Vec<u32> para exibir os elementos de forma personalizada. Diretamente, isso violaria a regra do órfão. Mas se criarmos um tipo MyVec(Vec<u32>), podemos implementar Display para MyVec, pois MyVec é local.
use std::fmt;
struct MyVec(Vec<u32>);
impl fmt::Display for MyVec {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
}Assim, conseguimos estender a funcionalidade de tipos externos de forma limpa, sem violar a regra do órfão.
Implementando traits externas
Além de Display, o padrão Newtype é frequentemente usado para implementar traits de conversão, como From e TryFrom, e traits de operadores, como Add, Mul, etc. Isso permite que os tipos novos se comportem como o tipo base, mas com semântica própria.
Vamos ver um exemplo com From:
struct Idade(u32);
impl From<u32> for Idade {
fn from(value: u32) -> Self {
Idade(value)
}
}
// Uso
let idade = Idade::from(30);Também podemos implementar Add para permitir somar idades:
use std::ops::Add;
impl Add for Idade {
type Output = Idade;
fn add(self, other: Idade) -> Idade {
Idade(self.0 + other.0)
}
}Isso torna o tipo mais expressivo e seguro, pois a soma de idades só pode ser feita com idades, não com outros números.
Outro uso comum é implementar traits de serialização/deserialização, como serde::Serialize e Deserialize, para tipos que não os possuem. Isso é especialmente útil em aplicações que lidam com formatos externos.
Casos de uso
O padrão Newtype é versátil e aparece em muitos cenários:
- Segurança de tipos: diferenciar valores que têm o mesmo tipo primitivo, mas significados distintos, como
Idade,Altura,Peso,CPF, etc. - Unidades de medida: criar tipos como
Metros,Segundos,Quilogramaspara evitar misturar unidades em cálculos. - Validação de dados: encapsular valores que passam por validação, como um
Emailque garante que a string contém um email válido. - Implementação de traits externos: como vimos, permite implementar traits de crates externas em tipos locais que encapsulam tipos externos.
- Melhorar a ergonomia: fornecer métodos e comportamentos específicos para o domínio, em vez de usar o tipo base diretamente.
Por exemplo, em um sistema de banco, podemos ter Moeda que encapsula um f64 e implementa operações de soma com arredondamento adequado:
struct Moeda(f64);
impl Moeda {
fn novo(valor: f64) -> Self {
Moeda((valor * 100.0).round() / 100.0)
}
}
impl std::ops::Add for Moeda {
type Output = Moeda;
fn add(self, other: Moeda) -> Moeda {
Moeda::novo(self.0 + other.0)
}
}Isso evita erros de arredondamento e garante que as operações monetárias sejam consistentes.
Boas práticas e observações
Ao usar o padrão Newtype, é importante considerar:
- Fornecer métodos de acesso e conversão quando necessário, mas manter o campo interno privado para preservar invariantes.
- Implementar
Derefpode ser útil para delegar métodos ao tipo interno, mas use com cuidado para não vazar a semântica do tipo base. - Considere derivar traits comuns como
Debug,Clone,Copy(se o tipo base for Copy) ePartialEqpara facilitar o uso. - Use o padrão em APIs públicas para melhorar a segurança e a documentação.
Em resumo, o padrão Newtype é uma ferramenta poderosa no arsenal do Rust, permitindo criar abstrações seguras e expressivas sem custo de performance, já que a representação é a mesma do tipo base.
Referências
- The Rust Book - Advanced Types (Newtype Pattern)
- Rust by Example - Newtypes
- Rust Reference - Structs
- Rust Standard Library - From trait
- Rust Standard Library - Add trait
- Serde - Serialization Framework
Exercícios
- Crie um tipo
Pesoque encapsula umf64, com um métodonovoque valida se o valor é positivo. ImplementeDebugeClone. - Implemente a trait
From<f64>para o tipoPesocriado acima, chamandonovo. - Implemente a trait
Displaypara um tipoUrlque encapsula umaString, exibindo a URL sem o prefixo "https://" (se existir). - Considere que você deseja implementar a trait
IntoIteratorpara um tipoMyVecque encapsulaVec<u32>. Escreva o código necessário. - Crie um tipo
Notaque encapsula umu8e implementeTryFrom<u8>para validar que o valor está entre 0 e 10 (inclusive).
#[derive(Debug, Clone)]
struct Peso(f64);
impl Peso {
fn novo(valor: f64) -> Self {
assert!(valor >= 0.0, "Peso não pode ser negativo");
Peso(valor)
}
}impl From<f64> for Peso {
fn from(value: f64) -> Self {
Peso::novo(value)
}
}use std::fmt;
struct Url(String);
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let s = self.0.strip_prefix("https://").unwrap_or(&self.0);
write!(f, "{}", s)
}
}struct MyVec(Vec<u32>);
impl IntoIterator for MyVec {
type Item = u32;
type IntoIter = std::vec::IntoIter<u32>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}struct Nota(u8);
impl TryFrom<u8> for Nota {
type Error = &'static str;
fn try_from(value: u8) -> Result<Self, Self::Error> {
if value <= 10 {
Ok(Nota(value))
} else {
Err("Nota deve ser entre 0 e 10")
}
}
}