Prática de Tipos Avançados em Rust

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Bem-vindo a Tipos Avançados. Este laboratório faz parte do Livro do Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, discutiremos newtypes, type aliases (alias de tipos), o tipo !, e tipos de tamanho dinâmico no sistema de tipos do Rust.

Tipos Avançados

O sistema de tipos do Rust possui algumas funcionalidades que mencionamos até agora, mas que ainda não discutimos. Começaremos discutindo newtypes em geral, examinando por que newtypes são úteis como tipos. Em seguida, passaremos para type aliases (alias de tipos), uma funcionalidade semelhante aos newtypes, mas com semântica ligeiramente diferente. Também discutiremos o tipo ! e tipos de tamanho dinâmico.

Usando o Padrão Newtype para Segurança de Tipos e Abstração

Nota: Esta seção assume que você leu a seção anterior "Usando o Padrão Newtype para Implementar Traits Externos".

O padrão newtype também é útil para tarefas além daquelas que discutimos até agora, incluindo a imposição estática de que os valores nunca sejam confundidos e a indicação das unidades de um valor. Você viu um exemplo de uso de newtypes para indicar unidades na Listagem 19-15: lembre-se que as structs Millimeters e Meters encapsulavam valores u32 em um newtype. Se escrevêssemos uma função com um parâmetro do tipo Millimeters, não conseguiríamos compilar um programa que acidentalmente tentasse chamar essa função com um valor do tipo Meters ou um simples u32.

Também podemos usar o padrão newtype para abstrair alguns detalhes de implementação de um tipo: o novo tipo pode expor uma API pública que é diferente da API do tipo interno privado.

Newtypes também podem ocultar a implementação interna. Por exemplo, poderíamos fornecer um tipo People para encapsular um HashMap<i32, String> que armazena o ID de uma pessoa associado ao seu nome. O código que usa People só interagiria com a API pública que fornecemos, como um método para adicionar uma string de nome à coleção People; esse código não precisaria saber que atribuímos um ID i32 aos nomes internamente. O padrão newtype é uma maneira leve de alcançar a encapsulação para ocultar detalhes de implementação, o que discutimos em "Encapsulamento que Oculta Detalhes de Implementação".

Criando Sinônimos de Tipos com Type Aliases (Alias de Tipos)

O Rust oferece a capacidade de declarar um type alias (alias de tipo) para dar a um tipo existente outro nome. Para isso, usamos a palavra-chave type. Por exemplo, podemos criar o alias Kilometers para i32 da seguinte forma:

type Kilometers = i32;

Agora, o alias Kilometers é um sinônimo para i32; ao contrário dos tipos Millimeters e Meters que criamos na Listagem 19-15, Kilometers não é um tipo novo e separado. Valores que têm o tipo Kilometers serão tratados da mesma forma que valores do tipo i32:

type Kilometers = i32;

let x: i32 = 5;
let y: Kilometers = 5;

println!("x + y = {}", x + y);

Como Kilometers e i32 são o mesmo tipo, podemos adicionar valores de ambos os tipos e podemos passar valores Kilometers para funções que aceitam parâmetros i32. No entanto, usando este método, não obtemos os benefícios de verificação de tipo que obtemos do padrão newtype discutido anteriormente. Em outras palavras, se misturarmos valores Kilometers e i32 em algum lugar, o compilador não nos dará um erro.

O principal caso de uso para sinônimos de tipos é reduzir a repetição. Por exemplo, podemos ter um tipo extenso como este:

Box<dyn Fn() + Send + 'static>

Escrever este tipo extenso em assinaturas de funções e como anotações de tipo em todo o código pode ser cansativo e propenso a erros. Imagine ter um projeto cheio de código como o da Listagem 19-24.

let f: Box<dyn Fn() + Send + 'static> = Box::new(|| {
    println!("hi");
});

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
    --snip--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
    --snip--
}

Listagem 19-24: Usando um tipo longo em muitos lugares

Um alias de tipo torna este código mais gerenciável, reduzindo a repetição. Na Listagem 19-25, introduzimos um alias chamado Thunk para o tipo verboso e podemos substituir todos os usos do tipo pelo alias mais curto Thunk.

type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
    --snip--
}

fn returns_long_type() -> Thunk {
    --snip--
}

Listagem 19-25: Introduzindo um alias de tipo Thunk para reduzir a repetição

Este código é muito mais fácil de ler e escrever! Escolher um nome significativo para um alias de tipo pode ajudar a comunicar sua intenção também (thunk é uma palavra para código a ser avaliado em um momento posterior, então é um nome apropriado para uma closure que é armazenada).

Alias de tipos também são comumente usados com o tipo Result<T, E> para reduzir a repetição. Considere o módulo std::io na biblioteca padrão. Operações de I/O geralmente retornam um Result<T, E> para lidar com situações em que as operações falham. Esta biblioteca tem uma struct std::io::Error que representa todos os possíveis erros de I/O. Muitas das funções em std::io retornarão Result<T, E> onde o E é std::io::Error, como estas funções no trait Write:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(
        &mut self,
        fmt: fmt::Arguments,
    ) -> Result<(), Error>;
}

O Result<..., Error> é repetido muitas vezes. Como tal, std::io tem esta declaração de alias de tipo:

type Result<T> = std::result::Result<T, std::io::Error>;

Como esta declaração está no módulo std::io, podemos usar o alias totalmente qualificado std::io::Result<T>; ou seja, um Result<T, E> com o E preenchido como std::io::Error. As assinaturas de função do trait Write acabam parecendo assim:

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

O alias de tipo ajuda de duas maneiras: torna o código mais fácil de escrever e nos dá uma interface consistente em todo o std::io. Como é um alias, é apenas outro Result<T, E>, o que significa que podemos usar quaisquer métodos que funcionem em Result<T, E> com ele, bem como sintaxe especial como o operador ?.

O Tipo Never (Nunca) que Nunca Retorna

O Rust tem um tipo especial chamado ! que é conhecido na linguagem da teoria dos tipos como o tipo vazio (empty type) porque não tem valores. Preferimos chamá-lo de tipo never (nunca) porque ele fica no lugar do tipo de retorno quando uma função nunca retornará. Aqui está um exemplo:

fn bar() -> ! {
    --snip--
}

Este código é lido como "a função bar retorna nunca". Funções que retornam nunca são chamadas de funções divergentes (diverging functions). Não podemos criar valores do tipo !, então bar nunca pode retornar.

Mas qual a utilidade de um tipo para o qual você nunca pode criar valores? Recorde o código da Listagem 2-5, parte do jogo de adivinhação de números; reproduzimos um pouco dele aqui na Listagem 19-26.

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};

Listagem 19-26: Um match com um braço que termina em continue

Na época, ignoramos alguns detalhes neste código. Em "A Construção de Fluxo de Controle match", discutimos que os braços match devem todos retornar o mesmo tipo. Então, por exemplo, o código a seguir não funciona:

let guess = match guess.trim().parse() {
    Ok(_) => 5,
    Err(_) => "hello",
};

O tipo de guess neste código teria que ser um inteiro e uma string, e o Rust exige que guess tenha apenas um tipo. Então, o que continue retorna? Como fomos autorizados a retornar um u32 de um braço e ter outro braço que termina com continue na Listagem 19-26?

Como você pode ter adivinhado, continue tem um valor !. Ou seja, quando o Rust calcula o tipo de guess, ele olha para ambos os braços match, o primeiro com um valor de u32 e o último com um valor !. Como ! nunca pode ter um valor, o Rust decide que o tipo de guess é u32.

A forma formal de descrever este comportamento é que expressões do tipo ! podem ser forçadas em qualquer outro tipo. Somos autorizados a terminar este braço match com continue porque continue não retorna um valor; em vez disso, ele move o controle de volta para o topo do loop, então no caso Err, nunca atribuímos um valor a guess.

O tipo never é útil com a macro panic! também. Recorde a função unwrap que chamamos em valores Option<T> para produzir um valor ou entrar em pânico com esta definição:

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!(
                "called `Option::unwrap()` on a `None` value"
            ),
        }
    }
}

Neste código, a mesma coisa acontece como no match na Listagem 19-26: Rust vê que val tem o tipo T e panic! tem o tipo !, então o resultado da expressão match geral é T. Este código funciona porque panic! não produz um valor; ele encerra o programa. No caso None, não estaremos retornando um valor de unwrap, então este código é válido.

Uma expressão final que tem o tipo ! é um loop:

print!("forever ");

loop {
    print!("and ever ");
}

Aqui, o loop nunca termina, então ! é o valor da expressão. No entanto, isso não seria verdade se incluíssemos um break, porque o loop terminaria quando chegasse ao break.

Tipos de Tamanho Dinâmico e o Trait Sized

O Rust precisa saber certos detalhes sobre seus tipos, como quanto espaço alocar para um valor de um tipo específico. Isso deixa um canto de seu sistema de tipos um pouco confuso no início: o conceito de tipos de tamanho dinâmico (dynamically sized types). Às vezes referidos como DSTs ou tipos sem tamanho (unsized types), esses tipos nos permitem escrever código usando valores cujo tamanho só podemos saber em tempo de execução.

Vamos analisar os detalhes de um tipo de tamanho dinâmico chamado str, que temos usado ao longo do livro. É isso mesmo, não &str, mas str sozinho, é um DST. Não podemos saber quanto tempo a string tem até o tempo de execução, o que significa que não podemos criar uma variável do tipo str, nem podemos receber um argumento do tipo str. Considere o seguinte código, que não funciona:

let s1: str = "Hello there!";
let s2: str = "How's it going?";

O Rust precisa saber quanta memória alocar para qualquer valor de um tipo específico, e todos os valores de um tipo devem usar a mesma quantidade de memória. Se o Rust nos permitisse escrever este código, esses dois valores str precisariam ocupar a mesma quantidade de espaço. Mas eles têm comprimentos diferentes: s1 precisa de 12 bytes de armazenamento e s2 precisa de 15. É por isso que não é possível criar uma variável que contenha um tipo de tamanho dinâmico.

Então, o que fazemos? Neste caso, você já sabe a resposta: tornamos os tipos de s1 e s2 um &str em vez de um str. Recorde de "Fatias de String" que a estrutura de dados de fatia apenas armazena a posição inicial e o comprimento da fatia. Então, embora um &T seja um único valor que armazena o endereço de memória de onde o T está localizado, um &str são dois valores: o endereço do str e seu comprimento. Como tal, podemos saber o tamanho de um valor &str em tempo de compilação: é o dobro do comprimento de um usize. Ou seja, sempre sabemos o tamanho de um &str, não importa o quão longa seja a string a que ele se refere. Em geral, esta é a maneira pela qual os tipos de tamanho dinâmico são usados no Rust: eles têm um bit extra de metadados que armazena o tamanho da informação dinâmica. A regra de ouro dos tipos de tamanho dinâmico é que sempre devemos colocar valores de tipos de tamanho dinâmico atrás de um ponteiro de algum tipo.

Podemos combinar str com todos os tipos de ponteiros: por exemplo, Box<str> ou Rc<str>. Na verdade, você já viu isso antes, mas com um tipo de tamanho dinâmico diferente: traits. Cada trait é um tipo de tamanho dinâmico ao qual podemos nos referir usando o nome do trait. Em "Usando Objetos de Trait que Permitem Valores de Tipos Diferentes", mencionamos que para usar traits como objetos de trait, devemos colocá-los atrás de um ponteiro, como &dyn Trait ou Box<dyn Trait> (Rc<dyn Trait> também funcionaria).

Para trabalhar com DSTs, o Rust fornece o trait Sized para determinar se o tamanho de um tipo é conhecido em tempo de compilação. Este trait é implementado automaticamente para tudo cujo tamanho é conhecido em tempo de compilação. Além disso, o Rust adiciona implicitamente um limite em Sized para cada função genérica. Ou seja, uma definição de função genérica como esta:

fn generic<T>(t: T) {
    --snip--
}

é realmente tratada como se tivéssemos escrito isto:

fn generic<T: Sized>(t: T) {
    --snip--
}

Por padrão, funções genéricas funcionarão apenas em tipos que têm um tamanho conhecido em tempo de compilação. No entanto, você pode usar a seguinte sintaxe especial para relaxar esta restrição:

fn generic<T: ?Sized>(t: &T) {
    --snip--
}

Um limite de trait em ?Sized significa "T pode ou não ser Sized" e esta notação substitui o padrão de que tipos genéricos devem ter um tamanho conhecido em tempo de compilação. A sintaxe ?Trait com este significado só está disponível para Sized, não para quaisquer outros traits.

Observe também que mudamos o tipo do parâmetro t de T para &T. Como o tipo pode não ser Sized, precisamos usá-lo atrás de algum tipo de ponteiro. Neste caso, escolhemos uma referência.

Em seguida, falaremos sobre funções e closures!

Resumo

Parabéns! Você concluiu o laboratório de Tipos Avançados. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.