Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tipos Avançados

O sistema de tipos do Rust possui alguns recursos que mencionamos até agora, mas ainda não discutimos de fato. Começaremos falando de newtypes em geral, à medida que examinamos por que eles são úteis como tipos. Em seguida, passaremos para aliases de tipo, um recurso semelhante aos newtypes, mas com semântica um pouco diferente. Também discutiremos o tipo ! e os tipos de tamanho dinâmico.

Segurança de Tipos e Abstração com o Padrão Newtype

Esta seção pressupõe que você tenha lido a seção anterior, “Implementando Traits Externas com o Padrão Newtype”. O padrão newtype também é útil para tarefas além daquelas que discutimos até aqui, incluindo impor estaticamente que valores nunca sejam confundidos e indicar as unidades de um valor. Você viu um exemplo de uso de newtypes para indicar unidades na Listagem 20-16: lembre-se de que as structs Millimeters e Meters encapsulam 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 tentasse, por engano, chamá-la com um valor do tipo Meters ou com 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 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 usasse People interagiria apenas 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, internamente, atribuímos um ID i32 aos nomes. O padrão newtype é uma forma leve de obter encapsulamento para esconder detalhes de implementação, algo que discutimos na seção “Encapsulamento que oculta detalhes de implementação” no Capítulo 18.

Sinônimos e Aliases de Tipo

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

fn main() {
    type Kilometers = i32;

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

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

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

fn main() {
    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 somar valores dos dois tipos e também passar valores Kilometers para funções que recebem parâmetros i32. No entanto, usando esse método, não obtemos os benefícios de verificação de tipo que o padrão newtype oferece. Em outras palavras, se misturarmos valores Kilometers e i32 em algum ponto, o compilador não nos dará nenhum erro.

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

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

Escrever esse tipo extenso em assinaturas de funções e em anotações de tipo por todo o código pode ser cansativo e sujeito a erros. Imagine um projeto cheio de código como o da Listagem 20-25.

fn main() {
    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--
        Box::new(|| ())
    }
}
Listing 20-25: Usando um tipo longo em muitos lugares

Um alias de tipo torna esse código mais administrável ao reduzir a repetição. Na Listagem 20-26, introduzimos um alias chamado Thunk para esse tipo verboso e substituímos todas as ocorrências pelo alias mais curto Thunk.

fn main() {
    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--
        Box::new(|| ())
    }
}
Listing 20-26: Introduzindo um alias de tipo, Thunk, para reduzir repetição

Esse código é bem mais fácil de ler e escrever. Escolher um nome significativo para um type alias também pode ajudar a comunicar sua intenção. Thunk é uma palavra usada para código que será avaliado mais tarde, portanto é um nome apropriado para uma closure armazenada.

Aliases de tipo também são comumente usados com Result<T, E> para reduzir repetição. Considere o módulo std::io da biblioteca padrão. Operações de E/S geralmente retornam um Result<T, E> para lidar com situações em que algo falha. Essa biblioteca tem uma struct std::io::Error que representa todos os erros de E/S possíveis. Muitas das funções de std::io retornam Result<T, E>, em que E é std::io::Error, como acontece nestas funções da 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>;
}

Result<..., Error> se repete bastante. Por isso, std::io possui esta declaração de alias de tipo:

use std::fmt;

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

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<()>;
}

Como essa declaração está no módulo std::io, podemos usar o alias totalmente qualificado std::io::Result<T>; isto é, um Result<T, E> com E preenchido como std::io::Error. As assinaturas das funções da trait Write acabam ficando assim:

use std::fmt;

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

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 é apenas um alias, ele é só outra forma de escrever Result<T, E>, o que significa que podemos usar com ele qualquer método que funcione com Result<T, E>, além de sintaxes especiais, como o operador ?.

O Tipo Never, Que Nunca Retorna

Rust tem um tipo especial chamado !, conhecido, no jargão da teoria dos tipos, como tipo vazio, porque não possui valores. Preferimos chamá-lo de tipo never porque ele aparece no lugar do tipo de retorno quando uma função nunca retornará. Aqui está um exemplo:

fn bar() -> ! {
    // --snip--
    panic!();
}

Esse código é lido como “a função bar retorna never”. Funções que nunca retornam são chamadas de funções divergentes. Não podemos criar valores do tipo !, então bar jamais poderá retornar.

Mas para que serve um tipo para o qual você nunca pode criar valores? Lembre-se do código da Listagem 2-5, parte do jogo de adivinhação de números; reproduzimos um trecho dele aqui na Listagem 20-27.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

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

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 20-27: Um match com um braço que termina em continue

Na época, pulamos alguns detalhes desse código. Na seção “A estrutura de controle de fluxo match do Capítulo 6, discutimos que todos os braços de match devem retornar o mesmo tipo. Assim, por exemplo, o código a seguir não funciona:

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

O tipo de guess nesse código teria de ser um inteiro e uma string, e Rust exige que guess tenha apenas um tipo. Então, o que continue retorna? Como pudemos retornar um u32 de um braço e ter outro braço que termina com continue na Listagem 20-27?

Como você já deve ter imaginado, continue tem o valor !. Ou seja, quando Rust calcula o tipo de guess, ele analisa ambos os braços do match: o primeiro com um valor u32 e o segundo com um valor !. Como ! nunca pode ter um valor, Rust decide que o tipo de guess é u32.

A forma formal de descrever esse comportamento é que expressões do tipo ! podem ser coercidas para qualquer outro tipo. Podemos encerrar esse braço do match com continue porque continue não retorna um valor; em vez disso, ele transfere o controle de volta para o início do loop, portanto, no caso Err, nunca atribuímos um valor a guess.

O tipo never também é útil com a macro panic!. Lembre-se da função unwrap, que chamamos em valores Option<T> para produzir um valor ou gerar um panic, com esta definição:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

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

Nesse código, acontece o mesmo que no match da Listagem 20-27: Rust vê que val tem tipo T e panic! tem tipo !, então o resultado da expressão match como um todo é T. Esse 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, portanto esse código é válido.

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

fn main() {
    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 ao chegar ao break.

Tipos de Tamanho Dinâmico e a Trait Sized

Rust precisa saber alguns detalhes sobre seus tipos, como quanto espaço deve ser alocado para um valor de um tipo específico. Isso deixa um canto do sistema de tipos um pouco confuso no início: o conceito de tipos de tamanho dinâmico. Às vezes chamados de DSTs ou unsized types, esses tipos nos permitem escrever código usando valores cujo tamanho só podemos saber em tempo de execução.

Vamos nos aprofundar nos detalhes de um tipo de tamanho dinâmico chamado str, que temos usado ao longo do livro. Isso mesmo: não &str, mas str por si só, é um DST. Em muitos casos, como ao armazenar um texto inserido pelo usuário, não podemos saber o tamanho da string até o tempo de execução. Isso significa que não podemos criar uma variável do tipo str, nem aceitar um argumento do tipo str. Considere o código a seguir, que não funciona:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust precisa saber quanta memória deve ser alocada para qualquer valor de um determinado tipo, e todos os valores de um mesmo tipo devem usar a mesma quantidade de memória. Se Rust nos permitisse escrever esse 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, de 15. Por isso, não é possível criar uma variável armazenando um tipo de tamanho dinâmico.

Então, o que fazemos? Nesse caso, você já sabe a resposta: fazemos com que o tipo de s1 e s2 seja string slice (&str), em vez de str. Lembre-se da seção “String Slices”, no Capítulo 4: a estrutura de dados slice armazena apenas a posição inicial e o comprimento do slice. Portanto, embora &T seja um único valor que armazena o endereço de memória de onde T está localizado, uma string slice tem dois valores: o endereço do str e seu comprimento. Assim, podemos saber o tamanho de uma string slice em tempo de compilação: ele é o dobro do tamanho de um usize. Ou seja, sempre sabemos o tamanho de uma string slice, não importa quão longa seja a string à qual ela se refere. Em geral, é assim que tipos de tamanho dinâmico são usados em Rust: eles carregam um pouco extra de metadados que armazena o tamanho da informação dinâmica. A regra de ouro para tipos de tamanho dinâmico é que devemos sempre colocar valores desses tipos atrás de algum tipo de ponteiro.

Podemos combinar str com vários tipos de ponteiro, por exemplo, Box<str> ou Rc<str>. Na verdade, você já viu isso antes, mas com outro tipo de tamanho dinâmico: traits. Toda trait é um tipo de tamanho dinâmico ao qual podemos nos referir usando o nome da trait. Na seção “Usando Objetos de Trait para Abstrair Comportamento Compartilhado” do Capítulo 18, mencionamos que, para usar traits como trait objects, devemos colocá-las atrás de um ponteiro, como &dyn Trait ou Box<dyn Trait> (Rc<dyn Trait> também funcionaria).

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

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

na verdade é tratada como se tivéssemos escrito isto:

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

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

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

Um trait bound ?Sized significa “T pode ou não ser Sized”, e essa notação substitui o comportamento padrão segundo o qual tipos genéricos precisam ter tamanho conhecido em tempo de compilação. A sintaxe ?Trait com esse significado só está disponível para Sized, e não para outras 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.

A seguir falaremos sobre funções e closures!