Tipos de Dados Genéricos
Usamos genéricos para criar definições de itens como assinaturas de função ou structs, que depois podemos usar com muitos tipos concretos de dados. Vamos primeiro ver como definir funções, structs, enums e métodos usando genéricos. Depois, 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 do valor de retorno. Fazer isso torna nosso código mais flexível e oferece mais funcionalidade para quem chama a função, evitando duplicação de código.
Continuando com nossa função largest, a Listagem 10-4 mostra duas funções que
ambas encontram o maior valor em uma fatia. Em seguida, vamos combiná-las em
uma única função usando genéricos.
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
assert_eq!(*result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
assert_eq!(*result, 'y');
}
A função largest_i32 é aquela que extraímos na Listagem 10-3, que encontra o
maior i32 em uma fatia. A função largest_char encontra o maior char em
uma fatia. Os corpos das duas funções têm o mesmo código, então vamos eliminar
a duplicação introduzindo um parâmetro de tipo genérico em uma única função.
Para parametrizar os tipos nessa nova função única, precisamos dar um nome ao
parâmetro de tipo, assim como fazemos com parâmetros de valor de uma função.
Você pode usar qualquer identificador como nome do parâmetro de tipo. Mas
vamos usar T porque, por convenção, nomes de parâmetros de tipo em Rust são
curtos, geralmente com apenas uma letra, e a convenção de nomenclatura de tipos
em Rust é UpperCamelCase. Como abreviação de type, T é a escolha padrão da
maioria dos programadores Rust.
Quando usamos um parâmetro no corpo da função, precisamos declarar seu nome na
assinatura para que o compilador saiba o que ele significa. Da mesma forma,
quando usamos um nome de parâmetro de tipo em uma assinatura de função,
precisamos declarar esse nome antes de usá-lo. Para definir a função genérica
largest, colocamos as declarações de nome de tipo dentro de colchetes
angulares, <>, entre o nome da função e a lista de parâmetros, assim:
fn largest<T>(list: &[T]) -> &T {
Lemos essa definição como: “A função largest é genérica sobre algum tipo
T.” Essa função tem um parâmetro chamado list, que é uma fatia de valores
do tipo T. A função largest retornará uma referência a um valor do mesmo
tipo T.
A Listagem 10-5 mostra a definição combinada da função largest usando o tipo
de dado genérico em sua assinatura. A listagem também mostra como podemos
chamar a função tanto com uma fatia de valores i32 quanto com valores
char. Observe que esse código ainda não compila.
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
largest usando parâmetros de tipo genérico; isso ainda não compilaSe compilarmos esse 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 > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
O texto de ajuda menciona std::cmp::PartialOrd, que é uma trait, e vamos
falar sobre traits na próxima seção. Por enquanto, basta saber que esse erro
afirma que o corpo de largest não funciona para todos os tipos possíveis que
T poderia representar. Como queremos comparar valores do tipo T dentro do
corpo, só podemos usar tipos cujos valores possam ser ordenados. Para
habilitar comparações, a biblioteca padrão fornece a trait
std::cmp::PartialOrd, que você pode implementar em tipos. Veja o Apêndice C
para mais informações sobre essa trait. Para corrigir a Listagem 10-5, podemos
seguir a sugestão do texto de ajuda e restringir os tipos válidos para T
apenas àqueles que implementam PartialOrd. A listagem então compilará,
porque a biblioteca padrão implementa PartialOrd tanto em i32 quanto em
char.
Em Definições de Struct
Também podemos definir structs para usar um parâmetro de tipo genérico em um ou
mais campos usando a sintaxe <>. A Listagem 10-6 define uma struct
Point<T> para armazenar valores de coordenadas x e y de qualquer tipo.
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 };
}
Point<T> que armazena valores x e y do tipo TA sintaxe para usar genéricos em definições de struct é parecida com a 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 struct. Depois, usamos o tipo genérico na definição da struct onde, de outra forma, especificaríamos tipos concretos.
Observe que, como usamos apenas um tipo genérico para definir Point<T>, essa
definição diz que a struct Point<T> é genérica sobre algum tipo T, e que
os campos x e y são ambos desse mesmo tipo, seja ele qual for. Se
criarmos uma instância de Point<T> com valores de tipos diferentes, como na
Listagem 10-7, nosso código não compilará.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
x e y precisam ter o mesmo tipo porque ambos usam o mesmo tipo de dado genérico TNeste exemplo, quando atribuímos o valor inteiro 5 a x, deixamos o
compilador saber que o tipo genérico T será um inteiro para essa instância
de Point<T>. Depois, quando especificamos 4.0 para y, que definimos como
tendo o mesmo tipo de x, receberemos um erro de incompatibilidade de tipos
assim:
$ 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
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Para definir uma struct Point em que x e y sejam genéricos, mas possam
ter tipos diferentes, podemos usar múltiplos parâmetros de tipo genérico. Por
exemplo, na Listagem 10-8, alteramos a definição de Point para ser genérica
sobre os tipos T e U, em que x é do tipo T e y é do tipo U.
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 };
}
Point<T, U> genérico sobre dois tipos, para que x e y possam ter valores de tipos diferentesAgora todas as instâncias de Point mostradas 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 o código difícil de ler. Se você perceber que precisa de
muitos tipos genéricos no seu código, isso pode indicar que ele precisa ser
reestruturado em partes menores.
Em Definições de Enum
Assim como fizemos com structs, podemos definir enums para armazenar tipos de
dado genéricos em suas variantes. Vamos dar outra olhada no enum Option<T>
fornecido pela biblioteca padrão, que usamos no Capítulo 6:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Essa definição agora deve fazer mais sentido para você. Como pode ver, o enum
Option<T> é genérico sobre o tipo T e tem duas variantes: Some, que
contém um valor do tipo T, e None, que não contém valor algum. Usando o
enum Option<T>, podemos expressar o conceito abstrato de um valor opcional,
e, como Option<T> é 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 sobre dois tipos, T e E, e tem duas variantes:
Ok, que contém um valor do tipo T, e Err, que contém um valor do tipo
E. Essa definição torna conveniente usar o enum Result em qualquer lugar em
que temos uma operação que pode ter sucesso, retornando um valor de algum tipo
T, ou falhar, retornando um erro de algum tipo E. Na verdade, foi isso que
usamos para abrir um arquivo na Listagem 9-3, em que 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.
Quando você reconhece situações no código com múltiplas definições de structs ou enums que diferem apenas nos tipos de valores que armazenam, pode evitar 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
struct Point<T> que definimos na Listagem 10-6 com um método chamado x
implementado nela.
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());
}
x na struct Point<T> que retorna uma referência ao campo x do tipo TAqui, definimos um método chamado x em Point<T> que retorna uma referência
aos dados no campo x.
Observe que precisamos declarar T logo após impl para que possamos usar
T e especificar que estamos implementando métodos no tipo Point<T>. Ao
declarar T como tipo genérico depois de impl, o Rust consegue identificar
que o tipo entre colchetes angulares em Point é um tipo genérico e não um
tipo concreto. Poderíamos ter escolhido um nome diferente para esse parâmetro
genérico em relação ao declarado na definição da struct, mas usar o mesmo nome
é convencional. Se você escrever um método dentro de um impl que declare um
tipo genérico, esse método será definido em qualquer instância do tipo, não
importa qual tipo concreto acabe substituindo o tipo genérico.
Também podemos especificar restrições sobre tipos genéricos ao definir métodos
no tipo. Poderíamos, por exemplo, implementar métodos apenas em instâncias
Point<f32>, em vez de em 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.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Point<f32> {
fn distance_from_origin(&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());
}
impl que se aplica apenas a uma struct com um tipo concreto específico para o parâmetro de tipo genérico TEsse código significa que o tipo Point<f32> terá um método
distance_from_origin; outras instâncias de Point<T> em que T não seja do
tipo f32 não terão esse método definido. O método mede a distância do ponto
até a origem, nas coordenadas (0.0, 0.0), e usa operações matemáticas
disponíveis apenas para tipos de ponto flutuante.
Os parâmetros de tipo genérico em uma definição de struct nem sempre são os
mesmos que você usa nas assinaturas de métodos dessa mesma struct. A Listagem
10-11 usa os tipos genéricos X1 e Y1 para a struct Point e X2 e Y2
para a assinatura do método mixup, para deixar o exemplo mais claro. O
método cria uma nova instância de Point com o valor x do Point de self,
do tipo X1, e o valor y do Point passado como argumento, do tipo Y2.
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
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);
}
Em main, definimos um Point que tem um i32 para x, com valor 5, e um
f64 para y, com valor 10.4. A variável p2 é uma struct Point que tem
uma fatia de string para x, com valor "Hello", e um char para y, com
valor c. Chamar 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! imprimirá
p3.x = 5, p3.y = c.
O objetivo desse exemplo é demonstrar uma situação em que alguns parâmetros
genéricos são declarados em impl e outros são declarados na definição do
método. Aqui, os parâmetros genéricos X1 e Y1 são declarados após impl
porque pertencem à definição da struct. Já os parâmetros genéricos X2 e
Y2 são declarados após fn mixup porque só são relevantes para esse método.
Desempenho de Código Usando Genéricos
Você talvez esteja se perguntando se existe algum custo em tempo de execução ao usar parâmetros de tipo genérico. A boa notícia é que usar tipos genéricos não fará seu programa rodar mais lentamente do que faria com tipos concretos.
O Rust consegue isso realizando monomorfização do código que usa 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 na compilação. Nesse processo, o compilador faz o oposto das etapas que usamos para criar a função genérica da Listagem 10-5: ele observa todos os lugares em que o código genérico é chamado e gera código para os tipos concretos com os quais ele foi usado.
Vamos ver como isso funciona usando o enum genérico 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 monomorfização. Durante esse
processo, o compilador lê os valores usados nas instâncias de Option<T> e
identifica duas formas de Option<T>: uma é i32, e a outra é f64. Assim,
ele expande a definição genérica de Option<T> em duas definições
especializadas para i32 e f64, substituindo a definição genérica pelas
específicas.
A versão monomorfizada do código é parecida com a seguinte, embora o compilador use nomes diferentes dos que estamos usando aqui apenas para ilustração:
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);
}
O genérico Option<T> é substituído pelas definições específicas criadas pelo
compilador. Como o Rust compila código genérico em código que especifica o tipo
em cada instância, não pagamos custo de tempo de execução por usar genéricos.
Quando o código roda, ele se comporta exatamente como se tivéssemos duplicado
cada definição manualmente. O processo de monomorfização torna os genéricos do
Rust extremamente eficientes em tempo de execução.