Pourquoi Rust a-t-il des types associés, et quelle est la différence entre eux et les arguments de type aka génériques, car ils sont si similaires? Ne suffit-il pas seulement de ce dernier, comme dans toutes les langues normales? Pour ceux qui commencent tout juste à apprendre la rouille, et surtout pour ceux qui viennent d'autres langues ("C'est générique!" - dira le javiste, sage depuis des années), une telle question se pose régulièrement. Faisons les choses correctement.
TL; DR Le premier contrôle le code appelé, le second l'appelant.
Génériques vs types associés
Donc, nous avons déjà des arguments de type, ou les génériques préférés de tout le monde. Cela ressemble à ceci:
trait Foo<T> { fn bar(self, x: T); }
Ici, T
est précisément l'argument type. Il semble que cela devrait être suffisant pour tout le monde (comme 640 kilo-octets de mémoire). Mais dans Rust, il existe également des types associés, quelque chose comme ceci:
trait Foo { type Bar;
À première vue, les mêmes œufs, mais sous un angle différent. Pourquoi avez-vous dû introduire une autre entité dans la langue? (Ce qui, soit dit en passant, n'était pas dans les premières versions de la langue.)
Les arguments de type sont exactement des arguments , cela signifie qu'ils sont passés au trait à l'endroit de l'appel, et le contrôle sur le type qui sera utilisé au lieu de T
appartient à l'appelant. Même si nous ne spécifions pas explicitement T
à l'emplacement de l'appel, le compilateur le fera pour nous en utilisant l'inférence de type. Autrement dit, de toute façon, ce type sera déduit de l'appelant et passé en argument. (Bien sûr, tout cela se produit pendant la compilation, pas pendant l'exécution.)
Prenons un exemple. La bibliothèque standard a un AsRef
AsRef, qui permet à un type de faire semblant d'être un autre type pendant un certain temps, convertissant un lien vers lui-même en un lien vers autre chose. Simplifié, ce trait ressemble à ceci (en réalité, c'est un peu plus compliqué, j'ai volontairement supprimé tout ce qui n'était pas nécessaire, ne laissant que le minimum nécessaire à la compréhension):
trait AsRef<T> { fn as_ref(&self) -> &T; }
Ici, le type T
passé par l'appelant comme argument, même s'il se produit implicitement (si le compilateur déduit ce type pour vous). En d'autres termes, c'est l'appelant qui décide quel nouveau type T
prétendra être notre type qui implémente ce trait:
let foo = Foo::new(); let bar: &Bar = foo.as_ref();
Ici, le compilateur, en utilisant la connaissance de bar: &Bar
, utilisera l' AsRef<Bar>
pour appeler la méthode as_ref()
, car c'est le type de Bar
requis par l'appelant. Il va sans dire que le type Foo
doit implémenter le trait AsRef AsRef<Bar>
, et en plus de cela, il peut implémenter autant d'autres AsRef<T>
, parmi lesquelles l'appelant sélectionne celle désirée.
Dans le cas du type associé, tout est exactement le contraire. Le type associé est entièrement contrôlé par ceux qui mettent en œuvre cette caractéristique, et non par l'appelant.
Un exemple courant est un itérateur. Supposons que nous ayons une collection et que nous voulons en obtenir un itérateur. Quel type de valeurs l'itérateur doit-il renvoyer? Exactement celle contenue dans cette collection! Il n'appartient pas à l'appelant de décider ce que l'itérateur retournera, et l'itérateur lui-même sait mieux ce qu'il sait exactement comment retourner. Voici le code abrégé de la bibliothèque standard:
trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; }
Notez que l'itérateur n'a pas de paramètre de type qui permet à l'appelant de choisir ce que l'itérateur doit retourner. Au lieu de cela, le type de la valeur renvoyée par la méthode next()
est déterminé par l'itérateur lui-même en utilisant le type associé, mais il n'est pas coincé avec des clous, c'est-à-dire chaque implémentation d'itérateur peut choisir son type.
Arrêter Et alors? Néanmoins, il n'est pas clair pourquoi c'est mieux qu'un générique. Imaginez un instant que nous utilisons le générique habituel au lieu du type associé. Le trait de l'itérateur ressemblera alors à ceci:
trait GenericIterator<T> { fn next(&mut self) -> Option<T>; }
Mais maintenant, premièrement, le type T
doit être indiqué encore et encore à chaque endroit où l'itérateur est mentionné, et deuxièmement, maintenant il est devenu possible d'implémenter ce trait plusieurs fois avec différents types, ce qui pour l'itérateur semble quelque peu étrange. Voici un exemple:
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();
Vous voyez le hic? Nous ne pouvons pas simplement prendre et appeler iter.next()
sans squats - nous devons faire savoir au compilateur, explicitement ou implicitement, quel type sera retourné. Et cela semble gênant: pourquoi devrions-nous, du côté de l'appel, connaître (et dire au compilateur!) Le type que l'itérateur retournera, alors que cet itérateur devrait mieux savoir quel type il retourne?! Et tout cela parce que nous avons pu implémenter le GenericIterator
GenericIterator deux fois avec un paramètre différent pour le même MyIterator
, ce qui, du point de vue de la sémantique de l'itérateur, semble également ridicule: pourquoi le même itérateur peut-il renvoyer des valeurs de différents types?
Si nous revenons à la variante avec le type associé, tous ces problèmes peuvent être évités:
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(); }
Ici, premièrement, le compilateur affichera correctement la value: Option<String>
type sans mots inutiles, et deuxièmement, cela ne fonctionnera pas pour implémenter le MyIter
Iterator
pour MyIter
deuxième fois avec un type de retour différent, et ainsi tout gâcher.
Pour la fixation. Une collection peut implémenter un tel trait afin de pouvoir se transformer en itérateur:
trait IntoIterator { type Item; type IntoIter: Iterator<Item=Self::Item>; fn into_iter(self) -> Self::IntoIter; }
Et encore une fois, c'est la collection qui décide de quel itérateur il s'agit, à savoir: un itérateur dont le type de retour correspond au type d'éléments de la collection elle-même, et aucun autre.
Plus sur les doigts
Si les exemples ci-dessus sont encore incompréhensibles, alors voici une explication encore moins scientifique mais plus intelligible. Les arguments de type peuvent être considérés comme des informations «d'entrée» que nous fournissons pour que le trait fonctionne. Les types associés peuvent être considérés comme des informations de «sortie» que le trait nous fournit afin que nous puissions utiliser les résultats de son travail.
La bibliothèque standard a la capacité de surcharger les opérateurs mathématiques pour ses types (addition, soustraction, multiplication, division, etc.). Pour ce faire, vous devez implémenter l'un des traits correspondants de la bibliothèque standard. Voici, par exemple, à quoi ressemble ce trait pour l'opération d'addition (encore une fois, simplifiée):
trait Add<RHS> { type Output; fn add(self, rhs: RHS) -> Self::Output; }
Ici, nous avons l'argument RHS
«entrée» - c'est le type auquel nous appliquerons l'opération d'addition avec notre type. Et il y a un argument "sortie" Add::Output
- c'est le type qui résultera de l'addition. Dans le cas général, cela peut différer du type de termes, qui, à leur tour, peuvent également être de types différents (ajoutez du goût au bleu et devenez doux - mais quoi, je le fais tout le temps). Le premier est spécifié à l'aide de l'argument type, le second est spécifié à l'aide du type associé.
Vous pouvez implémenter n'importe quel nombre d'additions avec différents types du deuxième argument, mais à chaque fois il n'y aura qu'un seul type de résultat, et il est déterminé par l'implémentation de cette addition.
Essayons d'implémenter ce trait:
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;
Dans cet exemple, le type de la variable y
est déterminé par l'algorithme d'addition, pas le code appelant. Il serait très étrange s'il était possible d'écrire quelque chose comme let y: Baz = x + 42
, c'est-à-dire de forcer l'opération d'addition à renvoyer un résultat d'un type étranger. C'est à partir de telles choses que le type associé Add::Output
nous assure.
Total
Nous utilisons des génériques où cela ne nous dérange pas d'avoir plusieurs implémentations de traits pour le même type, et où il est acceptable de spécifier une implémentation spécifique du côté de l'appel. Nous utilisons des types associés où nous voulons avoir une implémentation "canonique", qui contrôle elle-même les types. Combinez et mélangez dans les bonnes proportions, comme dans le dernier exemple.
La pièce a-t-elle échoué? Tuez-moi avec des commentaires.