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)); // ok

Alé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, Quilogramas para evitar misturar unidades em cálculos.
  • Validação de dados: encapsular valores que passam por validação, como um Email que 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 Deref pode 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) e PartialEq para 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

Exercícios

  1. Crie um tipo Peso que encapsula um f64, com um método novo que valida se o valor é positivo. Implemente Debug e Clone.
  2. ✓ Resposta:
    #[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)
        }
    }
  3. Implemente a trait From<f64> para o tipo Peso criado acima, chamando novo.
  4. ✓ Resposta:
    impl From<f64> for Peso {
        fn from(value: f64) -> Self {
            Peso::novo(value)
        }
    }
  5. Implemente a trait Display para um tipo Url que encapsula uma String, exibindo a URL sem o prefixo "https://" (se existir).
  6. ✓ Resposta:
    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)
        }
    }
  7. Considere que você deseja implementar a trait IntoIterator para um tipo MyVec que encapsula Vec<u32>. Escreva o código necessário.
  8. ✓ Resposta:
    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()
        }
    }
  9. Crie um tipo Nota que encapsula um u8 e implemente TryFrom<u8> para validar que o valor está entre 0 e 10 (inclusive).
  10. ✓ Resposta:
    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")
            }
        }
    }