O padrão Newtype é uma técnica poderosa em Rust que consiste em criar uma estrutura com um único campo, envolvendo um tipo existente. Esse padrão é amplamente utilizado para dar mais significado semântico a tipos primitivos, adicionar validações e, principalmente, contornar a regra do órfão (orphan rule) ao implementar traits externas em tipos locais.

Nesta aula, vamos mergulhar nos conceitos por trás do Newtype, explorar como encapsular tipos, entender a regra do órfão e como o padrão nos ajuda a implementar traits de crates externas em nossos próprios tipos. Também veremos casos de uso práticos que demonstram a versatilidade e a importância desse padrão no ecossistema Rust.

Encapsulando tipos

O padrão Newtype é uma forma de criar uma abstração sobre um tipo existente, definindo uma nova estrutura com um único campo. Isso permite que você trate o novo tipo como algo distinto, com suas próprias regras e comportamentos, mesmo que internamente ele seja apenas um invólucro de outro tipo.

Por exemplo, em vez de usar diretamente um String para representar um nome de usuário, você pode criar uma struct Username(String). Isso torna o código mais expressivo e impede que você, por engano, passe um nome de usuário onde se espera um e-mail, por exemplo. Além disso, você pode adicionar métodos específicos para esse novo tipo, como validações ou formatações.

struct Username(String);

impl Username {
    fn new(s: &str) -> Self {
        // Validação simples: não pode ser vazio
        assert!(!s.is_empty(), "Username não pode ser vazio");
        Username(s.to_string())
    }

    fn as_str(&self) -> &str {
        &self.0
    }
}

fn main() {
    let user = Username::new("alice");
    println!("Usuário: {}", user.as_str());
}

Note que, ao contrário de uma tupla, o Newtype permite implementar traits e métodos próprios, dando mais controle sobre a interface pública. O campo interno é acessível via .0, mas você pode encapsular esse acesso com métodos, como fizemos com as_str().

Orphan rule

A regra do órfão (orphan rule) é uma restrição do Rust que impede a implementação de uma trait para um tipo se ambos (trait e tipo) não forem definidos no mesmo crate. Isso é essencial para garantir a coerência do sistema de tipos: sem essa regra, dois crates poderiam implementar a mesma trait para o mesmo tipo de maneiras conflitantes, causando ambiguidade.

Na prática, isso significa que você não pode implementar uma trait de um crate externo (como serde::Serialize) para um tipo também externo (como String). Mas você pode implementar traits externas para tipos locais (definidos no seu crate) e traits locais para tipos externos. O Newtype é uma forma de contornar essa limitação: ao criar uma struct local que envolve um tipo externo, você pode implementar traits externas para esse novo tipo local.

Por exemplo, suponha que você queira que um tipo Vec<u8> seja serializável com Serde. Como Vec<u8> é externo, você não pode implementar Serialize diretamente. Mas, criando um Newtype struct Bytes(Vec<u8>), você pode implementar Serialize para Bytes no seu crate.

use serde::{Serialize, Serializer};

struct Bytes(Vec<u8>);

impl Serialize for Bytes {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_bytes(&self.0)
    }
}

Isso é extremamente útil quando você precisa integrar tipos externos com traits de crates populares, como serialização, hashing, formatação, etc.

Implementando traits externas

O padrão Newtype é frequentemente utilizado para implementar traits que não podem ser implementadas diretamente devido à regra do órfão. Ao criar um tipo local que envolve um tipo externo, você ganha a liberdade de implementar qualquer trait para esse novo tipo, desde que a trait também seja local ou que você esteja implementando uma trait externa para um tipo local.

Além de traits de serialização, é comum implementar traits como Display, From, Deref, FromStr, entre outros, em Newtypes. Por exemplo, você pode criar um tipo Temperature que encapsula um f64 e implementar Display para formatá-lo com a unidade Celsius.

use std::fmt;

struct Temperature(f64);

impl fmt::Display for Temperature {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.1}°C", self.0)
    }
}

fn main() {
    let temp = Temperature(23.5);
    println!("Temperatura: {}", temp); // Temperatura: 23.5°C
}

Outro exemplo comum é implementar From para conversões seguras. Suponha que você tenha um Newtype UserId(u64) e queira permitir a conversão de u64 para UserId de forma explícita.

struct UserId(u64);

impl From<u64> for UserId {
    fn from(id: u64) -> Self {
        UserId(id)
    }
}

fn main() {
    let id = UserId::from(42);
    // ou let id: UserId = 42.into();
}

Essas implementações tornam o código mais seguro e expressivo, pois garantem que os dados sejam tratados de acordo com seu significado.

Casos de uso

O padrão Newtype é versátil e aparece em muitos contextos. Vamos explorar alguns casos de uso comuns:

  • Tipos de segurança: Criar tipos distintos para representar diferentes unidades ou conceitos, como Meters, Seconds, UserId, etc., evita erros de mistura de unidades ou identificadores.
  • Validação de dados: Ao encapsular um tipo, você pode garantir que os valores sejam válidos antes de serem armazenados. Por exemplo, um Email Newtype pode validar o formato no construtor.
  • Implementação de traits externas: Como vimos, o Newtype permite implementar traits de crates externos para tipos que você não pode modificar. Isso é essencial para integração com bibliotecas como Serde, Diesel, etc.
  • Ocultar detalhes de implementação: O Newtype pode esconder a representação interna, permitindo que você a altere sem quebrar o código que usa o tipo. Por exemplo, um Cache pode ser implementado com um HashMap internamente, mas você pode mudar para um BTreeMap sem afetar os usuários.
  • Melhorar a API: Ao invés de passar múltiplos parâmetros primitivos, você pode agrupá-los em um Newtype, tornando a assinatura da função mais clara e menos propensa a erros de ordem.

Por exemplo, considere uma função que recebe um nome e um sobrenome. Em vez de fn criar_usuario(nome: String, sobrenome: String), você pode criar uma struct NomeCompleto { primeiro: String, ultimo: String } e passar um único parâmetro. Isso melhora a legibilidade e evita trocas acidentais.

struct NomeCompleto {
    primeiro: String,
    ultimo: String,
}

fn criar_usuario(nome: NomeCompleto) {
    // ...
}

fn main() {
    let nome = NomeCompleto {
        primeiro: "Maria".to_string(),
        ultimo: "Silva".to_string(),
    };
    criar_usuario(nome);
}

Em resumo, o padrão Newtype é uma ferramenta essencial no arsenal de um desenvolvedor Rust, promovendo segurança, expressividade e flexibilidade.

Boas práticas

Ao usar o padrão Newtype, é importante considerar algumas boas práticas:

  • Use nomes significativos: O nome da struct deve refletir o significado do tipo, não apenas o tipo interno.
  • Implemente Deref com cautela: Implementar Deref pode ser útil para acessar métodos do tipo interno, mas pode causar ambiguidades. Prefira métodos explícitos para expor funcionalidades.
  • Forneça construtores claros: Use funções associadas como new ou implemente From para criar instâncias, garantindo que a validação seja feita.
  • Considere o custo de abstração: O Newtype não adiciona overhead em tempo de execução (é um tipo com zero custo de abstração), mas pode tornar o código mais verboso. Use-o quando os benefícios superarem a verbosidade.

Referências

Exercícios

  1. Crie um Newtype chamado Meters que encapsula um f64. Implemente um método new que verifica se o valor é não negativo. Em seguida, implemente Display para exibir o valor com a unidade "m".

    ✓ Resposta:
    use std::fmt;
    
    struct Meters(f64);
    
    impl Meters {
        fn new(value: f64) -> Self {
            assert!(value >= 0.0, "Valor não pode ser negativo");
            Meters(value)
        }
    }
    
    impl fmt::Display for Meters {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "{} m", self.0)
        }
    }
    
    fn main() {
        let d = Meters::new(12.5);
        println!("Distância: {}", d);
    }
  2. Implemente o trait From<u32> para um Newtype Id que encapsula um u32.

    ✓ Resposta:
    struct Id(u32);
    
    impl From<u32> for Id {
        fn from(id: u32) -> Self {
            Id(id)
        }
    }
    
    fn main() {
        let id = Id::from(42);
        let id2: Id = 42.into();
    }
  3. Usando o Newtype, implemente o trait serde::Serialize para um tipo que encapsula um Vec<u8>, serializando como bytes.

    ✓ Resposta:
    use serde::{Serialize, Serializer};
    
    struct Bytes(Vec<u8>);
    
    impl Serialize for Bytes {
        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
        where
            S: Serializer,
        {
            serializer.serialize_bytes(&self.0)
        }
    }
  4. Explique por que o padrão Newtype é útil para contornar a regra do órfão. Dê um exemplo concreto.

    ✓ Resposta:A regra do órfão impede implementar uma trait externa para um tipo externo. Criando um Newtype local que envolve o tipo externo, podemos implementar a trait para esse tipo local, pois agora temos um tipo definido no nosso crate. Exemplo: implementar Display para um tipo externo como Vec<u8> não é permitido, mas se criarmos struct Bytes(Vec<u8>), podemos implementar Display para Bytes.
  5. Considere um sistema de coordenadas. Crie um Newtype Ponto2D que encapsula dois f64 (x, y) e implemente o trait PartialEq para comparar dois pontos com uma tolerância (por exemplo, 1e-6). Dica: implemente manualmente ou use uma struct com dois campos.

    ✓ Resposta:
    #[derive(Debug)]
    struct Ponto2D(f64, f64);
    
    impl PartialEq for Ponto2D {
        fn eq(&self, other: &Self) -> bool {
            (self.0 - other.0).abs() < 1e-6 && (self.1 - other.1).abs() < 1e-6
        }
    }
    
    fn main() {
        let p1 = Ponto2D(1.0, 2.0);
        let p2 = Ponto2D(1.0000001, 2.0);
        println!("p1 == p2? {}", p1 == p2); // true
    }