Aller au contenu

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.

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)
▶ Essayez par vous-même

À 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> : une String peut se construire à partir d’un &str, d’un char, d’une Box<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 + Meters
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
// Remplace la valeur par défaut : Meters + Centimeters
impl 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)
▶ Essayez par vous-même

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 Display
trait 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 ***
▶ Essayez par vous-même

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 gratuitement
impl<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!
▶ Essayez par vous-même

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 instead

Le 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 étranger
struct 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 articles
▶ Essayez par vous-même

self.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 BOUP
Hello!
¡Hola!
anglais
espagnol
▶ Essayez par vous-même

Trois 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 version English ». 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 sans self, car il n’y a aucune valeur que Rust puisse examiner. English::language() tout seul échoue avec error[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érique fn 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émente Trait ».
// L'appelant choisit : n'importe quel itérateur de u32 convient
fn 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 jamais
fn 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]
▶ Essayez par vous-même

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) -> String
where
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 12
noms : le plus grand est "bob"
▶ Essayez par vous-même

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.

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 this
4 | | } 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.

  • 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 T les 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 Trait en valeur de retour cache un type concret ; where rend les longues contraintes lisibles.

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