Définir des comportements partagés avec les traits

Un trait décrit une fonctionnalité qu'a un type particulier et qu'il peut partager avec d'autres types, à destination du compilateur Rust. Nous pouvons utiliser les traits pour définir un comportement partagé de manière abstraite. Nous pouvons lier ces traits à un type générique pour exprimer le fait qu'il puisse être de n'importe quel type à condition qu'il ait un comportement donné.

Remarque : les traits sont similaires à ce qu'on appelle parfois les interfaces dans d'autres langages, malgré quelques différences.

Définir un trait

Le comportement d'un type s'exprime via les méthodes que nous pouvons appeler sur ce type. Différents types peuvent partager le même comportement si nous pouvons appeler les mêmes méthodes sur tous ces types. Définir un trait est une manière de regrouper des signatures de méthodes pour définir un comportement nécessaire pour accomplir un objectif.

Par exemple, imaginons que nous avons plusieurs structures qui stockent différents types et quantités de texte : une structure ArticleDePresse, qui contient un reportage dans un endroit donné et un Tweet qui peut avoir jusqu'à 280 caractères maximum et des métadonnées qui indiquent si cela est un nouveau tweet, un retweet, ou une réponse à un autre tweet.

Nous voulons construire une crate de bibliothèque agregateur pour des agrégateurs de médias qui peut afficher le résumé des données stockées dans une instance de ArticleDePresse ou de Tweet. Pour cela, il nous faut un résumé pour chaque type, et nous allons demander ce résumé en appelant la méthode resumer sur une instance. L'encart 10-12 nous montre la définition d'un trait public Resumable qui décrit ce comportement.

Fichier : src/lib.rs

pub trait Resumable {
    fn resumer(&self) -> String;
}

Encart 10-12 : un trait Resumable qui représente le comportement fourni par une méthode resumer

Ici, nous déclarons un trait en utilisant le mot-clé trait et ensuite le nom du trait, qui est Resumable dans notre cas. Nous avons aussi déclaré le trait comme pub afin que les crates qui dépendent de cette crate puissent aussi utiliser ce trait, comme nous allons le voir dans quelques exemples. Entre les accolades, nous déclarons la signature de la méthode qui décrit le comportement des types qui implémentent ce trait, qui est dans notre cas fn resumer(&self) -> String.

A la fin de la signature de la méthode, au lieu de renseigner une implémentation entre des accolades, nous utilisons un point-virgule. Chaque type qui implémente ce trait doit renseigner son propre comportement dans le corps de la méthode. Le compilateur va s'assurer que tous les types qui ont le trait Resumable auront la méthode resumer définie avec cette signature précise.

Un trait peut avoir plusieurs méthodes dans son corps : les signatures des méthodes sont ajoutées ligne par ligne et chaque ligne se termine avec un point-virgule.

Implémenter un trait sur un type

Maintenant que nous avons défini les signatures souhaitées des méthodes du trait Resumable, nous pouvons maintenant l'implémenter sur les types de notre agrégateur de médias. L'encart 10-13 montre une implémentation du trait Resumable sur la structure ArticleDePresse qui utilise le titre, le nom de l'auteur et le lieu pour créer la valeur de retour de resumer. Pour la structure Tweet, nous définissons resumer avec le nom d'utilisateur suivi par le texte entier du tweet, en supposant que le contenu du tweet est déjà limité à 280 caractères.

Fichier : src/lib.rs

pub trait Resumable {
    fn resumer(&self) -> String;
}

pub struct ArticleDePresse {
    pub titre: String,
    pub lieu: String,
    pub auteur: String,
    pub contenu: String,
}

impl Resumable for ArticleDePresse {
    fn resumer(&self) -> String {
        format!("{}, par {} ({})", self.titre, self.auteur, self.lieu)
    }
}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{} : {}", self.nom_utilisateur, self.contenu)
    }
}

Encart 10-13 : implémentation du trait Resumable sur les types ArticleDePresse et Tweet

L'implémentation d'un trait sur un type est similaire à l'implémentation d'une méthode classique. La différence est que nous ajoutons le nom du trait que nous voulons implémenter après le impl, et que nous utilisons ensuite le mot-clé for suivi du nom du type sur lequel nous souhaitons implémenter le trait. À l'intérieur du bloc impl, nous ajoutons les signatures des méthodes présentes dans la définition du trait. Au lieu d'ajouter un point-virgule après chaque signature, nous plaçons les accolades et on remplit le corps de la méthode avec le comportement spécifique que nous voulons que les méthodes du trait suivent pour le type en question.

Maintenant que la bibliothèque a implémenté le trait Resumable sur ArticleDePresse et Tweet, les utilisateurs de cette crate peuvent appeler les méthodes de l'instance de ArticleDePresse et Tweet comme si elles étaient des méthodes classiques. La seule différence est que le trait ainsi que les types doivent être introduits dans la portée pour obtenir les méthodes de trait additionnelles. Voici un exemple de comment la crate binaire pourra utiliser notre crate de bibliothèque agregateur :

use agregateur::{Resumable, Tweet};

fn main() {
    let tweet = Tweet {
        nom_utilisateur: String::from("jean"),
        contenu: String::from("Bien sûr, les amis, comme vous le savez probablement déjà"),
        reponse: false,
        retweet: false,
    };
    
    println!("1 nouveau tweet : {}", tweet.resumer());
}

Ce code affichera 1 nouveau tweet : jean : Bien sûr, les amis, comme vous le savez probablement déjà.

Les autres crates qui dépendent de la crate agregateur peuvent aussi importer dans la portée le trait Resumable afin d'implémenter le trait sur leurs propres types. Il y a une limitation à souligner avec l'implémentation des traits, c'est que nous ne pouvons implémenter un trait sur un type qu'à condition qu'au moins le trait ou le type soit défini localement dans notre crate. Par exemple, nous pouvons implémenter des traits de la bibliothèque standard comme Display sur un type personnalisé comme Tweet comme une fonctionnalité de notre crate agregateur, car le type Tweet est défini localement dans notre crate agregateur. Nous pouvons aussi implémenter Resumable sur Vec<T> dans notre crate agregateur, car le trait Resumable est défini localement dans notre crate agregateur.

Mais nous ne pouvons pas implémenter des traits externes sur des types externes. Par exemple, nous ne pouvons pas implémenter le trait Display sur Vec<T> à l'intérieur de notre crate agregateur, car Display et Vec<T> sont définis dans la bibliothèque standard et ne sont donc pas définis localement dans notre crate agregateur. Cette limitation fait partie d'une propriété des programmes que l'on appelle la cohérence, et plus précisément la règle de l'orphelin, qui s'appelle ainsi car le type parent n'est pas présent. Cette règle s'assure que le code des autres personnes ne casse pas votre code et réciproquement. Sans cette règle, deux crates pourraient implémenter le même trait sur le même type, et Rust ne saurait pas quelle implémentation utiliser.

Implémentations par défaut

Il est parfois utile d'avoir un comportement par défaut pour toutes ou une partie des méthodes d'un trait plutôt que de demander l'implémentation de toutes les méthodes sur chaque type. Ainsi, si nous implémentons le trait sur un type particulier, nous pouvons garder ou réécrire le comportement par défaut de chaque méthode.

L'encart 10-14 nous montre comment préciser une String par défaut pour la méthode resumer du trait Resumable plutôt que de définir uniquement la signature de la méthode, comme nous l'avons fait dans l'encart 10-12.

Fichier : src/lib.rs

pub trait Resumable {
    fn resumer(&self) -> String {
        String::from("(En savoir plus ...)")
    }
}

pub struct ArticleDePresse {
    pub titre: String,
    pub lieu: String,
    pub auteur: String,
    pub contenu: String,
}

impl Resumable for ArticleDePresse {}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{}: {}", self.nom_utilisateur, self.contenu)
    }
}

Encart 10-14 : définition du trait Resumable avec une implémentation par défaut de la méthode resumer

Pour utiliser l'implémentation par défaut pour résumer des instances de ArticleDePresse au lieu de préciser une implémentation personnalisée, nous précisons un bloc impl vide avec impl Resumable for ArticleDePresse {}.

Même si nous ne définissons plus directement la méthode resumer sur ArticleDePresse, nous avons fourni une implémentation par défaut et précisé que ArticleDePresse implémente le trait Resumable. Par conséquent, nous pouvons toujours appeler la méthode resumer sur une instance de ArticleDePresse, comme ceci :

use chapter10::{self, ArticleDePresse, Resumable};

fn main() {
    let article = ArticleDePresse {
        titre: String::from("Les Penguins ont remporté la Coupe Stanley !"),
        lieu: String::from("Pittsburgh, PA, USA"),
        auteur: String::from("Iceburgh"),
        contenu: String::from(
            "Les Penguins de Pittsburgh sont une nouvelle fois la meilleure \
            équipe de hockey de la LNH.",
        ),
    };

    println!("Nouvel article disponible ! {}", article.resumer());
}

Ce code va afficher Nouvel article disponible ! (En savoir plus ...).

La création d'une implémentation par défaut pour resumer n'a pas besoin que nous modifiions quelque chose dans l'implémentation de Resumable sur Tweet dans l'encart 10-13. C'est parce que la syntaxe pour réécrire l'implémentation par défaut est la même que la syntaxe pour implémenter une méthode qui n'a pas d'implémentation par défaut.

Les implémentations par défaut peuvent appeler d'autres méthodes du même trait, même si ces autres méthodes n'ont pas d'implémentation par défaut. Ainsi, un trait peut fournir de nombreuses fonctionnalités utiles et n'exiger du développeur qui l'utilise que d'en implémenter une petite partie. Par exemple, nous pouvons définir le trait Resumable comme ayant une méthode resumer_auteur dont l'implémentation est nécessaire, et ensuite définir une méthode resumer qui a une implémentation par défaut qui appelle la méthode resumer_auteur :

pub trait Resumable {
    fn resumer_auteur(&self) -> String;

    fn resumer(&self) -> String {
        format!("(Lire plus d'éléments de {} ...)", self.resumer_auteur())
    }
}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer_auteur(&self) -> String {
        format!("@{}", self.nom_utilisateur)
    }
}

Pour pouvoir utiliser cette version de Resumable, nous avons seulement besoin de définir resumer_auteur lorsqu'on implémente le trait sur le type :

pub trait Resumable {
    fn resumer_auteur(&self) -> String;

    fn resumer(&self) -> String {
        format!("(Lire plus d'éléments de {} ...)", self.resumer_auteur())
    }
}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer_auteur(&self) -> String {
        format!("@{}", self.nom_utilisateur)
    }
}

Après avoir défini resumer_auteur, nous pouvons appeler resumer sur des instances de la structure Tweet, et l'implémentation par défaut de resumer va appeler resumer_auteur, que nous avons défini. Comme nous avons implémenté resumer_auteur, le trait Resumable nous a donné le comportement de la méthode resumer sans nous obliger à écrire une ligne de code supplémentaire.

use chapter10::{self, Resumable, Tweet};

fn main() {
    let tweet = Tweet {
        nom_utilisateur: String::from("jean"),
        contenu: String::from("Bien sûr, les amis, comme vous le savez probablement déjà"),
        reponse: false,
        retweet: false,
    };
    
    println!("1 nouveau tweet : {}", tweet.resumer());
}

Ce code affichera 1 nouveau tweet : (Lire plus d'éléments de @jean ...).

Notez qu'il n'est pas possible d'appeler l'implémentation par défaut à partir d'une réécriture de cette même méthode.

Des traits en paramètres

Maintenant que vous savez comment définir et implémenter les traits, nous pouvons regarder comment utiliser les traits pour définir des fonctions qui acceptent plusieurs types différents.

Par exemple, dans l'encart 10-13, nous avons implémenté le trait Resumable sur les types ArticleDePresse et Tweet. Nous pouvons définir une fonction notifier qui va appeler la méthode resumer sur son paramètre element, qui est d'un type qui implémente le trait Resumable. Pour faire ceci, nous pouvons utiliser la syntaxe impl Trait, comme ceci :

pub trait Resumable {
    fn resumer(&self) -> String;
}

pub struct ArticleDePresse {
    pub titre: String,
    pub lieu: String,
    pub auteur: String,
    pub contenu: String,
}

impl Resumable for ArticleDePresse {
    fn resumer(&self) -> String {
        format!("{}, par {} ({})", self.titre, self.auteur, self.lieu)
    }
}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{} : {}", self.nom_utilisateur, self.contenu)
    }
}

pub fn notifier(element: &impl Resumable) {
    println!("Flash info ! {}", element.resumer());
}

Au lieu d'un type concret pour le paramètre element, nous précisons le mot-clé impl et le nom du trait. Ce paramètre accepte n'importe quel type qui implémente le trait spécifié. Dans le corps de notifier, nous pouvons appeler toutes les méthodes sur element qui proviennent du trait Resumable, comme resumer. Nous pouvons appeler notifier et passer une instance de ArticleDePresse ou de Tweet. Le code qui appellera la fonction avec un autre type, comme une String ou un i32, ne va pas se compiler car ces types n'implémentent pas Resumable.

La syntaxe du trait lié

La syntaxe impl Trait fonctionne bien pour des cas simples, mais est en réalité du sucre syntaxique pour une forme plus longue, qui s'appelle le trait lié, qui ressemble à ceci :

pub fn notifier<T: Resumable>(element: &T) {
    println!("Flash info ! {}", element.resumer());
}

Cette forme plus longue est équivalente à l'exemple dans la section précédente, mais est plus verbeuse. Nous plaçons les traits liés dans la déclaration des paramètres de type génériques après un deux-point entre des chevrons.

La syntaxe impl Trait est pratique pour rendre du code plus concis dans des cas simples. La syntaxe du trait lié exprime plus de complexité dans certains cas. Par exemple, nous pouvons avoir deux paramètres qui implémentent Resumable. En utilisant la syntaxe impl Trait, nous aurons ceci :

pub fn notifier(element1: &impl Resumable, element2: &impl Resumable) {

Si nous souhaitons permettre à element1 et element2 d'avoir des types différents, l'utilisation de impl Trait est appropriée (du moment que chacun de ces types implémentent Resumable). Mais si nous souhaitons forcer les deux paramètres à être du même type, cela n'est possible à exprimer qu'avec un trait lié, comme ceci :

pub fn notifier<T: Resumable>(element1: &T, element2: &T) {

Le type générique T renseigné comme type des paramètres element1 et element2 contraint la fonction de manière à ce que les types concrets des valeurs passées en arguments pour element1 et element2 soient identiques.

Renseigner plusieurs traits liés avec la syntaxe +

Nous pouvons aussi préciser que nous attendons plus d'un trait lié. Imaginons que nous souhaitons que notifier utilise le formatage d'affichage sur element ainsi que la méthode resumer : nous indiquons dans la définition de notify que element doit implémenter à la fois Display et Resumable. Nous pouvons faire ceci avec la syntaxe + :

pub fn notifier(element: &(impl Resumable + Display)) {

La syntaxe + fonctionne aussi avec les traits liés sur des types génériques :

pub fn notifier<T: Resumable + Display>(element: &T) {

Avec les deux traits liés renseignés, le corps de notifier va appeler resumer et utiliser {} pour formater element.

Des traits liés plus clairs avec la clause where

L'utilisation de trop nombreux traits liés a aussi ses désavantages. Chaque type générique a ses propres traits liés, donc les fonctions avec plusieurs paramètres de type génériques peuvent aussi avoir de nombreuses informations de traits liés entre le nom de la fonction et la liste de ses paramètres, ce qui rend la signature de la fonction difficile à lire. Pour cette raison, Rust a une syntaxe alternative pour renseigner les traits liés, dans une clause where après la signature de la fonction. Donc, au lieu d'écrire ceci ...

fn une_fonction<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

... nous pouvons utiliser la clause where, comme ceci :

fn une_fonction<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{

La signature de cette fonction est moins encombrée : le nom de la fonction, la liste des paramètres et le type de retour sont plus proches les uns des autres, comme une fonction sans traits liés.

Retourner des types qui implémentent des traits

Nous pouvons aussi utiliser la syntaxe impl Trait à la place du type de retour afin de retourner une valeur d'un type qui implémente un trait, comme ci-dessous :

pub trait Resumable {
    fn resumer(&self) -> String;
}

pub struct ArticleDePresse {
    pub titre: String,
    pub lieu: String,
    pub auteur: String,
    pub contenu: String,
}

impl Resumable for ArticleDePresse {
    fn resumer(&self) -> String {
        format!("{}, par {} ({})", self.titre, self.auteur, self.lieu)
    }
}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{} : {}", self.nom_utilisateur, self.contenu)
    }
}

fn retourne_resumable() -> impl Resumable {
    Tweet {
        nom_utilisateur: String::from("jean"),
        contenu: String::from("Bien sûr, les amis, comme vous le savez probablement déjà"),
        reponse: false,
        retweet: false,
    }
}

En utilisant impl Resumable pour le type de retour, nous indiquons que la fonction retourne_resumable retourne un type qui implémente le trait Resumable sans avoir à écrire le nom du type concret. Dans notre cas, retourne_resumable retourne un Tweet, mais le code qui appellera cette fonction ne le saura pas.

La capacité de retourner un type qui est uniquement caractérisé par le trait qu'il implémente est tout particulièrement utile dans le cas des fermetures et des itérateurs, que nous verrons au chapitre 13. Les fermetures et les itérateurs créent des types que seul le compilateur est en mesure de comprendre ou alors des types qui sont très longs à définir. La syntaxe impl Trait vous permet de renseigner de manière concise qu'une fonction retourne un type particulier qui implémente le trait Iterator sans avoir à écrire un très long type.

Cependant, vous pouvez seulement utiliser impl Trait si vous retournez un seul type possible. Par exemple, ce code va retourner soit un ArticleDePresse, soit un Tweet, alors que le type de retour avec impl Resumable ne va pas fonctionner :

pub trait Resumable {
    fn resumer(&self) -> String;
}

pub struct ArticleDePresse {
    pub titre: String,
    pub lieu: String,
    pub auteur: String,
    pub contenu: String,
}

impl Resumable for ArticleDePresse {
    fn resumer(&self) -> String {
        format!("{}, par {} ({})", self.titre, self.auteur, self.lieu)
    }
}

pub struct Tweet {
    pub nom_utilisateur: String,
    pub contenu: String,
    pub reponse: bool,
    pub retweet: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{} : {}", self.nom_utilisateur, self.contenu)
    }
}

fn retourne_resumable(estArticle: bool) -> impl Resumable {
    if estArticle {
        ArticleDePresse {
            titre: String::from("Les Penguins ont remporté la Coupe Stanley !"),
            lieu: String::from("Pittsburgh, PA, USA"),
            auteur: String::from("Iceburgh"),
            contenu: String::from("Les Penguins de Pittsburgh sont une nouvelle fois la \
            meilleure équipe de hockey de la LNH."),
        }
    } else {
        Tweet {
            nom_utilisateur: String::from("jean"),
            contenu: String::from("Bien sûr, les amis, comme vous le savez probablement déjà"),
            reponse: false,
            retweet: false,
        }
    }
}

Retourner soit un ArticleDePresse, soit un Tweet n'est pas autorisé à cause des restrictions sur la façon dont la syntaxe impl Trait est implémentée dans le compilateur. Nous verrons comment écrire une fonction avec ce comportement dans une section du chapitre 17.

Corriger la fonction le_plus_grand avec les traits liés

Maintenant que vous savez comment renseigner le comportement que vous souhaitez utiliser en utilisant les traits liés des paramètres de type génériques, retournons à l'encart 10-5 pour corriger la définition de la fonction le_plus_grand qui utilise un paramètre de type générique ! La dernière fois que nous avons essayé de lancer ce code, nous avions l'erreur suivante :

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:17
  |
5 |         if element > le_plus_grand {
  |            ------- ^ ------------- T
  |            |
  |            T
  |
help: consider restricting type parameter `T`
  |
1 | fn le_plus_grand<T: std::cmp::PartialOrd>(liste: &[T]) -> T {
  |                   ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` due to previous error

Dans le corps de le_plus_grand, nous voulions comparer les deux valeurs du type T en utilisant l'opérateur plus grand que (>). Comme cet opérateur est défini comme une méthode par défaut dans le trait de la bibliothèque standard std::cmp::PartialOrd, nous devons préciser PartialOrd dans les traits liés pour T afin que la fonction le_plus_grand puisse fonctionner sur les slices de n'importe quel type que nous pouvons comparer. Nous n'avons pas besoin d'importer PartialOrd dans la portée car il est importé dans l'étape préliminaire. Changez la signature de le_plus_grand par quelque chose comme ceci :

fn le_plus_grand<T: PartialOrd>(liste: &[T]) -> T {
    let mut le_plus_grand = liste[0];

    for &element in liste {
        if element > le_plus_grand {
            le_plus_grand = element;
        }
    }

    le_plus_grand
}

fn main() {
    let liste_de_nombres = vec![34, 50, 25, 100, 65];

    let resultat = le_plus_grand(&liste_de_nombres);
    println!("Le plus grand nombre est {}", resultat);

    let liste_de_caracteres = vec!['y', 'm', 'a', 'q'];

    let resultat = le_plus_grand(&liste_de_caracteres);
    println!("Le plus grand caractère est {}", resultat);
}

Cette fois, lorsque nous allons compiler le code, nous aurons un ensemble d'erreurs différent :

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0508]: cannot move out of type `[T]`, a non-copy slice
 -- > src/main.rs:2:23
  |
2 |     let mut le_plus_grand = liste[0];
  |                             ^^^^^^^^
  |                             |
  |                             cannot move out of here
  |                             move occurs because `liste[_]` has type `T`, which does not implement the `Copy` trait
  |                             help: consider borrowing here: `&liste[0]`

error[E0507]: cannot move out of a shared reference
 -- > src/main.rs:4:18
  |
4 |     for &element in liste {
  |         --------    ^^^^^
  |         ||
  |         |data moved here
  |         |move occurs because `element` has type `T`, which does not implement the `Copy` trait
  |         help: consider removing the `&`: `element`

Some errors have detailed explanations: E0507, E0508.
For more information about an error, try `rustc --explain E0507`.
error: could not compile `chapter10` due to 2 previous errors

L'élement-clé dans ces erreurs est cannot move out of type [T], a non-copy slice (impossible de déplacer une valeur hors du type [T], slice non Copy). Avec notre version non générique de la fonction le_plus_grand, nous avions essayé de trouver le plus grand i32 ou char. Comme nous l'avons vu dans la section “Données uniquement sur la pile : la copie” du chapitre 4, les types comme i32 et char ont une taille connue et peuvent être stockés sur la pile, donc ils implémentent le trait Copy. Mais quand nous avons rendu générique la fonction le_plus_grand, il est devenu possible que le paramètre liste contienne des types qui n'implémentent pas le trait Copy. Par conséquent, nous ne pouvons pas forcément déplacer la valeur de list[0] dans notre variable le_plus_grand, ce qui engendre cette erreur.

Pour pouvoir appeler ce code avec seulement les types qui implémentent le trait Copy, nous pouvons ajouter Copy aux traits liés de T ! L'encart 10-15 nous montre le code complet d'une fonction générique le_plus_grand qui va se compiler tant que le type des valeurs dans la slice que nous passons dans la fonction implémente les traits PartialOrd et Copy, comme le font i32 et char.

Fichier : src/main.rs

fn le_plus_grand<T: PartialOrd + Copy>(liste: &[T]) -> T {
    let mut le_plus_grand = liste[0];

    for &element in liste {
        if element > le_plus_grand {
            le_plus_grand = element;
        }
    }

    le_plus_grand
}

fn main() {
    let liste_de_nombres = vec![34, 50, 25, 100, 65];

    let resultat = le_plus_grand(&liste_de_nombres);
    println!("Le nombre le plus grand est {}", resultat);

    let liste_de_caracteres = vec!['y', 'm', 'a', 'q'];

    let resultat = le_plus_grand(&liste_de_caracteres);
    println!("Le plus grand caractère est {}", resultat);
}

Encart 10-15 : une définition de la fonction le_plus_grand qui fonctionne et s'applique sur n'importe quel type générique qui implémente les traits PartialOrd et Copy

Si nous ne souhaitons pas restreindre la fonction le_plus_grand aux types qui implémentent le trait Copy, nous pouvons préciser que T a le trait lié Clone plutôt que Copy. Ainsi, nous pouvons cloner chaque valeur dans la slice lorsque nous souhaitons que la fonction le_plus_grand en prenne possession. L'utilisation de la fonction clone signifie que nous allons potentiellement allouer plus d'espace sur le tas dans le cas des types qui possèdent des données sur le tas, comme String, et les allocations sur le tas peuvent être lentes si nous travaillons avec des grandes quantités de données.

Une autre façon d'implémenter le_plus_grand est de faire en sorte que la fonction retourne une référence à une valeur T de la slice. Si nous changeons le type de retour en &T à la place de T et que nous adaptons le corps de la fonction afin de retourner une référence, nous n'aurions alors plus besoin des traits liés Clone ou Copy et nous pourrions ainsi éviter l'allocation sur le tas. Essayez d'implémenter ces solutions alternatives par vous-même ! Si vous bloquez sur des erreurs à propos des durées de vie (lifetimes), lisez la suite : la section suivante, “La conformité des références avec les durées de vies” vous expliquera cela, mais les durées de vie ne sont pas nécessaires pour résoudre ces exercices.

Utiliser les traits liés pour conditionner l'implémentation des méthodes

En utilisant un trait lié avec un bloc impl qui utilise les paramètres de type génériques, nous pouvons implémenter des méthodes en fonction des types qui implémentent des traits particuliers. Par exemple, le type Paire<T> de l'encart 10-16 implémente toujours la fonction new pour retourner une nouvelle instance de Paire<T> (pour rappel dans la section ”Définir des méthodes” du chapitre 5 que Self est un alias de type pour le type du bloc impl, qui est dans ce cas le Paire<T>). Mais dans le bloc impl suivant, Paire<T> implémente la méthode afficher_comparaison uniquement si son type interne T implémente le trait PartialOrd qui active la comparaison et le trait Display qui permet l'affichage.

Fichier : src/lib.rs

use std::fmt::Display;

struct Paire<T> {
    x: T,
    y: T,
}

impl<T> Paire<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Paire<T> {
    fn afficher_comparaison(&self) {
        if self.x >= self.y {
            println!("Le plus grand élément est x = {}", self.x);
        } else {
            println!("Le plus grand élément est y = {}", self.y);
        }
    }
}

Encart 10-16 : implémentation de méthodes sur un type générique en fonction des traits liés

Nous pouvons également implémenter un trait sur tout type qui implémente un autre trait en particulier. L'implémentation d'un trait sur n'importe quel type qui a un trait lié est appelée implémentation générale et est largement utilisée dans la bibliothèque standard Rust. Par exemple, la bibliothèque standard implémente le trait ToString sur tous les types qui implémentent le trait Display. Le bloc impl de la bibliothèque standard ressemble au code suivant :

impl<T: Display> ToString for T {
    // -- partie masquée ici --
}

Comme la bibliothèque standard a cette implémentation générale, nous pouvons appeler la méthode to_string définie par le trait ToString sur n'importe quel type qui implémente le trait Display. Par exemple, nous pouvons transformer les nombres entiers en leur équivalent dans une String comme ci-dessous car les entiers implémentent Display :


#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Les implémentations générales sont décrites dans la documentation du trait, dans la section “Implementors”.

Les traits et les traits liés nous permettent d'écrire du code qui utilise des paramètres de type génériques pour réduire la duplication de code, mais aussi pour indiquer au compilateur que nous voulons que le type générique ait un comportement particulier. Le compilateur peut ensuite utiliser les informations liées aux traits pour vérifier que tous les types concrets utilisés dans notre code suivent le comportement souhaité. Dans les langages typés dynamiquement, nous aurions une erreur à l'exécution si nous appelions une méthode sur un type qui n'implémenterait pas la méthode. Mais Rust décale l'apparition de ces erreurs au moment de la compilation afin de nous forcer à résoudre les problèmes avant même que notre code soit capable de s'exécuter. De plus, nous n'avons pas besoin d'écrire un code qui vérifie le comportement lors de l'exécution car nous l'avons déjà vérifié au moment de la compilation. Cela permet d'améliorer les performances sans avoir à sacrifier la flexibilité des types génériques.

Une autre sorte de générique que nous avons déjà utilisée est la durée de vie. Plutôt que de s'assurer qu'un type a le comportement que nous voulons, la durée de vie s'assure que les références sont en vigueur aussi longtemps que nous avons besoin qu'elles le soient. Nous allons voir à la page suivante comment la durée de vie fait cela.