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.