Traits : Définition de Comportement Partagé

Beginner

This tutorial is from open-source community. Access the source code

Introduction

Bienvenue dans Traits: Définir un Comportement Partagé. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous explorons les traits comme un moyen de définir un comportement partagé dans un type et de spécifier des contraintes de trait pour les types génériques.

Traits: Définir un Comportement Partagé

Un trait définit la fonctionnalité qu'un type particulier possède et peut partager avec d'autres types. Nous pouvons utiliser les traits pour définir un comportement partagé de manière abstraite. Nous pouvons utiliser des contraintes de trait pour spécifier qu'un type générique peut être n'importe quel type qui a un certain comportement.

Note : Les traits sont similaires à une fonctionnalité souvent appelée interfaces dans d'autres langages, bien qu'avec quelques différences.

Définir un Trait

Le comportement d'un type est constitué des méthodes que l'on peut appeler sur ce type. Différents types partagent le même comportement si l'on peut appeler les mêmes méthodes sur tous ces types. Les définitions de traits sont un moyen de regrouper les signatures de méthodes pour définir un ensemble de comportements nécessaires pour accomplir un certain but.

Par exemple, disons que nous avons plusieurs structs qui contiennent différents types et quantités de texte : un struct NewsArticle qui contient un article de presse enregistré à un emplacement particulier et un Tweet qui peut avoir au plus 280 caractères, ainsi que des métadonnées indiquant s'il s'agit d'un nouveau tweet, d'un retweet ou d'une réponse à un autre tweet.

Nous voulons créer une bibliothèque de crate d'agrégateur de médias appelée aggregator qui peut afficher des résumés de données qui pourraient être stockées dans une instance de NewsArticle ou Tweet. Pour ce faire, nous avons besoin d'un résumé de chaque type, et nous demanderons ce résumé en appelant une méthode summarize sur une instance. La liste 10-12 montre la définition d'un trait public Summary qui exprime ce comportement.

Nom du fichier : src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

Liste 10-12 : Un trait Summary qui consiste en le comportement fourni par une méthode summarize

Ici, nous déclarons un trait en utilisant le mot clé trait, puis le nom du trait, qui est Summary dans ce cas. Nous déclarons également le trait comme pub afin que les crates dépendantes de cette crate puissent également utiliser ce trait, comme nous le verrons dans quelques exemples. Dans les accolades, nous déclarons les signatures de méthodes qui décrivent les comportements des types qui implémentent ce trait, qui est fn summarize(&self) -> String dans ce cas.

Après la signature de la méthode, au lieu de fournir une implémentation entre accolades, nous utilisons un point-virgule. Chaque type implémentant ce trait doit fournir son propre comportement personnalisé pour le corps de la méthode. Le compilateur imposera que tout type ayant le trait Summary aura la méthode summarize définie exactement avec cette signature.

Un trait peut avoir plusieurs méthodes dans son corps : les signatures de méthodes sont listées une par ligne, et chaque ligne se termine par 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 Summary, nous pouvons l'implementer sur les types de notre agrégateur de médias. La liste 10-13 montre une implémentation du trait Summary sur le struct NewsArticle qui utilise le titre, l'auteur et l'emplacement pour créer la valeur de retour de summarize. Pour le struct Tweet, nous définissons summarize comme le nom d'utilisateur suivi du texte complet du tweet, en supposant que le contenu du tweet est déjà limité à 280 caractères.

Nom du fichier : src/lib.rs

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!(
            "{}, by {} ({})",
            self.headline,
            self.author,
            self.location
        )
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Liste 10-13 : Implémentation du trait Summary sur les types NewsArticle et Tweet

L'implémentation d'un trait sur un type est similaire à l'implémentation de méthodes régulières. La différence est que après impl, nous mettons le nom du trait que nous voulons implémenter, puis utilisons le mot clé for, et ensuite spécifions le nom du type pour lequel nous voulons implémenter le trait. Dans le bloc impl, nous mettons les signatures de méthodes définies par la définition du trait. Au lieu d'ajouter un point-virgule après chaque signature, nous utilisons des accolades et remplissons le corps de la méthode avec le comportement spécifique que nous voulons que les méthodes du trait aient pour le type particulier.

Maintenant que la bibliothèque a implémenté le trait Summary sur NewsArticle et Tweet, les utilisateurs de la crate peuvent appeler les méthodes du trait sur des instances de NewsArticle et Tweet de la même manière que nous appelons les méthodes régulières. La seule différence est que l'utilisateur doit également porter le trait dans le scope, ainsi que les types. Voici un exemple de la manière dont une crate binaire pourrait utiliser notre bibliothèque de crate aggregator :

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Ce code affiche 1 new tweet: horse_ebooks: of course, as you probably already know, people.

D'autres crates qui dépendent de la crate aggregator peuvent également porter le trait Summary dans le scope pour implémenter Summary sur leurs propres types. Une restriction à noter est que nous ne pouvons implémenter un trait sur un type que si le trait ou le type, ou les deux, sont locaux à notre crate. Par exemple, nous pouvons implémenter des traits de la bibliothèque standard comme Display sur un type personnalisé comme Tweet en tant que partie de la fonctionnalité de notre crate aggregator car le type Tweet est local à notre crate aggregator. Nous pouvons également implémenter Summary sur Vec<T> dans notre crate aggregator car le trait Summary est local à notre crate aggregator.

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> dans notre crate aggregator car Display et Vec<T> sont tous deux définis dans la bibliothèque standard et ne sont pas locaux à notre crate aggregator. Cette restriction est partie d'une propriété appelée cohérence, et plus précisément la règle de l'orphelin, ainsi nommée parce que le type parent n'est pas présent. Cette règle garantit que le code d'autres personnes ne peut pas casser votre code et vice versa. Sans cette règle, deux crates pourraient implémenter le même trait pour le même type, et Rust ne saurait laquelle des implémentations utiliser.

Implémentations Par Défaut

Parfois, il est utile d'avoir un comportement par défaut pour certaines ou toutes les méthodes d'un trait, plutôt que de demander des implémentations pour toutes les méthodes sur chaque type. Ensuite, lorsque nous implémentons le trait sur un type particulier, nous pouvons conserver ou remplacer le comportement par défaut de chaque méthode.

Dans la liste 10-14, nous spécifions une chaîne de caractères par défaut pour la méthode summarize du trait Summary, au lieu de seulement définir la signature de la méthode, comme nous l'avons fait dans la liste 10-12.

Nom du fichier : src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Lire la suite...)")
    }
}

Liste 10-14 : Définition d'un trait Summary avec une implémentation par défaut de la méthode summarize

Pour utiliser l'implémentation par défaut pour résumer des instances de NewsArticle, nous spécifions un bloc impl vide avec impl Summary for NewsArticle {}.

Même si nous ne définissons plus directement la méthode summarize sur NewsArticle, nous avons fourni une implémentation par défaut et spécifié que NewsArticle implémente le trait Summary. En conséquence, nous pouvons toujours appeler la méthode summarize sur une instance de NewsArticle, comme ceci :

let article = NewsArticle {
    headline: String::from(
        "Penguins win the Stanley Cup Championship!"
    ),
    location: String::from("Pittsburgh, PA, USA"),
    author: String::from("Iceburgh"),
    content: String::from(
        "The Pittsburgh Penguins once again are the best \
         hockey team in the NHL.",
    ),
};

println!("New article available! {}", article.summarize());

Ce code affiche New article available! (Lire la suite...).

Créer une implémentation par défaut ne nécessite pas que nous changions quoi que ce soit dans l'implémentation de Summary sur Tweet dans la liste 10-13. La raison en est que la syntaxe pour remplacer une implémentation par défaut est la même que la syntaxe pour implémenter une méthode de trait qui n'a pas d'implémentation par défaut.

Les implémentations par défaut peuvent appeler d'autres méthodes dans le même trait, même si ces autres méthodes n'ont pas d'implémentation par défaut. De cette manière, un trait peut fournir beaucoup de fonctionnalités utiles et ne demander aux implémentateurs de spécifier qu'une petite partie d'entre elles. Par exemple, nous pourrions définir le trait Summary pour avoir une méthode summarize_author dont l'implémentation est requise, puis définir une méthode summarize qui a une implémentation par défaut qui appelle la méthode summarize_author :

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!(
            "(Lire la suite de {}...)",
            self.summarize_author()
        )
    }
}

Pour utiliser cette version de Summary, nous n'avons besoin que de définir summarize_author lorsque nous implémentons le trait sur un type :

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Après avoir défini summarize_author, nous pouvons appeler summarize sur des instances du struct Tweet, et l'implémentation par défaut de summarize appellera la définition de summarize_author que nous avons fournie. Parce que nous avons implémenté summarize_author, le trait Summary nous a donné le comportement de la méthode summarize sans que nous ayons besoin d'écrire plus de code. Voici à quoi cela ressemble :

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
        "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

Ce code affiche 1 new tweet: (Lire la suite de @horse_ebooks...).

Notez qu'il n'est pas possible d'appeler l'implémentation par défaut à partir d'une implémentation qui remplace la même méthode.

Traits en tant que Paramètres

Maintenant que vous savez comment définir et implémenter des traits, nous pouvons explorer comment utiliser les traits pour définir des fonctions qui acceptent de nombreux types différents. Nous utiliserons le trait Summary que nous avons implémenté sur les types NewsArticle et Tweet dans la liste 10-13 pour définir une fonction notify qui appelle la méthode summarize sur son paramètre item, qui est d'un certain type qui implémente le trait Summary. Pour ce faire, nous utilisons la syntaxe impl Trait, comme ceci :

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Au lieu d'un type concret pour le paramètre item, nous spécifions le mot clé impl et le nom du trait. Ce paramètre accepte tout type qui implémente le trait spécifié. Dans le corps de notify, nous pouvons appeler n'importe quelle méthode sur item qui vient du trait Summary, comme summarize. Nous pouvons appeler notify et passer n'importe quelle instance de NewsArticle ou Tweet. Le code qui appelle la fonction avec n'importe quel autre type, tel qu'une String ou un i32, ne compilera pas car ces types n'implémentent pas Summary.

Syntaxe des Contraintes de Trait

La syntaxe impl Trait fonctionne pour les cas simples, mais est en fait un sucre syntaxique pour une forme plus longue connue sous le nom de contrainte de trait ; elle ressemble à ceci :

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Cette forme plus longue est équivalente à l'exemple de la section précédente, mais est plus verbeuse. Nous plaçons les contraintes de trait avec la déclaration du paramètre de type générique après deux points et à l'intérieur de crochets.

La syntaxe impl Trait est pratique et permet d'avoir un code plus concis dans les cas simples, tandis que la syntaxe complète des contraintes de trait peut exprimer plus de complexité dans d'autres cas. Par exemple, nous pouvons avoir deux paramètres qui implémentent Summary. Le faire avec la syntaxe impl Trait ressemble à ceci :

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Utiliser impl Trait est approprié si nous voulons que cette fonction permette à item1 et item2 d'avoir des types différents (pourvu que les deux types implémentent Summary). Si nous voulons forcer les deux paramètres à avoir le même type, cependant, nous devons utiliser une contrainte de trait, comme ceci :

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Le type générique T spécifié comme type des paramètres item1 et item2 contraint la fonction de sorte que le type concret de la valeur passée en argument pour item1 et item2 doit être le même.

Spécification de Plusieurs Contraintes de Trait avec la Syntaxe +

Nous pouvons également spécifier plusieurs contraintes de trait. Disons que nous voulions que notify utilise la mise en forme d'affichage ainsi que summarize sur item : nous spécifions dans la définition de notify que item doit implémenter à la fois Display et Summary. Nous pouvons le faire en utilisant la syntaxe + :

pub fn notify(item: &(impl Summary + Display)) {

La syntaxe + est également valide avec les contraintes de trait sur les types génériques :

pub fn notify<T: Summary + Display>(item: &T) {

Avec les deux contraintes de trait spécifiées, le corps de notify peut appeler summarize et utiliser {} pour formater item.

Contraintes de Trait Plus Claires avec les Clauses where

Utiliser trop de contraintes de trait a ses inconvénients. Chaque type générique a ses propres contraintes de trait, donc les fonctions avec plusieurs paramètres de type générique peuvent contenir beaucoup d'informations sur les contraintes de trait entre le nom de la fonction et sa liste de paramètres, rendant la signature de la fonction difficile à lire. Pour cette raison, Rust a une syntaxe alternative pour spécifier les contraintes de trait dans une clause where après la signature de la fonction. Ainsi, au lieu d'écrire ceci :

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

nous pouvons utiliser une clause where, comme ceci :

fn some_function<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 de paramètres et le type de retour sont proches les uns des autres, similaire à une fonction sans beaucoup de contraintes de trait.

Retourner des Types qui Implémentent des Traits

Nous pouvons également utiliser la syntaxe impl Trait dans la position de retour pour retourner une valeur d'un certain type qui implémente un trait, comme le montre ici :

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

En utilisant impl Summary pour le type de retour, nous spécifions que la fonction returns_summarizable retourne un certain type qui implémente le trait Summary sans nommer le type concret. Dans ce cas, returns_summarizable retourne un Tweet, mais le code appelant 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 particulièrement utile dans le contexte des closures et des itérateurs, que nous aborderons au chapitre 13. Les closures et les itérateurs créent des types que seul le compilateur connaît ou des types très longs à spécifier. La syntaxe impl Trait vous permet de spécifier de manière concise qu'une fonction retourne un certain type qui implémente le trait Iterator sans avoir besoin d'écrire un type très long.

Cependant, vous ne pouvez utiliser impl Trait que si vous retournez un seul type. Par exemple, ce code qui retourne soit un NewsArticle soit un Tweet avec le type de retour spécifié comme impl Summary ne fonctionnerait pas :

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Retourner soit un NewsArticle soit un Tweet n'est pas autorisé en raison des restrictions sur la manière dont la syntaxe impl Trait est implémentée dans le compilateur. Nous aborderons la manière d'écrire une fonction avec ce comportement dans "Utilisation d'Objets de Trait qui Autorisent des Valeurs de Différents Types".

Utiliser des Contraintes de Trait pour Implémenter des Méthodes Conditionnellement

En utilisant une contrainte de trait avec un bloc impl qui utilise des paramètres de type générique, nous pouvons implémenter des méthodes conditionnellement pour les types qui implémentent les traits spécifiés. Par exemple, le type Pair<T> dans la liste 10-15 implémente toujours la fonction new pour retourner une nouvelle instance de Pair<T> (rappelez-vous de "Définition de Méthodes" que Self est un alias de type pour le type du bloc impl, qui dans ce cas est Pair<T>). Mais dans le prochain bloc impl, Pair<T> implémente seulement la méthode cmp_display si son type interne T implémente le trait PartialOrd qui permet la comparaison et le trait Display qui permet l'affichage.

Nom de fichier : src/lib.rs

use std::fmt::Display;

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

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

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Liste 10-15 : Implémentation conditionnelle de méthodes sur un type générique en fonction des contraintes de trait

Nous pouvons également implémenter un trait conditionnellement pour tout type qui implémente un autre trait. Les implémentations d'un trait sur tout type qui satisfait les contraintes de trait sont appelées implémentations génériques et sont largement utilisées dans la bibliothèque standard de Rust. Par exemple, la bibliothèque standard implémente le trait ToString sur tout type qui implémente le trait Display. Le bloc impl dans la bibliothèque standard ressemble à ce code :

impl<T: Display> ToString for T {
    --snip--
}

En raison de cette implémentation générique dans la bibliothèque standard, nous pouvons appeler la méthode to_string définie par le trait ToString sur tout type qui implémente le trait Display. Par exemple, nous pouvons convertir des entiers en leurs valeurs String correspondantes comme ceci car les entiers implémentent Display :

let s = 3.to_string();

Les implémentations génériques apparaissent dans la documentation du trait dans la section "Implementeurs".

Les traits et les contraintes de trait nous permettent d'écrire du code qui utilise des paramètres de type générique pour réduire la duplication, mais également de spécifier au compilateur que nous voulons que le type générique ait un comportement particulier. Le compilateur peut ensuite utiliser les informations sur les contraintes de trait pour vérifier que tous les types concret utilisés avec notre code fournissent le bon comportement. Dans les langages à typage dynamique, nous obtiendrions une erreur à l'exécution si nous appelions une méthode sur un type qui n'a pas défini la méthode. Mais Rust déplace ces erreurs au moment de la compilation, de sorte que nous sommes contraints de corriger les problèmes avant même que notre code ne puisse s'exécuter. De plus, nous n'avons pas besoin d'écrire du code qui vérifie le comportement à l'exécution car nous avons déjà vérifié au moment de la compilation. Cela améliore les performances sans avoir à renoncer à la flexibilité des types génériques.

Sommaire

Félicitations ! Vous avez terminé le laboratoire Traits : Définition de Comportement Partagé. Vous pouvez pratiquer d'autres laboratoires dans LabEx pour améliorer vos compétences.