sábado, 17 de abril de 2021

Tipos de dados genéricos em Rust

Podemos usar genéricos para criar definições para itens como assinaturas de função ou estruturas, que podemos então usar com muitos tipos de dados concretos diferentes. Vejamos primeiro como definir funções, estruturas, enums e métodos usando genéricos. Em seguida, discutiremos como os genéricos afetam o desempenho do código.

Em Definições de Função

Ao definir uma função que usa genéricos, colocamos os genéricos na assinatura da função onde normalmente especificaríamos os tipos de dados dos parâmetros e o valor de retorno. Isso torna nosso código mais flexível e fornece mais funcionalidade aos chamadores de nossa função, evitando a duplicação de código.

Continuando com nossa função maior, a Listagem 10-4 mostra duas funções que encontram o maior valor em uma fatia.

Nome do arquivo: src/main.rs

fn maior_i32(list: &[i32]) -> &i32 {
    let mut maior = &list[0];

    for item in list {
        if item > maior {
            maior = item;
        }
    }

    maior
}

fn maior_char(list: &[char]) -> &char {
    let mut maior = &list[0];

    for item in list {
        if item > maior {
            maior = item;
        }
    }

    maior
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = maior_i32(&number_list);
    println!("O maior numero é {}", result);
    assert_eq!(result, &100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = maior_char(&char_list);
    println!("O maior char é {}", result);
    assert_eq!(result, &'y');
}

Listagem 10-4: Duas funções que diferem apenas em seus nomes e os tipos em suas assinaturas

A função maior_i32 é aquela que extraímos na Listagem 10-3 que encontra a maior i32 em uma fatia. A função maior_char encontra o maior char em uma fatia. Os corpos das funções têm o mesmo código, portanto, vamos eliminar a duplicação introduzindo um parâmetro de tipo genérico em uma única função.

Para parametrizar os tipos na nova função que definiremos, precisamos nomear o parâmetro de tipo, assim como fazemos para os parâmetros de valor de uma função. Você pode usar qualquer identificador como um nome de parâmetro de tipo. Mas usaremos T porque, por convenção, os nomes dos parâmetros no Rust são curtos, geralmente apenas uma letra, e a convenção de nomenclatura de tipo do Rust é CamelCase. Abreviação de “tipo”, T é a escolha padrão da maioria dos programadores do Rust.

Quando usamos um parâmetro no corpo da função, temos que declarar o nome do parâmetro na assinatura para que o compilador saiba o que esse nome significa. Da mesma forma, quando usamos um nome de parâmetro de tipo em uma assinatura de função, temos que declarar o nome do parâmetro de tipo antes de usá-lo. Para definir a função genérica maior, coloque as declarações de nome de tipo entre colchetes angulares, <>, entre o nome da função e a lista de parâmetros, assim:

fn maior<T>(list: &[T]) -> &T {

Lemos esta definição como: a função maior é genérica sobre algum tipo T. Essa função tem um parâmetro denominado list, que é uma fatia de valores do tipo T. A função maior retornará uma referência a um valor do mesmo tipo T.

A Listagem 10-5 mostra a definição da função maior combinada usando o tipo de dados genérico em sua assinatura. A lista também mostra como podemos chamar a função com uma fatia de valores i32 ou valores char. Observe que este código não será compilado ainda, mas vamos consertar isso mais tarde neste capítulo.

Nome do arquivo: src/main.rs

Esse código não compila Esse código não compila.

fn maior<T>(list: &[T]) -> &T {
    let mut maior = &list[0];

    for item in list {
        if item > maior {
            maior = item;
        }
    }

    maior
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = maior(&number_list);
    println!("O maior numero é {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = maior(&char_list);
    println!("O maior char é {}", result);
}

Listagem 10-5: Uma definição da função maior que usa parâmetros de tipo genérico, mas ainda não compila

Se compilarmos este código agora, obteremos este erro:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > maior {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn maior<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to previous error

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10`

To learn more, run the command again with --verbose.

A nota menciona std::cmp::PartialOrd, que é um característica. Falaremos sobre as características na próxima seção. Por enquanto, esse erro afirma que o corpo de maior não funcionará para todos os tipos possíveis que T poderiam ser. Como queremos comparar valores de tipo T no corpo, só podemos usar tipos cujos valores podem ser solicitados. Para permitir comparações, a biblioteca padrão tem a característica std::cmp::PartialOrd que você pode implementar em tipos (consulte o Apêndice C para mais informações sobre esta característica). Você aprenderá como especificar que um tipo genérico tem uma característica particular na seção “Características como parâmetros”, mas vamos primeiro explorar outras maneiras de usar parâmetros de tipo genérico.

Em Definições Struct

Também podemos definir estruturas para usar um parâmetro de tipo genérico em um ou mais campos usando a sintaxe <>. A Listagem 10-6 mostra como definir uma estrutura Point<T> para conter os valores das coordenadas x e y de qualquer tipo.

Nome do arquivo: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Listagem 10-6: Uma estrutura Point<T> que contém valores x e y do tipo T

A sintaxe para usar genéricos em definições de estrutura é semelhante à usada em definições de função. Primeiro, declaramos o nome do parâmetro de tipo entre colchetes angulares logo após o nome da estrutura. Então, podemos usar o tipo genérico na definição de estrutura, onde, de outra forma, especificaríamos tipos de dados concretos.

Observe que, como nós usamos apenas um tipo genérico para definir Point<T>, esta definição diz que a estrutura Point<T> é genérica sobre algum tipo T, e os campos x e y são ambos do mesmo tipo, qualquer que seja o tipo. Se criarmos uma instância de Point<T> que possui valores de tipos diferentes, como na Listagem 10-7, nosso código não será compilado.

Nome do arquivo: src/main.rs

Esse código não compila Esse código não compila.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Listagem 10-7: Os campos x e y devem ser do mesmo tipo porque ambos têm o mesmo tipo de dados genérico T.

Neste exemplo, quando atribuímos o valor inteiro 5 a x, informamos ao compilador que o tipo genérico T será um inteiro para esta instância de Point<T>. Então, quando especificarmos 4.0 para y, que definimos como sendo do mesmo tipo x, obteremos um erro de incompatibilidade de tipo como este:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10`

To learn more, run the command again with --verbose.

Para definir uma estrutura Point onde x e y são genéricos, mas podem ter tipos diferentes, podemos usar vários parâmetros de tipo genérico. Por exemplo, na Listagem 10-8, podemos alterar a definição de Point para ser genérico sobre os tipos T e U onde x é do tipo T e y é do tipo U.

Nome do arquivo: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Listagem 10-8: Um genérico Point<T, U> sobre dois tipos, de modo que x e y podem ser valores de tipos diferentes

Agora todas as instâncias de Point mostrado são permitidas! Você pode usar quantos parâmetros de tipo genérico quiser em uma definição, mas usar mais do que alguns torna seu código difícil de ler. Quando você precisa de muitos tipos genéricos em seu código, isso pode indicar que seu código precisa ser reestruturado em partes menores.

Em Definições Enum

Como fizemos com structs, podemos definir enums para conter tipos de dados genéricos em suas variantes. Vamos dar uma outra olhada no enum Option<T> que a biblioteca padrão fornece, que usamos no Capítulo 6:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Esta definição agora deve fazer mais sentido para você. Como você pode ver, Option<T> é um enum que é genérico sobre o tipo T e tem duas variantes:, Some que contém um valor de tipo T e uma variante None que não contém nenhum valor. Usando o enum Option<T>, podemos expressar o conceito abstrato de ter um valor opcional e, por Option<T> ser genérico, podemos usar essa abstração independentemente do tipo do valor opcional.

Enums também podem usar vários tipos genéricos. A definição do enum Result que usamos no Capítulo 9 é um exemplo:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

O enum Result é genérico em dois tipos, T e E, e tem duas variantes: Ok que contém um valor de tipo T e Err, que contém um valor de tipo E. Essa definição torna conveniente usar o enum Result em qualquer lugar em que tenhamos uma operação que pode ser bem-sucedida (retornar um valor de algum tipo T) ou se falhar (retornar um erro de algum tipo E). Na verdade, é isso que usamos para abrir um arquivo na Listagem 9-3, onde T foi preenchido com o tipo std::fs::File quando o arquivo foi aberto com sucesso e E foi preenchido com o tipo std::io::Error quando houve problemas ao abrir o arquivo.

Ao reconhecer situações em seu código com várias definições de struct ou enum que diferem apenas nos tipos dos valores que contêm, você pode evitar a duplicação usando tipos genéricos.

Em Definições de Método

Podemos implementar métodos em structs e enums (como fizemos no Capítulo 5) e usar tipos genéricos em suas definições também. A Listagem 10-9 mostra a estrutura Point<T> que definimos na Listagem 10-6 com um método denominado x implementado nela.

Nome do arquivo: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Listagem 10-9: Implementando um método nomeado x na estrutura Point<T> que retornará uma referência ao campo x do tipo T

Aqui, definimos um método denominado x em Point<T> que retorna uma referência aos dados no campo x.

Observe que temos que declarar T logo depois de impl para que possamos usá-lo para especificar que estamos implementando métodos no tipo Point<T>. Ao declarar T como um tipo genérico depois de impl, Rust pode identificar que o tipo entre os colchetes angulares em Point é um tipo genérico em vez de um tipo concreto.

Poderíamos, por exemplo, implementar métodos apenas nas instâncias de Point<f32>, em vez das instâncias Point<T> com qualquer tipo genérico. Na Listagem 10-10, usamos o tipo concreto f32, o que significa que não declaramos nenhum tipo depois de impl.

Nome do arquivo: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distancia_da_origen(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Listagem 10-10: Um bloco impl que se aplica apenas a uma estrutura com um tipo específico de concreto para o parâmetro de tipo genérico T

Este código significa que o tipo Point<f32> terá um método denominado distancia_da_origen e outras instâncias de Point<T> onde T não é do tipo f32 não terão esse método definido. O método mede o quão longe nosso ponto está do ponto nas coordenadas (0,0, 0,0) e usa operações matemáticas que estão disponíveis apenas para tipos de ponto flutuante.

Os parâmetros de tipo genérico em uma definição de estrutura nem sempre são os mesmos que você usa nas assinaturas de método dessa estrutura. Por exemplo, a Listagem 10-11 define o método mixup na estrutura Point<T, U> da Listagem 10-8. O método recebe outro Point como parâmetro, que pode ter tipos diferentes do self Point que estamos chamando mixup. O método cria uma nova instância de Point com o valor x de self Point (do tipo T) e o valor y do passado Point (do tipo W).

Nome do arquivo: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

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

Listagem 10-11: Um método que usa diferentes tipos genéricos da definição de sua estrutura

Em main, definimos um Point que possui um i32 para x (com valor 5) e um f64 para y (com valor 10.4). A variável p2 é uma estrutura Point que possui uma fatia de string para x (com valor "Hello") e uma char para y (com valor c). Chamando mixup em p1 com o argumento p2 nos dá p3, que terá um i32 para x, porque x veio de p1. A variável p3 terá um char para y, porque y veio de p2. A chamada da macro println! será impressa p3.x = 5, p3.y = c.

O objetivo deste exemplo é demonstrar uma situação em que alguns parâmetros genéricos são declarados com impl e alguns são declarados com a definição do método. Aqui, os parâmetros genéricos T e U são declarados depois de impl, porque vão com a definição da estrutura. Os parâmetros genéricos V e W são declarados depois de fn mixup, porque são relevantes apenas para o método.

Desempenho do código usando genéricos

Você pode estar se perguntando se há um custo de tempo de execução ao usar parâmetros de tipo genérico. A boa notícia é que o Rust implementa genéricos de forma que seu código não execute mais devagar usando tipos genéricos do que faria com tipos concretos.

Rust faz isso realizando a monomorfização do código que está usando genéricos em tempo de compilação. Monomorfização é o processo de transformar código genérico em código específico, preenchendo os tipos concretos que são usados quando compilados.

Nesse processo, o compilador faz o oposto das etapas que usamos para criar a função genérica na Listagem 10-5: o compilador examina todos os lugares onde o código genérico é chamado e gera o código para os tipos concretos com os quais o código genérico é chamado.

Vejamos como isso funciona com um exemplo que usa o enum Option<T> da biblioteca padrão:


#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Quando o Rust compila esse código, ele realiza a monomorfização. Durante esse processo, o compilador lê os valores que foram usados ​​nas instâncias de Option<T> e identifica dois tipos de Option<T>: um é i32 e o outro é f64. Como tal, ele expande a definição genérica de Option<T> em Option_i32 e Option_f64, assim, substituindo a definição genérica por outras específicas.

A versão monomorfizada do código se parece com o seguinte. O genérico Option<T> é substituído pelas definições específicas criadas pelo compilador:

Nome do arquivo: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

Como o Rust compila o código genérico em um código que especifica o tipo em cada instância, não pagamos nenhum custo de tempo de execução pelo uso de genéricos. Quando o código é executado, ele funciona exatamente como se tivéssemos duplicado cada definição manualmente. O processo de monomorfização torna os genéricos de Rust extremamente eficientes em tempo de execução.

Traduzido por Acervo Lima. O original pode ser acessado aqui.

Licença

0 comentários:

Postar um comentário