Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Traits avancés

Nous avons d’abord vu les traits dans la section “Définition de comportements partagés avec les traits” 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.

Définition de traits avec des 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. La définition du trait Iterator figure dans l’encart 20-13.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listing 20-13: The definition of the Iterator trait that has an associated type Item

Le type Item est un espace réservé, 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. Pour examiner les différences entre les deux, nous allons regarder une implémentation du trait Iterator sur un type nommé Compteur qui spécifie que le type Item est u32 :

Filename: 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 20-14 ?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: A hypothetical definition of the Iterator trait using generics

La différence est que lorsque on utilise les génériques, comme dans l’encart 20-14, 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 20-13 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 types associés font également partie intégrante du contrat des traits :les implémenteurs du trait doivent fournir un type pour remplacer l’espace réservé de type associé. Les types associés ont souvent un nom qui décrit la manière dont ils seront utilisés, et c’est une saine habitude que de documenter le type associé dans la documentation de l’API.

Utilisation des paramètres génériques par défaut et de 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 la surcharge d’opérateurs, dans laquelle vous personnalisez 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 20-15, 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.

Filename: 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 }
    );
}
Listing 20-15: Implementing the Add trait to overload the + operator for Point instances

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 “Implémentation de traits externes avec le motif Newtype”. Nous voulons additionner des valeurs en millimètres avec des valeurs en mètres et avoir l’implémentation de Add pour faire la conversion correctement. Nous pouvons implémenter Add sur Millimetres avec Metres étant le Rhs, comme dans l’encart 20-16.

Filename: 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))
    }
}
Listing 20-16: Implementing the Add trait on Millimeters to add Millimeters and Meters

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 :

  1. pour étendre un type sans casser le code existant ;
  2. pour permettre la personnalisation dans des cas spécifiques dont la plupart des utilisateurs n’auront pas besoin.

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.

Différenciation de méthodes portant 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 20-17 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.

Filename: 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 commandant de bord 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() {}
Listing 20-17: Two traits are defined to have a fly method and are implemented on the Human type, and a fly method is implemented on Human directly.

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 20-18.

Filename: 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 commandant de bord 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();
}
Listing 20-18: Calling fly on an instance of Human

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 20-19 montre cette syntaxe.

Filename: 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 commandant de bord 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();
}
Listing 20-19: Specifying which trait’s fly method we want to call

Si l’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 20-19, 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 affichera ceci :

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
Ici le commandant de bord 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 à moins que vous utilisiez la syntaxe totalement définie. Par exemple, dans l’encart 20-20, nous créons un trait pour un refuge pour animaux qui souhaite que tous leurs chiots soient nommés Spot. Nous créons un trait Animal avec une fonction associée nom_bebe qui n’est pas une méthode. Le trait Animal est implémenté pour la structure Chien, sur laquelle nous fournissons aussi directement une fonction associée nom_bebe qui n’est pas une méthode.

Filename: 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());
}
Listing 20-20: A trait with an associated function and a type with an associated function of the same name that also implements the trait

Nous implémentons le code pour nommer tous les chiots Spot dans la fonction associée nom_bebe qui est définie sur 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 s’appellent 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 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` profile [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 souhaitions. Nous voulons appeler la fonction nom_bebe qui fait partie du trait Animal que nous avons implémentée 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 dans l’encart 20-19 ne va pas nous aider ici ; si nous changeons le main par le code de l’encart 20-21, nous allons avoir une erreur de compilation.

Filename: 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());
}
Listing 20-21: Attempting to call the baby_name function from the Animal trait, but Rust doesn’t know which implementation to use

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[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
 2 |     fn nom_bebe() -> String;
   |     ------------------------ `Animal::nom_bebe` defined here
...
20 |     println!("Un bébé chien s'appelle un {}", Animal::nom_bebe());
   |                                               ^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("Un bébé chien s'appelle un {}", <Chien as Animal>::nom_bebe());
   |                                               +++++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 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 20-22 montre comment utiliser la syntaxe totalement définie.

Filename: 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());
}
Listing 20-22: Using fully qualified syntax to specify that we want to call the baby_name function from the Animal trait as implemented on Dog

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` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
Un bébé chien s'appelle un chiot

D’une 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.

Utilisation des supertraits

Il peut arriver que vous écriviez une définition d’un trait qui dépende d’un autre trait : pour qu’un type implémente le premier trait, vous souhaitez exiger que ce type implémente aussi le second trait. Vous procédez ainsi de manière à ce que votre définition de trait puisse utiliser les éléments associés du second trait. Le trait dont dépend votre définition de trait s’appelle un supertrait de votre trait.

Par exemple, imaginons que nous souhaitions créer un trait OutlinePrint avec une méthode outline_print qui va afficher une valeur donnée entourée d’astérisques. Ainsi, pour une structure Point qui implémente le trait Display de la bibliothèque standard 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 la méthode 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 20-23 montre une implémentation du trait OutlinePrint.

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let sortie = self.to_string();
        let largeur = sortie.len();
        println!("{}", "*".repeat(largeur + 4));
        println!("*{}*", " ".repeat(largeur + 2));
        println!("* {} *", sortie);
        println!("*{}*", " ".repeat(largeur + 2));
        println!("{}", "*".repeat(largeur + 4));
    }
}

fn main() {}
Listing 20-23: Implementing the OutlinePrint trait that requires the functionality from 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 :

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let sortie = self.to_string();
        let largeur = sortie.len();
        println!("{}", "*".repeat(largeur + 4));
        println!("*{}*", " ".repeat(largeur + 2));
        println!("* {} *", sortie);
        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:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
 4 |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

Pour régler cela, nous implémentons Display sur Point afin de répondre aux besoins de OutlinePrint, comme ceci :

Filename: src/main.rs
trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let sortie = self.to_string();
        let largeur = sortie.len();
        println!("{}", "*".repeat(largeur + 4));
        println!("*{}*", " ".repeat(largeur + 2));
        println!("* {} *", sortie);
        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.

Implémentation de traits externes avec le motif Newtype

Dans la section “Implémentation d’un trait sur un type” du chapitre 10, nous avions mentionné la règle de l’orphelin qui énonçait que nous pouvions seulement implémenter un trait sur un type si le trait ou le type, ou bien les deux, sont locaux à 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 “Création de différents types avec les structures tuples” 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 20-24.

Filename: 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}");
}
Listing 20-24: Creating a Wrapper type around Vec<String> to implement 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 trait Display sur Enveloppe.

L’inconvénient 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 sur Enveloppe pour retourner le type interne pourrait être une solution (nous avons vu dans la section “Considérer les pointeurs intelligents comme des références classiques”). 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.

Ce motif newtype est aussi 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.