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

Définir des comportements partagés avec les traits

Un trait définit une fonctionnalité qu’a un type particulier et qu’il peut partager avec d’autres types. 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 s’il s’agit d’une nouvelle publication, d’une republication, ou une réponse à une publication.

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.

Filename: src/lib.rs
pub trait Resumable {
    fn resumer(&self) -> String;
}
Listing 10-12: A Summary trait that consists of the behavior provided by a summarize method

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 déclarons aussi 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.

À 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 de la publication, en supposant que le contenu de la publication est déjà limité à 280 caractères.

Filename: 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 republication: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{} : {}", self.nom_utilisateur, self.contenu)
    }
}
Listing 10-13: Implementing the Summary trait on the NewsArticle and SocialPost types

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 nous remplissons 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 l’utilisateur doit introduire le trait dans la portée, tout comme les types, 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::{Tweet, Resumable};

fn main() {
    let publication = Tweet {
        nom_utilisateur: String::from("jean"),
        contenu: String::from(
            "Bien sûr, les amis, comme vous le savez probablement déjà",
        ),
        reponse: false,
        republication: false,
    };

    println!("1 nouveau tweet : {}", publication.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 Resumable sur leurs propres types. Il y a une limitation à souligner, c’est que nous ne pouvons implémenter un trait sur un type que si le trait, le type, ou les deux, sont définis 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 tous deux 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é appelée 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.

Dans l’encart 10-14, nous indiquons une chaîne de caractères 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.

Filename: 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 republication: bool,
}

impl Resumable for Tweet {
    fn resumer(&self) -> String {
        format!("{} : {}", self.nom_utilisateur, self.contenu)
    }
}
Listing 10-14: Defining a Summary trait with a default implementation of the summarize method

Pour utiliser l’implémentation par défaut pour résumer des instances de ArticleDePresse, nous mettons 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 agregateur::{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 republication: 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 republication: 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. Voici à quoi cela ressemble :

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

fn main() {
    let publication = Tweet {
        nom_utilisateur: String::from("jean"),
        contenu: String::from(
            "Bien sûr, les amis, comme vous le savez probablement déjà",
        ),
        reponse: false,
        republication: false,
    };

    println!("1 nouveau tweet : {}", publication.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.

Utilisation des traits comme 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. Nous utiliserons le trait Resumable que nous avons implémenté sur les types ArticleDePresse et Tweet dans l’encart 10-13, pour 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 ce faire, 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 republication: 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 connue sous le nom de 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, alors que la syntaxe du trait lié exprime plus de complexité dans d’autres 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) {

L’utilisation de impl Trait est pertinente si nous voulons que cette fonction permette à element1 et element2 d’avoir des types différents (tant que chacun de ces types implémente Resumable). Toutefois, si nous souhaitons forcer les deux paramètres à avoir le même type, nous devons utiliser 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.

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 ainsi que la méthode resumersur element : nous indiquons dans la définition de notifier 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,
{
    unimplemented!()
}

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 republication: 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,
        republication: 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 n’a pas besoin de le savoir.

La capacité de spécifier un type de retour uniquement 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 republication: 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,
            republication: 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 la section “Utiliser les objets traits qui permettent des valeurs de types différents” du chapitre 18.

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-15 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.

Filename: 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);
        }
    }
}
Listing 10-15: Conditionally implementing methods on a generic type depending on trait bounds

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.