Por que Rust tem tipos associados e qual a diferença entre eles e argumentos de tipo, também conhecidos como genéricos, porque são muito semelhantes? Não basta apenas o último, como em todas as línguas normais? Para aqueles que estão apenas começando a aprender Rust, e especialmente para pessoas que vêm de outras línguas ("Isto é genérico!" - o javista, sábio por anos, dirá), essa pergunta surge regularmente. Vamos acertar.
TL; DR O primeiro controla o código chamado, o último o chamador.
Genéricos vs tipos associados
Portanto, já temos argumentos de tipo ou genéricos favoritos de todos. Parece algo como isto:
trait Foo<T> { fn bar(self, x: T); }
Aqui T
é precisamente o argumento do tipo. Parece que isso deve ser suficiente para todos (como 640 kilobytes de memória). Mas no Rust, também existem tipos associados, algo como isto:
trait Foo { type Bar;
À primeira vista, os mesmos ovos, mas de um ângulo diferente. Por que você precisou introduzir outra entidade no idioma? (Que, a propósito, não estava nas primeiras versões do idioma.)
Argumentos de tipo são argumentos , o que significa que eles são passados para a característica no local da chamada, e o controle sobre qual tipo será usado em vez de T
pertence ao chamador. Mesmo se não especificarmos explicitamente T
no local da chamada, o compilador fará isso por nós usando a inferência de tipo. Ou seja, de qualquer maneira, implicitamente, esse tipo será inferido no chamador e passado como argumento. (Obviamente, tudo isso acontece durante a compilação, não em tempo de execução.)
Considere um exemplo. A biblioteca padrão tem uma AsRef
AsRef, que permite que um tipo finja ser outro tipo por um tempo, convertendo um link para si mesmo em um link para outra coisa. Simplificado, esse traço se parece com isso (na realidade, é um pouco mais complicado, intencionalmente removi tudo desnecessário, deixando apenas o mínimo necessário para a compreensão):
trait AsRef<T> { fn as_ref(&self) -> &T; }
Aqui, o tipo T
passado pelo chamador como argumento, mesmo que isso aconteça implicitamente (se o compilador inferir esse tipo para você). Em outras palavras, é o chamador que decide qual novo tipo T
fingirá ser o nosso tipo que implementa essa característica:
let foo = Foo::new(); let bar: &Bar = foo.as_ref();
Aqui, o compilador, usando o conhecimento de bar: &Bar
, usará a AsRef<Bar>
para chamar o método as_ref()
, porque é o tipo de Bar
exigido pelo chamador. Escusado será dizer que o tipo Foo
deve implementar a característica AsRef AsRef<Bar>
e, além disso, pode implementar tantas outras AsRef<T>
, entre as quais o chamador seleciona a desejada.
No caso do tipo associado, tudo é exatamente o oposto. O tipo associado é completamente controlado por aqueles que implementam essa característica, e não pelo chamador.
Um exemplo comum é um iterador. Suponha que temos uma coleção e queremos obter um iterador. Que tipo de valores o iterador deve retornar? Exatamente o contido nesta coleção! Não cabe ao chamador decidir o que o iterador retornará, e o próprio iterador sabe melhor o que exatamente ele sabe como retornar. Aqui está o código abreviado da biblioteca padrão:
trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; }
Observe que o iterador não possui um parâmetro de tipo que permite ao chamador escolher o que o iterador deve retornar. Em vez disso, o tipo do valor retornado do método next()
é determinado pelo próprio iterador usando o tipo associado, mas não fica preso às unhas, ou seja, cada implementação do iterador pode escolher seu tipo.
Parar E daí? Mesmo assim, não está claro por que isso é melhor que os genéricos. Imagine por um momento que usamos o genérico usual em vez do tipo associado. A característica do iterador será mais ou menos assim:
trait GenericIterator<T> { fn next(&mut self) -> Option<T>; }
Mas agora, primeiro, o tipo T
precisa ser indicado repetidamente em todos os lugares em que o iterador é mencionado e, em segundo lugar, agora tornou-se possível implementar essa característica várias vezes com tipos diferentes, o que para o iterador parece estranho. Aqui está um exemplo:
struct MyIterator; impl GenericIterator<i32> for MyIterator { fn next(&mut self) -> Option<i32> { unimplemented!() } } impl GenericIterator<String> for MyIterator { fn next(&mut self) -> Option<String> { unimplemented!() } } fn test() { let mut iter = MyIterator; let lolwhat: Option<_> = iter.next();
Veja o problema? Não podemos simplesmente pegar e chamar iter.next()
sem agachamento - precisamos informar ao compilador, explícita ou implicitamente, que tipo será retornado. E parece estranho: por que deveríamos, no lado da chamada, saber (e informar ao compilador!) O tipo que o iterador retornará, enquanto esse iterador deve saber melhor que tipo ele retorna ?! E tudo porque conseguimos implementar a GenericIterator
GenericIterator duas vezes com um parâmetro diferente para o mesmo MyIterator
, que, do ponto de vista da semântica do iterador, também parece ridículo: por que o mesmo iterador pode retornar valores de tipos diferentes?
Se retornarmos à variante com o tipo associado, todos esses problemas poderão ser evitados:
struct MyIter; impl Iterator for MyIter { type Item = String; fn next(&mut self) -> Option<Self::Item> { unimplemented!() } } fn test() { let mut iter = MyIter; let value = iter.next(); }
Aqui, em primeiro lugar, o compilador emitirá corretamente o value: Option<String>
type sem palavras desnecessárias e, em segundo lugar, não funcionará para implementar a MyIter
Iterator
para MyIter
segunda vez com um tipo de retorno diferente, e assim arruinar tudo.
Para fixação. Uma coleção pode implementar essa característica para poder se transformar em um iterador:
trait IntoIterator { type Item; type IntoIter: Iterator<Item=Self::Item>; fn into_iter(self) -> Self::IntoIter; }
E, novamente, aqui é a coleção que decide o que o iterador será, a saber: um iterador cujo tipo de retorno corresponde ao tipo de elementos na própria coleção e nenhum outro.
Mais nos dedos
Se os exemplos acima ainda são incompreensíveis, então aqui está uma explicação ainda menos científica, mas mais inteligível. Os argumentos de tipo podem ser considerados informações de "entrada" que fornecemos para que a característica funcione. Tipos associados podem ser considerados informações de "saída" fornecidas pela característica, para que possamos usar os resultados de seu trabalho.
A biblioteca padrão tem a capacidade de sobrecarregar operadores matemáticos para seus tipos (adição, subtração, multiplicação, divisão e similares). Para fazer isso, você precisa implementar uma das características correspondentes da biblioteca padrão. Aqui, por exemplo, como essa característica procura a operação de adição (novamente, simplificada):
trait Add<RHS> { type Output; fn add(self, rhs: RHS) -> Self::Output; }
Aqui temos o argumento RHS
"input" - este é o tipo ao qual aplicaremos a operação de adição com o nosso tipo. E existe um argumento de "saída" Add::Output
- este é o tipo que resultará da adição. No caso geral, pode ser diferente do tipo de termos, que, por sua vez, também podem ser de tipos diferentes (adicione sabor ao azul e suavize - mas o que, eu faço isso o tempo todo). O primeiro é especificado usando o argumento type, o segundo é especificado usando o tipo associado.
Você pode implementar qualquer número de adições com tipos diferentes do segundo argumento, mas cada vez haverá apenas um tipo de resultado, e isso é determinado pela implementação dessa adição.
Vamos tentar implementar esta característica:
use std::ops::Add; struct Foo(&'static str); #[derive(PartialEq, Debug)] struct Bar(&'static str, i32); impl Add<i32> for Foo { type Output = Bar; fn add(self, rhs: i32) -> Bar { Bar(self.0, rhs) } } fn test() { let x = Foo("test"); let y = x + 42;
Neste exemplo, o tipo da variável y
é determinado pelo algoritmo de adição, não pelo código de chamada. Seria muito estranho se fosse possível escrever algo como let y: Baz = x + 42
, ou seja, force a operação de adição a retornar um resultado de algum tipo estranho. É dessas coisas que o tipo associado Add::Output
nos assegura.
Total
Usamos genéricos onde não nos importamos de ter várias implementações de características para o mesmo tipo e onde é aceitável especificar uma implementação específica no lado da chamada. Usamos tipos associados nos quais queremos ter uma implementação "canônica", que controla os tipos. Combine e misture nas proporções corretas, como no último exemplo.
A moeda falhou? Mate-me com comentários.