En los dedos: tipos asociados en Rust y cuál es su diferencia con los argumentos de tipo

¿Por qué Rust tiene tipos asociados y cuál es la diferencia entre ellos y los argumentos de tipo, también conocidos como genéricos, porque son muy similares? ¿No es suficiente lo último, como en todos los idiomas normales? Para aquellos que recién están comenzando a aprender Rust, y especialmente para las personas que vienen de otros idiomas ("¡Esto es genérico!", Dirá el javista, sabio durante años), esta pregunta surge regularmente. Vamos a hacerlo bien.


TL; DR El primero controla el código llamado, el segundo la persona que llama.


Genéricos vs tipos asociados


Entonces, ya tenemos argumentos de tipo, o los genéricos favoritos de todos. Se parece a esto:


trait Foo<T> { fn bar(self, x: T); } 

Aquí T es precisamente el argumento tipo. Parece que esto debería ser suficiente para todos (como 640 kilobytes de memoria). Pero en Rust, también hay tipos asociados, algo como esto:


 trait Foo { type Bar; //    fn bar(self, x: Self::Bar); } 

A primera vista, los mismos huevos, pero desde un ángulo diferente. ¿Por qué necesitabas introducir otra entidad en el idioma? (Que, por cierto, no estaba en las primeras versiones del lenguaje).


Los argumentos de tipo son exactamente argumentos , esto significa que se pasan al rasgo en el lugar de la llamada, y el control sobre qué tipo se utilizará en lugar de T pertenece a la persona que llama. Incluso si no especificamos explícitamente T en la ubicación de la llamada, el compilador lo hará por nosotros mediante la inferencia de tipos. Es decir, implícitamente, de todos modos, este tipo se inferirá en la persona que llama y se pasará como un argumento. (Por supuesto, todo esto sucede durante la compilación, no en tiempo de ejecución).


Considera un ejemplo. La biblioteca estándar tiene un AsRef AsRef, que permite que un tipo finja ser otro tipo por un tiempo, convirtiendo un enlace en sí mismo en un enlace a otra cosa. Simplificado, este rasgo se ve así (en realidad, es un poco más complicado, intencionalmente eliminé todo lo innecesario, dejando solo el mínimo necesario para la comprensión):


 trait AsRef<T> { fn as_ref(&self) -> &T; } 

Aquí el llamador pasa el tipo T como argumento, incluso si sucede implícitamente (si el compilador le infiere este tipo). En otras palabras, es la persona que llama quien decide qué nuevo tipo T pretenderá ser nuestro tipo el que implementa este rasgo:


 let foo = Foo::new(); let bar: &Bar = foo.as_ref(); 

Aquí, el compilador, utilizando el conocimiento de bar: &Bar , utilizará la AsRef<Bar> para llamar al método as_ref() , porque es el tipo de Bar que necesita el llamante. No hace falta decir que el tipo Foo debe implementar el rasgo AsRef AsRef<Bar> , y además de esto, puede implementar tantas otras AsRef<T> , entre las cuales la persona que llama selecciona la deseada.


En el caso del tipo asociado, todo es exactamente lo contrario. El tipo asociado está completamente controlado por quienes implementan este rasgo, y no por la persona que llama.


Un ejemplo común es un iterador. Supongamos que tenemos una colección y queremos obtener un iterador de ella. ¿Qué tipo de valores debe devolver el iterador? ¡Exactamente el contenido en esta colección! No corresponde a la persona que llama decidir qué devolverá el iterador, y el iterador mismo sabe mejor qué sabe exactamente cómo devolver. Aquí está el código abreviado de la biblioteca estándar:


 trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } 

Tenga en cuenta que el iterador no tiene un parámetro de tipo que le permita a la persona que llama elegir qué debe devolver el iterador. En cambio, el tipo del valor devuelto por el método next() lo determina el iterador mismo usando el tipo asociado, pero no está atascado con clavos, es decir. cada implementación de iterador puede elegir su tipo.


Para ¿Y qué? De todos modos, no está claro por qué esto es mejor que un genérico. Imagine por un momento que usamos el genérico habitual en lugar del tipo asociado. El rasgo del iterador se verá así:


 trait GenericIterator<T> { fn next(&mut self) -> Option<T>; } 

Pero ahora, en primer lugar, el tipo T debe indicarse una y otra vez en cada lugar donde se menciona el iterador, y en segundo lugar, ahora es posible implementar este rasgo varias veces con diferentes tipos, lo que para el iterador parece algo extraño. Aquí hay un ejemplo:


 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(); // Error! Which impl of GenericIterator to use? } 

¿Ves la captura? No podemos simplemente tomarlo y llamarlo iter.next() sin sentadillas: debemos informar al compilador, explícita o implícitamente, qué tipo se devolverá. Y parece incómodo: ¿por qué deberíamos, en el lado de la llamada, saber (y decirle al compilador!) El tipo que devolverá el iterador, mientras que este iterador debería saber mejor qué tipo devuelve. Y todo porque pudimos implementar el GenericIterator GenericIterator dos veces con un parámetro diferente para el mismo MyIterator , que desde el punto de vista de la semántica del iterador también parece ridículo: ¿por qué el mismo iterador puede devolver valores de diferentes tipos?


Si volvemos a la variante con el tipo asociado, entonces se pueden evitar todos estos problemas:


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

Aquí, en primer lugar, el compilador generará correctamente el value: Option<String> tipo sin palabras innecesarias, y en segundo lugar, no funcionará para implementar el MyIter para MyIter por segunda vez con un tipo de retorno diferente, y así arruinar todo.


Para la fijación Una colección puede implementar dicho rasgo para poder convertirse en un iterador:


 trait IntoIterator { type Item; type IntoIter: Iterator<Item=Self::Item>; fn into_iter(self) -> Self::IntoIter; } 

Y de nuevo, aquí es la colección la que decide qué iterador será, a saber: un iterador cuyo tipo de retorno coincide con el tipo de elementos en la colección en sí, y ningún otro.


Más en los dedos


Si los ejemplos anteriores siguen siendo incomprensibles, entonces aquí hay una explicación aún menos científica pero más inteligible. Los argumentos de tipo se pueden considerar como información de "entrada" que proporcionamos para que el rasgo funcione. Los tipos asociados se pueden considerar como información de "salida" que el rasgo nos proporciona para que podamos usar los resultados de su trabajo.


La biblioteca estándar tiene la capacidad de sobrecargar operadores matemáticos para sus tipos (suma, resta, multiplicación, división y similares). Para hacer esto, debe implementar uno de los rasgos correspondientes de la biblioteca estándar. Aquí, por ejemplo, cómo este rasgo busca la operación de suma (nuevamente, simplificado):


 trait Add<RHS> { type Output; fn add(self, rhs: RHS) -> Self::Output; } 

Aquí tenemos el argumento RHS "entrada": este es el tipo al que aplicaremos la operación de suma con nuestro tipo. Y hay un argumento de "salida" Add::Output : este es el tipo que resultará de la adición. En el caso general, puede diferir del tipo de términos, que, a su vez, también pueden ser de diferentes tipos (agregue sabroso al azul y se ablande, pero qué, hago esto todo el tiempo). El primero se especifica usando el argumento type, el segundo se especifica usando el tipo asociado.


Puede implementar cualquier cantidad de adiciones con diferentes tipos del segundo argumento, pero cada vez solo habrá un tipo de resultado, y está determinado por la implementación de esta adición.


Intentemos implementar este rasgo:


 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; //      <Foo as Add>::add(42)  x assert_eq!(y, Bar("test", 42)); } 

En este ejemplo, el tipo de la variable y está determinado por el algoritmo de adición, no por el código de llamada. Sería muy extraño si fuera posible escribir algo como let y: Baz = x + 42 , es decir, forzar la operación de suma para devolver un resultado de algún tipo extraño. Es de tales cosas que el tipo asociado Add::Output nos asegura.


Total


Usamos genéricos donde no nos importa tener implementaciones de rasgos múltiples para el mismo tipo, y donde es aceptable especificar una implementación específica en el lado de la llamada. Usamos tipos asociados donde queremos tener una implementación "canónica", que controla los tipos. Combina y mezcla en las proporciones correctas, como en el último ejemplo.


¿Falló la moneda? Mátame con comentarios.

Source: https://habr.com/ru/post/441444/


All Articles