Les traits avancés
Nous avons vu les traits dans une section du chapitre 10, mais nous n'avons pas abordé certains détails plus avancés. Maintenant que vous en savez plus sur Rust, nous pouvons attaquer les choses sérieuses.
Placer des types à remplacer dans les définitions des traits grâce aux types associés
Les types associés connectent un type à remplacer avec un trait afin que la définition des méthodes puisse utiliser ces types à remplacer dans leur signature. Celui qui implémente un trait doit renseigner un type concret pour être utilisé à la place du type à remplacer pour cette implémentation précise. Ainsi, nous pouvons définir un trait qui utilise certains types sans avoir besoin de savoir exactement quels sont ces types jusqu'à ce que ce trait soit implémenté.
Nous avions dit que vous auriez rarement besoin de la plupart des fonctionnalités avancées de ce chapitre. Les types associés sont un entre-deux : ils sont utilisés plus rarement que les fonctionnalités expliquées dans le reste de ce livre, mais on les rencontre plus fréquemment que la plupart des autres fonctionnalités présentées dans ce chapitre.
Un exemple de trait avec un type associé est le trait Iterator que fournit la
bibliothèque standard. Le type associé Item permet de renseigner le type des
valeurs que le type qui implémente le trait Iterator parcourt. Dans une
section du chapitre 13, nous avions mentionné que la définition du trait
Iterator ressemblait à cet encart 19-12.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Encart 19-12 : la définition du trait Iterator qui a un
type Item associé
Le type Item est un type à remplacer, et la définition de la méthode next
informe qu'elle va retourner des valeurs du type Option<Self::Item>. Ceux qui
implémenterons le trait Iterator devront renseigner un type concret pour
Item, et la méthode next va retourner une Option qui contiendra une
valeur de ce type concret.
Les types associés ressemblent au même concept que les génériques, car ces derniers nous permettent de définir une fonction sans avoir à renseigner les types avec lesquels elle travaille. Donc pourquoi utiliser les types associés ?
Examinons les différences entre les deux concepts grâce à un exemple du
chapitre 13 qui implémente le trait Iterator sur la structure Compteur.
Dans l'encart 13-21, nous avions renseigné que le type Item était u32 :
Fichier : src/lib.rs
struct Compteur {
compteur: u32,
}
impl Compteur {
fn new() -> Compteur {
Compteur { compteur: 0 }
}
}
impl Iterator for Compteur {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// -- partie masquée ici --
if self.compteur < 5 {
self.compteur += 1;
Some(self.compteur)
} else {
None
}
}
}
Cette syntaxe ressemble aux génériques. Donc pourquoi ne pas simplement définir le
trait Iterator avec les génériques, comme dans l'encart 19-13 ?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Encart 19-13 : une définition hypothétique du trait
Iterator en utilisant des génériques
La différence est que lorsque on utilise les génériques, comme dans l'encart
19-13, on doit annoter les types dans chaque implémentation ; et comme nous
pouvons aussi implémenter Iterator<String> for Compteur ou tout autre type,
nous pourrions alors avoir plusieurs implémentations de Iterator pour
Compteur. Autrement dit, lorsqu'un trait a un paramètre générique, il peut
être implémenté sur un type plusieurs fois, en changeant à chaque fois le type
concret du paramètre de type générique. Lorsque nous utilisons la méthode next
sur Compteur, nous devons appliquer une annotation de type pour indiquer
quelle implémentation de Iterator nous souhaitons utiliser.
Avec les types associés, nous n'avons pas besoin d'annoter les types car nous
ne pouvons pas implémenter un trait plusieurs fois sur un même type. Dans l'encart
19-12 qui contient la définition qui utilise les types associés, nous ne pouvons
choisir quel sera le type de Item qu'une seule fois, car il ne peut
y avoir qu'un seul impl Iterator for Compteur. Nous n'avons pas à préciser
que nous souhaitons avoir un itérateur de valeurs u32 à chaque fois que nous
faisons appel à next sur Compteur.
Les paramètres de types génériques par défaut et la surcharge d'opérateur
Lorsque nous utilisons les paramètres de types génériques, nous pouvons
renseigner un type concret par défaut pour le type générique. Cela évite de
contraindre ceux qui implémentent ce trait d'avoir à renseigner un type concret
si celui par défaut fonctionne bien. La syntaxe pour renseigner un type par
défaut pour un type générique est <TypeARemplacer=TypeConcret> lorsque nous
déclarons le type générique.
Un bon exemple d'une situation pour laquelle cette technique est utile est avec
la surcharge d'opérateurs. La surcharge d'opérateur permet de personnaliser
le comportement d'un opérateur (comme +) dans des cas particuliers.
Rust ne vous permet pas de créer vos propres opérateurs ou de surcharger des
opérateurs. Mais vous pouvez surcharger les opérations et les traits listés
dans std::ops en implémentant les traits associés à l'opérateur. Par exemple,
dans l'encart 19-14 nous surchargeons l'opérateur + pour additionner ensemble
deux instances de Point. Nous pouvons faire cela en implémentant le trait
Add sur une structure Point :
Fichier : src/main.rs
use std::ops::Add; #[derive(Debug, Copy, Clone, PartialEq)] struct Point { x: i32, y: i32, } impl Add for Point { type Output = Point; fn add(self, other: Point) -> Point { Point { x: self.x + other.x, y: self.y + other.y, } } } fn main() { assert_eq!( Point { x: 1, y: 0 } + Point { x: 2, y: 3 }, Point { x: 3, y: 3 } ); }
Encart 19-14 : implémentation du trait Add pour
surcharger l'opérateur + pour les instances de Point
La méthode add ajoute les valeurs x de deux instances de Point ainsi que
les valeurs y de deux instances de Point pour créer un nouveau Point. Le
trait Add a un type associé Output qui détermine le type retourné pour la
méthode add.
Le type générique par défaut dans ce code est dans le trait Add. Voici sa
définition :
#![allow(unused)] fn main() { trait Add<Rhs=Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output; } }
Ce code devrait vous être familier : un trait avec une méthode et un type
associé. La nouvelle partie concerne Rhs=Self : cette syntaxe s'appelle les
paramètres de types par défaut. Le paramètre de type générique Rhs
(c'est le raccourci de “Right Hand Side”) qui définit le type du paramètre
rhs dans la méthode add. Si nous ne renseignons pas de type concret pour
Rhs lorsque nous implémentons le trait Add, le type de Rhs sera par
défaut Self, qui sera le type sur lequel nous implémentons Add.
Lorsque nous avons implémenté Add sur Point, nous avons utilisé la valeur
par défaut de Rhs car nous voulions additionner deux instances de Point.
Voyons un exemple d'implémentation du trait Add dans lequel nous souhaitons
personnaliser le type Rhs plutôt que d'utiliser celui par défaut.
Nous avons deux structures, Millimetres et Metres, qui stockent des valeurs
dans différentes unités. Ce léger enrobage d'un type existant dans une autre
structure s'appelle le motif newtype, que nous décrivons plus en détail dans
la section Utiliser le motif newtype pour la sécurité et l'abstraction des
types. Nous voulons pouvoir additionner les valeurs en
millimètres avec les valeurs en mètres et appliquer l'implémentation de Add
pour pouvoir faire la conversion correctement. Nous pouvons implémenter Add
sur Millimetres avec Metres comme étant le Rhs, comme dans l'encart
19-15.
Fichier : src/lib.rs
use std::ops::Add;
struct Millimetres(u32);
struct Metres(u32);
impl Add<Metres> for Millimetres {
type Output = Millimetres;
fn add(self, other: Metres) -> Millimetres {
Millimetres(self.0 + (other.0 * 1000))
}
}
Encart 19-15 : implémentation du trait Add sur
Millimetres pour pouvoir additionner Millimetres à Metres
Pour additionner Millimetres et Metres, nous renseignons
impl Add<Metres> pour régler la valeur du paramètre de type Rhs au lieu
d'utiliser la valeur par défaut Self.
Vous utiliserez les paramètres de types par défaut dans deux principaux cas :
- Pour étendre un type sans casser le code existant
- Pour permettre la personnalisation dans des cas spécifiques que la plupart des utilisateurs n'auront pas
Le trait Add de la bibliothèque standard est un exemple du second cas :
généralement, vous additionnez deux types similaires, mais le trait Add offre
la possibilité de personnaliser cela. L'utilisation d'un paramètre de type par
défaut dans la définition du trait Add signifie que vous n'aurez pas à
renseigner de paramètre en plus la plupart du temps. Autrement dit, il n'est
pas nécessaire d'avoir recours à des assemblages de code, ce qui facilite
l'utilisation du trait.
Le premier cas est similaire au second mais dans le cas inverse : si vous souhaitez ajouter un paramètre de type à un trait existant, vous pouvez lui en donner un par défaut pour permettre l'ajout des fonctionnalités du trait sans casser l'implémentation actuelle du code.
La syntaxe totalement définie pour clarifier les appels à des méthodes qui ont le même nom
Il n'y a rien en Rust qui empêche un trait d'avoir une méthode portant le même nom qu'une autre méthode d'un autre trait, ni ne vous empêche d'implémenter ces deux traits sur un même type. Il est aussi possible d'implémenter directement une méthode avec le même nom que celle présente dans les traits sur ce type.
Lorsque nous faisons appel à des méthodes qui ont un conflit de nom, vous devez
préciser à Rust précisément celle que vous souhaitez utiliser. Imaginons le
code dans l'encart 19-16 dans lequel nous avons défini deux traits, Pilote et
Magicien, qui ont tous les deux une méthode voler. Nous
implémentons ensuite ces deux traits sur un type Humain qui a déjà lui-aussi
une méthode voler qui lui a été implémentée. Chaque méthode voler fait
quelque chose de différent.
Fichier : src/main.rs
trait Pilote { fn voler(&self); } trait Magicien { fn voler(&self); } struct Humain; impl Pilote for Humain { fn voler(&self) { println!("Ici le capitaine qui vous parle."); } } impl Magicien for Humain { fn voler(&self) { println!("Décollage !"); } } impl Humain { fn voler(&self) { println!("*agite frénétiquement ses bras*"); } } fn main() {}
Encart 19-16 : deux traits qui ont une méthode voler
et qui sont implémentés sur le type Humain, et une méthode voler est aussi
implémentée directement sur Humain
Lorsque nous utilisons voler sur une instance de Humain, le compilateur
fait appel par défaut à la méthode qui est directement implémentée sur le type,
comme le montre l'encart 19-17.
Fichier : src/main.rs
trait Pilote { fn voler(&self); } trait Magicien { fn voler(&self); } struct Humain; impl Pilote for Humain { fn voler(&self) { println!("Ici le capitaine qui vous parle."); } } impl Magicien for Humain { fn voler(&self) { println!("Décollage !"); } } impl Humain { fn voler(&self) { println!("*agite frénétiquement ses bras*"); } } fn main() { let une_personne = Humain; une_personne.voler(); }
Encart 19-17 : utilisation de voler sur une instance de
Humain
L'exécution de ce code va afficher *agite frénétiquement ses bras*, ce qui
démontre que Rust a appelé la méthode voler implémentée directement sur
Humain.
Pour faire appel aux méthodes voler des traits Pilote ou Magicien, nous
devons utiliser une syntaxe plus explicite pour préciser quelle méthode voler
nous souhaitons utiliser. L'encart 19-18 montre cette syntaxe.
Fichier : src/main.rs
trait Pilote { fn voler(&self); } trait Magicien { fn voler(&self); } struct Humain; impl Pilote for Humain { fn voler(&self) { println!("Ici le capitaine qui vous parle."); } } impl Magicien for Humain { fn voler(&self) { println!("Décollage !"); } } impl Humain { fn voler(&self) { println!("*agite frénétiquement ses bras*"); } } fn main() { let une_personne = Humain; Pilote::voler(&une_personne); Magicien::voler(&une_personne); une_personne.voler(); }
Encart 19-18 : préciser de quel trait nous souhaitons
utiliser la méthode voler
Si on renseigne le nom du trait avant le nom de la méthode, cela indique à Rust
quelle implémentation de voler nous souhaitons utiliser. Nous pouvons aussi
écrire Humain::voler(&une_personne), qui est équivalent à
une_personne.voler() que nous avons utilisé dans l'encart 19-18, mais c'est
un peu plus long à écrire si nous n'avons pas besoin de préciser les choses.
L'exécution de ce code affiche ceci :
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.46s
Running `target/debug/traits-example`
Ici le capitaine qui vous parle.
Décollage !
*agite frénétiquement ses bras*
Comme la méthode voler prend un paramètre self, si nous avions deux
types qui implémentaient chacun un des deux traits, Rust pourrait en
déduire quelle implémentation de quel trait utiliser en fonction du type
de self.
Cependant, les fonctions associées qui ne sont pas des méthodes n'ont pas de
paramètre self. Lorsqu'il y a plusieurs types ou traits qui définissent des
fonctions qui ne sont pas des méthodes et qui ont le même nom de fonction, Rust
ne peut pas toujours savoir quel type vous sous-entendez jusqu'à ce que vous
utilisiez la syntaxe totalement définie. Par exemple, le trait Animal de
l'encart 19-19 a une fonction associée nom_bebe qui n'est pas une méthode, et
le trait Animal est implémenté pour la structure Dog.Il y a aussi une
fonction associée nom_bebe qui n'est pas une méthode et qui est définie
directement sur Chien.
Fichier : src/main.rs
trait Animal { fn nom_bebe() -> String; } struct Chien; impl Chien { fn nom_bebe() -> String { String::from("Spot") } } impl Animal for Chien { fn nom_bebe() -> String { String::from("chiot") } } fn main() { println!("Un bébé chien s'appelle un {}", Chien::nom_bebe()); }
Encart 19-19 : un trait avec une fonction associée et un type avec une autre fonction associée qui porte le même nom et qui implémente aussi ce trait
Ce code a été conçu pour un refuge pour animaux qui souhaite que tous leurs chiots
soient nommés Spot, ce qui est implémenté dans la fonction associée nom_bebe
de Chien. Le type Chien implémente lui aussi le trait Animal, qui décrit
les caractéristiques que tous les animaux doivent avoir. Les bébés chiens
doivent s'appeler des chiots, et ceci est exprimé dans l'implémentation du
trait Animal sur Chien dans la fonction nom_bebe associée au trait
Animal.
Dans le main, nous faisons appel à la fonction Chien::nom_bebe, qui fait
appel à la fonction associée directement définie sur Chien. Ce code affiche
ceci :
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/traits-example`
Un bébé chien s'appelle un Spot
Ce résultat n'est pas celui que nous souhaitons. Nous voulons appeler la
fonction nom_bebe qui fait partie du trait Animal que nous avons implémenté
sur Chien afin que le code affiche Un bébé chien s'appelle un chiot. La
technique pour préciser le nom du trait que nous avons utilisée précédemment ne
va pas nous aider ici ; si nous changeons le main par le code de l'encart
19-20, nous allons avoir une erreur de compilation.
Fichier : src/main.rs
trait Animal {
fn nom_bebe() -> String;
}
struct Chien;
impl Chien {
fn nom_bebe() -> String {
String::from("Spot")
}
}
impl Animal for Chien {
fn nom_bebe() -> String {
String::from("chiot")
}
}
fn main() {
println!("Un bébé chien s'appelle un {}", Animal::nom_bebe());
}
Encart 19-20 : tentative d'appel à la fonction nom_bebe
du trait Animal, mais Rust ne sait pas quelle implémentation utiliser
Comme Animal::nom_bebe n'a pas de paramètre self, et qu'il peut y avoir
d'autres types qui implémentent le trait Animal, Rust ne peut pas savoir
quelle implémentation de Animal::nom_bebe nous souhaitons utiliser. Nous
obtenons alors cette erreur de compilation :
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0283]: type annotations needed
--> src/main.rs:20:43
|
20 | println!("Un bébé chien s'appelle un {}", Animal::nom_bebe());
| ^^^^^^^^^^^^^^^^ cannot infer type
|
= note: cannot satisfy `_: Animal`
For more information about this error, try `rustc --explain E0283`.
error: could not compile `traits-example` due to previous error
Pour expliquer à Rust que nous souhaitons utiliser l'implémentation de Animal
pour Chien et non pas l'implémentation de Animal pour d'autres types, nous
devons utiliser la syntaxe totalement définie. L'encart 19-21 montre comment
utiliser la syntaxe totalement définie.
Fichier : src/main.rs
trait Animal { fn nom_bebe() -> String; } struct Chien; impl Chien { fn nom_bebe() -> String { String::from("Spot") } } impl Animal for Chien { fn nom_bebe() -> String { String::from("chiot") } } fn main() { println!("Un bébé chien s'appelle un {}", <Chien as Animal>::nom_bebe()); }
Encart 19-21 : utilisation de la syntaxe totalement
définie pour préciser que nous souhaitons appeler la fonction nom_bebe du
trait Animal tel qu'il est implémenté sur Chien
Nous avons donné à Rust une annotation de type entre des chevrons, ce qui
indique que nous souhaitons appeler la méthode nom_bebe du trait Animal
telle qu'elle est implémentée sur Chien en indiquant que nous souhaitons traiter
le type Chien comme étant un Animal pour cet appel de fonction. Ce code va
désormais afficher ce que nous souhaitons :
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/traits-example`
Un bébé chien s'appelle un chiot
De manière générale, une syntaxe totalement définie est définie comme ceci :
<Type as Trait>::function(destinataire_si_methode, argument_suivant, ...);
Pour les fonctions associées qui ne sont pas des méthodes, il n'y a pas de
destinataire : il n'y a qu'une liste d'arguments. Vous pouvez utiliser la
syntaxe totalement définie à n'importe quel endroit où vous faites appel à des
fonctions ou des méthodes. Cependant, vous avez la possibilité de ne pas
renseigner toute partie de cette syntaxe que Rust peut déduire à partir
d'autres informations présentes dans le code. Vous avez seulement besoin
d'utiliser cette syntaxe plus verbeuse dans les cas où il y a plusieurs
implémentations qui utilisent le même nom et que Rust doit être aidé pour
identifier quelle implémentation vous souhaitez appeler.
Utiliser les supertraits pour utiliser la fonctionnalité d'un trait dans un autre trait
Des fois, vous pourriez avoir besoin d'un trait pour utiliser la fonctionnalité d'un autre trait. Dans ce cas, vous devez pouvoir compter sur le fait que le trait dépendant soit bien implémenté. Le trait sur lequel vous comptez est alors un supertrait du trait que vous implémentez.
Par exemple, imaginons que nous souhaitons créer un trait OutlinePrint qui
offre une méthode outline_print affichant une valeur entourée d'astérisques.
Ainsi, pour une structure Point qui implémente Display pour afficher (x, y),
lorsque nous faisons appel à outline_print sur une instance de Point qui a
1 pour valeur de x et 3 pour y, cela devrait afficher ceci :
**********
* *
* (1, 3) *
* *
**********
Dans l'implémentation de outline_print, nous souhaitons utiliser la
fonctionnalité du trait Display. De ce fait, nous devons indiquer que le
trait OutlinePrint fonctionnera uniquement pour les types qui auront également
implémenté Display et qui fourniront la fonctionnalité dont a besoin
OutlinePrint. Nous pouvons faire ceci dans la définition du trait en
renseignant OutlinePrint: Display. Cette technique ressemble à l'ajout d'un
trait lié au trait. L'encart 19-22 montre une implémentation du trait
OutlinePrint.
Fichier : src/main.rs
use std::fmt; trait OutlinePrint: fmt::Display { fn outline_print(&self) { let valeur = self.to_string(); let largeur = valeur.len(); println!("{}", "*".repeat(largeur + 4)); println!("*{}*", " ".repeat(largeur + 2)); println!("* {} *", valeur); println!("*{}*", " ".repeat(largeur + 2)); println!("{}", "*".repeat(largeur + 4)); } } fn main() {}
Encart 19-22 : implémentation du trait OutlinePrint qui
nécessite la fonctionnalité offerte par Display
Comme nous avons précisé que OutlinePrint nécessite le trait Display, nous
pouvons utiliser la fonction to_string qui est automatiquement implémentée
pour n'importe quel type qui implémente Display. Si nous avions essayé
d'utiliser to_string sans ajouter un double-point et en renseignant le trait
Display après le nom du trait, nous aurions alors obtenu une erreur qui nous
informerait qu'il n'y a pas de méthode to_string pour le type &Self dans la
portée courante.
Voyons ce qui ce passe lorsque nous essayons d'implémenter OutlinePrint sur
un type qui n'implémente pas Display, comme c'est le cas de la structure
Point :
Fichier : src/main.rs
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let valeur = self.to_string();
let largeur = valeur.len();
println!("{}", "*".repeat(largeur + 4));
println!("*{}*", " ".repeat(largeur + 2));
println!("* {} *", valeur);
println!("*{}*", " ".repeat(largeur + 2));
println!("{}", "*".repeat(largeur + 4));
}
}
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
fn main() {
let p = Point { x: 1, y: 3 };
p.outline_print();
}
Nous obtenons une erreur qui dit que Display est nécessaire mais n'est pas
implémenté :
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:20:6
|
20 | impl OutlinePrint for Point {}
| ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` due to previous error
Pour régler cela, nous implémentons Display sur Point afin de répondre aux
besoins de OutlinePrint, comme ceci :
Fichier : src/main.rs
trait OutlinePrint: fmt::Display { fn outline_print(&self) { let valeur = self.to_string(); let largeur = valeur.len(); println!("{}", "*".repeat(largeur + 4)); println!("*{}*", " ".repeat(largeur + 2)); println!("* {} *", valeur); println!("*{}*", " ".repeat(largeur + 2)); println!("{}", "*".repeat(largeur + 4)); } } struct Point { x: i32, y: i32, } impl OutlinePrint for Point {} use std::fmt; impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } fn main() { let p = Point { x: 1, y: 3 }; p.outline_print(); }
Ceci fait, l'implémentation du trait OutlinePrint sur Point va se
compiler avec succès, et nous pourrons appeler outline_print sur une instance
de Point pour l'afficher dans le cadre constitué d'astérisques.
Utiliser le motif newtype pour implémenter des traits externes sur des types externes
Dans une section du chapitre 10, nous avions mentionné la règle de l'orphelin qui énonçait que nous pouvions implémenter un trait sur un type à condition que le trait ou le type soit local à notre crate. Il est possible de contourner cette restriction en utilisant le motif newtype, ce qui implique de créer un nouveau type dans une structure tuple (nous avons vu les structures tuple dans la section “Utilisation de structures tuples sans champ nommé pour créer des types différents” du chapitre 5). La structure tuple aura un champ et sera une petite enveloppe pour le type sur lequel nous souhaitons implémenter le trait. Ensuite, le type enveloppant est local à notre crate, et nous pouvons lui implémenter un trait. Newtype est un terme qui provient du langage de programmation Haskell. Il n'y a pas de conséquence sur les performance à l'exécution pour l'utilisation de ce motif, ce qui signifie que le type enveloppant est résolu à la compilation.
Comme exemple, disons que nous souhaitons implémenter Display sur Vec<T>, ce
que la règle de l'orphelin nous empêche de faire directement car le trait
Display et le type Vec<T> sont définis en dehors de notre crate. Nous
pouvons construire une structure Enveloppe qui possède une instance de
Vec<T> ; et ensuite nous pouvons implémenter Display sur Enveloppe et
utiliser la valeur Vec<T>, comme dans l'encart 19-23.
Fichier : src/main.rs
use std::fmt; struct Enveloppe(Vec<String>); impl fmt::Display for Enveloppe { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) } } fn main() { let w = Enveloppe(vec![String::from("hello"), String::from("world")]); println!("w = {}", w); }
Encart 19-23 : création d'un type Enveloppe autour de
Vec<String> pour implémenter Display
L'implémentation de Display utilise self.0 pour accéder à la valeur de
Vec<T>, car Enveloppe est une structure tuple et Vec<T> est l'élément à
l'indice 0 du tuple. Ensuite, nous pouvons utiliser la fonctionnalité du type
Display sur Enveloppe.
Le désavantage d'utiliser cette technique est que Enveloppe est un nouveau
type, donc il n'implémente pas toutes les méthodes de la valeur qu'il possède.
Il faudrait implémenter toutes les méthodes de Vec<T> directement sur
Enveloppe de façon à ce qu'elles délèguent aux méthodes correspondantes de
self.0, ce qui nous permettrait d'utiliser Enveloppe exactement comme un
Vec<T>. Si nous voulions que le nouveau type ait toutes les méthodes du type
qu'il possède, l'implémentation du trait Deref (que nous avons vu dans
une section du chapitre 15) sur
Enveloppe pour retourner le type interne pourrait être une solution. Si nous
ne souhaitons pas que le type Enveloppe ait toutes les méthodes du type qu'il
possède (par exemple, pour limiter les fonctionnalités du type Enveloppe),
nous n'avons qu'à implémenter manuellement que les méthodes que nous souhaitons.
Maintenant vous savez comment le motif newtype est utilisé en lien avec les traits ; c'est aussi un motif très utile même lorsque les traits ne sont pas concernés. Changeons de sujet et découvrons d'autres techniques avancées pour interagir avec le système de type de Rust.