Les traits avancés en Rust
À la page Traits, vous avez appris à définir un trait et à l’implémenter. Aux pages Génériques et Traits courants, vous avez utilisé des traits comme Display, From et Add. Cela suffit pour la plupart du code de tous les jours. Mais en lisant la bibliothèque standard, ou une crate populaire, vous croiserez des fonctionnalités des traits qui paraissent un peu étranges au début : type Item, Add<Rhs = Self>, trait Pretty: Display, impl<T: Display> ... for T.
Cette page explique ces fonctionnalités une par une. Aucune n’est magique. Chacune résout un petit problème bien réel, et une fois que vous connaissez le problème, la syntaxe devient logique. Prenez votre temps : vous pouvez très bien lire une partie, essayer le code, et revenir plus tard.
Les types associés : une seule réponse par type
Section intitulée « Les types associés : une seule réponse par type »Vous avez déjà rencontré un type associé : Iterator a un type Item. Un type associé est un type « à remplir » à l’intérieur d’un trait. Chaque implémentation le remplit une fois pour toutes.
Voici un petit trait Container. Une playlist contient des String et une liste de scores contient des u32, donc chacune choisit son propre Item :
use std::fmt::Debug;
trait Container { type Item;
fn get(&self, index: usize) -> Option<&Self::Item>; fn size(&self) -> usize;
fn first(&self) -> Option<&Self::Item> { self.get(0) }}
struct Playlist { songs: Vec<String>,}
struct Scores { values: Vec<u32>,}
impl Container for Playlist { type Item = String;
fn get(&self, index: usize) -> Option<&String> { self.songs.get(index) } fn size(&self) -> usize { self.songs.len() }}
impl Container for Scores { type Item = u32;
fn get(&self, index: usize) -> Option<&u32> { self.values.get(index) } fn size(&self) -> usize { self.values.len() }}
// Marche avec n'importe quel Container dont les éléments s'affichent avec {:?}fn describe<C: Container>(c: &C)where C::Item: Debug,{ println!("{} éléments, le premier est {:?}", c.size(), c.first());}
fn main() { let playlist = Playlist { songs: vec![String::from("Intro"), String::from("Outro")] }; let scores = Scores { values: vec![90, 72, 85] };
describe(&playlist); describe(&scores);}Sortie :
2 éléments, le premier est Some("Intro")3 éléments, le premier est Some(90)À l’intérieur du trait, Self::Item veut dire « le type, quel qu’il soit, que cette implémentation a choisi ». Dans describe, C::Item veut dire la même chose pour le type C. (La ligne where est simplement un endroit plus lisible pour écrire la contrainte ; on y revient à la fin de cette page.)
Pourquoi pas plutôt un paramètre générique ? Vous pourriez écrire trait Container<T>. Mais alors un même type pourrait l’implémenter plusieurs fois, Container<String> et Container<u32>, et chaque appelant devrait préciser lequel il veut. Pour une playlist, ça n’a aucun sens : elle contient des chansons, point final.
C’est exactement pour ça qu’Iterator utilise type Item. Un itérateur sur un Vec<i32> produit des i32 et rien d’autre, donc for x in v.iter() n’a jamais besoin d’annotation de type. Une règle simple pour s’y retrouver :
- Une seule réponse naturelle par type → type associé (
Iterator::Item,Add::Output). - Plusieurs réponses valables par type → paramètre générique (
From<T>: uneStringpeut se construire à partir d’un&str, d’unchar, d’uneBox<str>, …).
Les paramètres de type par défaut et les opérateurs
Section intitulée « Les paramètres de type par défaut et les opérateurs »À la page Traits courants, vous avez fait marcher + pour un Point en implémentant Add. Voici comment Add est réellement défini dans la bibliothèque standard :
trait Add<Rhs = Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output;}Deux fonctionnalités se rencontrent ici. Output est un type associé (ce que produit le +). Et Rhs = Self est un paramètre de type par défaut : « le côté droit est du même type que le côté gauche, sauf si vous dites le contraire ». Quand vous écrivez impl Add for Point, vous obtenez la valeur par défaut. Quand vous voulez additionner un type différent, vous écrivez impl Add<AutreType> for Point.
use std::ops::Add;
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Centimeters(f64);
// Utilise la valeur par défaut Rhs = Self : c'est donc Meters + Metersimpl Add for Meters { type Output = Meters; fn add(self, other: Meters) -> Meters { Meters(self.0 + other.0) }}
// Remplace la valeur par défaut : Meters + Centimetersimpl Add<Centimeters> for Meters { type Output = Meters; fn add(self, other: Centimeters) -> Meters { Meters(self.0 + other.0 / 100.0) }}
fn main() { let a = Meters(2.0); let b = Meters(3.5); let c = Centimeters(50.0);
println!("{:?}", a + b); println!("{:?}", a + c);}Sortie :
Meters(5.5)Meters(2.5)Remarquez qu’Add utilise les deux outils : un paramètre générique pour le côté droit (plusieurs réponses valables : vous pouvez ajouter des Meters ou des Centimeters) et un type associé pour le résultat (une seule réponse pour chaque paire). Les paramètres de type par défaut servent surtout quand vous concevez un trait : ils gardent le cas courant concis, sans empêcher le cas rare.
Les supertraits : « vous devez aussi implémenter celui-ci »
Section intitulée « Les supertraits : « vous devez aussi implémenter celui-ci » »Parfois, votre trait n’a de sens que si le type sait déjà faire autre chose. Un supertrait dit « pour implémenter mon trait, vous devez aussi implémenter celui-là ». On l’écrit avec deux-points, comme une contrainte :
use std::fmt;
// Tout ce qui est Pretty doit aussi être Displaytrait Pretty: fmt::Display { fn pretty(&self) -> String { format!("*** {} ***", self) }}
struct Temperature { celsius: f64,}
impl fmt::Display for Temperature { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:.1}°C", self.celsius) }}
impl Pretty for Temperature {}
fn main() { let t = Temperature { celsius: 21.456 }; println!("{}", t); println!("{}", t.pretty());}Sortie :
21.5°C*** 21.5°C ***La méthode par défaut pretty peut utiliser {} sur self parce que le supertrait garantit que Display est là. Sans : fmt::Display, Rust refuserait ce format!, puisqu’il ne pourrait pas savoir que le type est affichable. impl Pretty for Temperature {} est vide : il dit simplement « oui, utilise la méthode par défaut ».
Des exemples réels tirés de la bibliothèque standard : Copy: Clone (tout type Copy doit aussi être Clone) et Eq: PartialEq.
Les blanket implementations : une seule impl pour beaucoup de types
Section intitulée « Les blanket implementations : une seule impl pour beaucoup de types »Une blanket implementation (implémentation générale) implémente un trait pour tous les types qui remplissent une condition, d’un seul coup. La syntaxe est impl<T: UneContrainte> MonTrait for T :
use std::fmt::Display;
trait Shout { fn shout(&self) -> String;}
// Tout type qui implémente Display obtient Shout gratuitementimpl<T: Display> Shout for T { fn shout(&self) -> String { format!("{}!", self.to_string().to_uppercase()) }}
fn main() { println!("{}", "bonjour".shout()); println!("{}", 42.shout()); println!("{}", 'a'.shout()); println!("{}", String::from("rust, c'est génial").shout());}Sortie :
BONJOUR!42!A!RUST, C'EST GÉNIAL!Nous n’avons jamais écrit impl Shout for i32 ni impl Shout for char. La blanket impl les a tous couverts, y compris les types Display qui n’existent pas encore.
Vous avez déjà utilisé une blanket impl bien des fois sans le savoir. La bibliothèque standard contient (à peu près) impl<T: Display> ToString for T. C’est pour ça que tous les types qui ont Display possèdent une méthode .to_string(). Le même tour de passe-passe vous donne Into gratuitement quand vous implémentez From.
Une limite : dès qu’une blanket impl existe, vous ne pouvez pas ajouter une seconde impl, plus spécifique, pour un type qu’elle couvre déjà (par exemple un impl Shout for i32 spécial). Rust aurait deux réponses possibles, et il refuse avec une erreur « conflicting implementations » (implémentations en conflit).
La règle de l’orphelin et l’astuce du newtype
Section intitulée « La règle de l’orphelin et l’astuce du newtype »Voici une règle qui surprend tout le monde au moins une fois. Essayez de donner un joli Display à Vec<String> :
use std::fmt;
impl fmt::Display for Vec<String> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.join(", ")) }}error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:3:1 |3 | impl fmt::Display for Vec<String> { | ^^^^^^^^^^^^^^^^^^^^^^----------- | | | `Vec` is not defined in the current crate | = note: define and implement a trait or new type insteadLe compilateur explique qu’on ne peut implémenter, pour des types définis hors de la crate, que des traits définis dans la crate actuelle, et que Vec n’est pas défini ici. C’est la règle de l’orphelin (orphan rule) : vous ne pouvez implémenter un trait pour un type que si le trait ou le type est défini dans votre crate. Display appartient à la bibliothèque standard, Vec aussi : aucun des deux n’est à vous.
Pourquoi cette règle existe-t-elle ? Imaginez que deux crates dont vous dépendez aient chacune écrit leur propre impl Display for Vec<String>. Laquelle Rust devrait-il utiliser ? Il n’y a pas de bonne réponse, alors Rust rend la situation impossible.
La solution de contournement, c’est le newtype pattern (le motif « nouveau type ») : enveloppez le type étranger dans une toute petite struct à vous. Maintenant, le type est à vous, et vous pouvez lui implémenter tout ce que vous voulez :
use std::fmt;
// Une enveloppe locale autour d'un type étrangerstruct Shopping(Vec<String>);
impl fmt::Display for Shopping { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) }}
fn main() { let list = Shopping(vec![String::from("lait"), String::from("œufs"), String::from("pain")]); println!("Il vous faut : {}", list); println!("Ça fait {} articles", list.0.len());}Sortie :
Il vous faut : [lait, œufs, pain]Ça fait 3 articlesself.0 donne accès au Vec à l’intérieur. Un newtype ne coûte rien à l’exécution : en mémoire, Shopping est exactement un Vec<String>. L’inconvénient, c’est que les méthodes de Vec ne sont pas directement disponibles sur Shopping, donc vous écrivez list.0.len() (ou vous ajoutez les méthodes dont vous avez besoin). Les newtypes servent à bien plus qu’à contourner la règle de l’orphelin ; voyez la page Design patterns.
La syntaxe entièrement qualifiée : quand les noms entrent en conflit
Section intitulée « La syntaxe entièrement qualifiée : quand les noms entrent en conflit »Deux traits peuvent avoir des méthodes du même nom, et le type lui-même peut aussi en avoir une. Laquelle appelle robot.greet() ?
trait English { fn greet(&self) -> String; fn language() -> String;}
trait Spanish { fn greet(&self) -> String; fn language() -> String;}
struct Robot;
impl Robot { fn greet(&self) -> String { String::from("BIP BOUP") }}
impl English for Robot { fn greet(&self) -> String { String::from("Hello!") } fn language() -> String { String::from("anglais") }}
impl Spanish for Robot { fn greet(&self) -> String { String::from("¡Hola!") } fn language() -> String { String::from("espagnol") }}
fn main() { let r = Robot;
println!("{}", r.greet()); // la méthode propre au type l'emporte println!("{}", English::greet(&r)); // choisir un trait par son nom println!("{}", Spanish::greet(&r));
// Pas de `self` ici, donc Rust a besoin du type ET du trait : println!("{}", <Robot as English>::language()); println!("{}", <Robot as Spanish>::language());}Sortie :
BIP BOUPHello!¡Hola!anglaisespagnolTrois niveaux, du plus court au plus explicite :
r.greet(): la méthode propre au type (méthode inhérente) est choisie en premier.English::greet(&r): « la versionEnglish». Rust déduit le type à partir de&r.<Robot as English>::language(): la syntaxe entièrement qualifiée (fully qualified syntax). Vous en avez besoin pour les fonctions sansself, car il n’y a aucune valeur que Rust puisse examiner.English::language()tout seul échoue avecerror[E0790]: cannot call associated function on trait without specifying the corresponding impl type, autrement dit : impossible d’appeler une fonction associée d’un trait sans préciser le type de l’implémentation.
Vous n’en aurez pas besoin souvent. Et quand ce sera le cas, l’erreur du compilateur suggère généralement la correction exacte.
impl Trait en argument et en valeur de retour, et where
Section intitulée « impl Trait en argument et en valeur de retour, et where »Vous pouvez écrire impl Trait à deux endroits, et cela veut dire quelque chose de légèrement différent dans chaque cas :
- En argument,
fn f(x: impl Trait): c’est l’appelant qui choisit le type. C’est une façon courte d’écrire la fonction génériquefn f<T: Trait>(x: T). - En valeur de retour,
fn f() -> impl Trait: c’est la fonction qui choisit un type concret et le garde caché. L’appelant sait seulement « c’est quelque chose qui implémenteTrait».
// L'appelant choisit : n'importe quel itérateur de u32 convientfn print_all(items: impl Iterator<Item = u32>) { let all: Vec<u32> = items.collect(); println!("{:?}", all);}
// La fonction choisit : le vrai type est un long Filter<...> qu'on n'écrit jamaisfn evens_up_to(limit: u32) -> impl Iterator<Item = u32> { (0..=limit).filter(|n| n % 2 == 0)}
fn main() { print_all(vec![3, 1, 4].into_iter()); print_all(evens_up_to(10));}Sortie :
[3, 1, 4][0, 2, 4, 6, 8, 10]impl Trait en valeur de retour est particulièrement pratique avec les closures et les itérateurs. Le vrai type du résultat d’evens_up_to contient le type de la closure, et les closures n’ont pas de nom que vous pourriez écrire. impl Iterator vous évite ce problème.
Quand une fonction a plusieurs paramètres génériques avec plusieurs contraintes chacun, les chevrons deviennent encombrés. Une clause where déplace les contraintes après la signature, ce qui rend la forme de la fonction plus facile à lire :
use std::fmt::{Debug, Display};
// Encombré : fn report<T: Display, U: Debug + PartialOrd>(label: T, a: U, b: U) -> String// Plus lisible :fn report<T, U>(label: T, a: U, b: U) -> Stringwhere T: Display, U: Debug + PartialOrd,{ let bigger = if a > b { a } else { b }; format!("{} : le plus grand est {:?}", label, bigger)}
fn main() { println!("{}", report("scores", 7, 12)); println!("{}", report(String::from("noms"), "ann", "bob"));}Sortie :
scores : le plus grand est 12noms : le plus grand est "bob"Pour le compilateur, les deux formes veulent dire exactement la même chose. where est aussi l’endroit naturel pour les contraintes sur les types associés, comme C::Item: Debug dans le premier exemple de cette page.
Erreurs courantes
Section intitulée « Erreurs courantes »Implémenter un trait mais oublier son supertrait. Si vous retirez le bloc impl fmt::Display for Temperature de l’exemple sur les supertraits, vous obtenez :
error[E0277]: `Temperature` doesn't implement `std::fmt::Display` --> src/main.rs:13:17 |13 | impl Pretty for Temperature {} | ^^^^^^^^^^^ unsatisfied trait bound |note: required by a bound in `Pretty` --> src/main.rs:3:15 | 3 | trait Pretty: fmt::Display { | ^^^^^^^^^^^^ required by this bound in `Pretty`Le compilateur vous dit que Temperature n’implémente pas Display, alors que Pretty l’exige. La solution est d’implémenter aussi le supertrait (ici, Display). La note pointe directement sur le : fmt::Display qui l’impose.
Renvoyer deux types différents depuis -> impl Trait. impl Trait cache un seul type. Il ne peut pas être « parfois celui-ci, parfois celui-là » :
fn numbers(evens: bool) -> impl Iterator<Item = u32> { if evens { (0..10).filter(|n| n % 2 == 0) } else { vec![1, 3, 5].into_iter() }}error[E0308]: `if` and `else` have incompatible types --> src/main.rs:5:9 |2 | / if evens {3 | | (0..10).filter(|n| n % 2 == 0) | | ------------------------------ | | | | | | | the expected closure | | expected because of this4 | | } else {5 | | vec![1, 3, 5].into_iter() | | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Filter<Range<{integer}>, {closure@...}>`, found `IntoIter<{integer}>`6 | | } | |_____- `if` and `else` have incompatible types | = note: expected struct `Filter<std::ops::Range<{integer}>, {closure@src/main.rs:3:24: 3:27}>` found struct `std::vec::IntoIter<{integer}>`help: you could change the return type to be a boxed trait object |1 - fn numbers(evens: bool) -> impl Iterator<Item = u32> {1 + fn numbers(evens: bool) -> Box<dyn Iterator<Item = u32>> { |help: if you change the return type to expect trait objects, box the returned expressions |3 ~ Box::new((0..10).filter(|n| n % 2 == 0))4 | } else {5 ~ Box::new(vec![1, 3, 5].into_iter()) |Le message dit que le if et le else ont des types incompatibles, et propose de renvoyer un trait object dans une Box. Quand le type change vraiment à l’exécution, c’est la bonne solution : renvoyez une Box<dyn Trait>, comme le suggère le compilateur. Voyez la page Trait objects.
Récapitulatif
Section intitulée « Récapitulatif »- Un type associé (
type Item) donne une seule réponse par implémentation. Utilisez un paramètre générique quand un type peut implémenter le trait de plusieurs façons. - Les paramètres de type par défaut, comme
Add<Rhs = Self>, gardent le cas courant concis ;impl Add<Autre> for Tles remplace. - Un supertrait (
trait Pretty: Display) exige un autre trait, et permet aux méthodes par défaut de s’en servir. - Une blanket impl (
impl<T: Display> MonTrait for T) couvre d’un coup tous les types qui correspondent : c’est comme ça que fonctionne.to_string(). - La règle de l’orphelin vous empêche d’implémenter un trait étranger pour un type étranger. Enveloppez le type dans un newtype pour la contourner.
- Utilisez la syntaxe entièrement qualifiée
<Type as Trait>::fonction()quand les noms entrent en conflit ;impl Traiten valeur de retour cache un type concret ;whererend les longues contraintes lisibles.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Pourquoi Iterator utilise-t-il type Item plutôt que Iterator<T> ?
2. Que veut dire Rhs = Self dans trait Add<Rhs = Self> ?
3. Vous voulez écrire impl Display for Vec<String>. Rust refuse. Quelle est la solution habituelle ?
4. Quelle ligne appelle la version English d'une fonction sans paramètre self ?
5. Dans fn evens() -> impl Iterator<Item = u32>, qui décide du type concret de l'itérateur ?
Score : 0 / 5
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Advanced Traits — The Rust Book (en anglais) : types associés, paramètres de type par défaut, supertraits et newtype pattern, avec d’autres exemples.
- Traits — The Rust Reference (en anglais) : les règles précises des éléments de trait, des supertraits et des éléments associés.
- Orphan rules — The Rust Reference (en anglais) : la définition exacte des cas où une impl est autorisée.
impl Trait— The Rust Reference (en anglais) : en quoiimpl Traiten argument et en valeur de retour diffèrent.- Supertraits — Rust by Example (en anglais) : un court exemple exécutable de traits qui s’appuient sur d’autres traits.