Blog ENI : Toute la veille numérique !
🐠 -25€ dès 75€ 
+ 7 jours d'accès à la Bibliothèque Numérique ENI. Cliquez ici
Accès illimité 24h/24 à tous nos livres & vidéos ! 
Découvrez la Bibliothèque Numérique ENI. Cliquez ici
  1. Livres et vidéos
  2. Rust
  3. Les traits en Rust
Extrait - Rust Développez des programmes robustes et sécurisés
Extraits du livre
Rust Développez des programmes robustes et sécurisés
4 avis
Revenir à la page d'achat du livre

Les traits en Rust

Introduction

Un trait en langage Rust peut être vu comme une collection de méthodes partageable entre différents types. Cela permet ainsi de partager d’une certaine manière des comportements communs.

La notion de trait participe, avec la notion déjà abordée de générique, à offrir au langage Rust ce que l’on appelle en informatique le polymorphisme. On peut donc voir le trait Rust comme une sorte d’interface C# ou comme une classe abstraite en langage C++.

On peut bien sûr développer ses propres traits. Par ailleurs, le langage Rust, dans sa librairie standard, regorge d’exemples d’utilisation de traits, que l’on appelle parfois les traits prédéfinis.

Mais commençons à la base en créant notre premier trait.

Premier trait en Rust

1. Création d’une caisse et d’un exécutable client

On commence par créer un projet dédié à notre exemple. Il s’agira de créer un trait relatif aux animaux.

On crée donc une librairie (une bibliothèque) Rust, qui n’est ni plus ni moins qu’une caisse (crate), dédiée à ce trait :

cargo new animal --lib 
     Created library `Animal` package 

On a utilisé l’option --lib, qui nous permet de créer une librairie (contrairement aux projets précédents qui étaient des exécutables).

Regardons le code par défaut dans le fichier lib.rs :

#[cfg(test)] 
mod tests { 
    #[test] 
    fn it_works() { 
        let result = 2 + 2; 
        assert_eq!(result, 4); 
    } 
} 

On voit qu’il y a dans ce code un module tests qui contient une fonction it_works.

La clause #[cfg(test)] ainsi que le #[test] indiquent que ce code est utilisable via la commande cargo test.

Nous allons nettoyer un peu tout cela de manière à pouvoir l’utiliser depuis l’extérieur de la caisse. On retire les lignes relatives au test et on remplace cette ligne :

assert_eq!(result, 4); 

par celle-ci :

assert_eq!(result, 3); 

Ainsi, quand on appellera la fonction, on obtiendra un message de panique lié à l’assert (en effet, 4 n’est pas égal à 3). Notre nouveau code devient pour le moment le suivant :

#![allow(unused)] 
 
pub mod tests { 
 
    pub fn it_works() { 
        let result = 2 + 2; 
        assert_eq!(result, 3); 
    } 
} 

Pour l’instant, on cherche seulement à vérifier que notre caisse est utilisable depuis l’extérieur. Bien évidemment, nous renommerons le module et ajouterons les fonctions qui nous intéressent.

Créons à présent un projet exécutable utilisant la caisse précédente. Nous nommons cet exécutable...

Utiliser un trait en paramètre

1. Introduction

On peut à présent chercher à avoir une fonction qui prend en paramètre un trait. En effet, cela permet d’avoir en réalité toutes sortes de structures éligibles pour être passées en paramètre. Dans notre exemple, on passerait un Animal en paramètre de la fonction, c’est-à-dire que l’on pourrait aussi bien avoir un Chat qu’un Chien. En science informatique, on parle parfois, pour désigner ce mécanisme, de transtypage.

2. Exemple de trait en paramètre

Nous allons ajouter une fonction afficher dans notre exécutable client. Le but est d’appeler la méthode afficher du trait Animal. On nomme cette méthode afficher_trait :

pub fn afficher_trait(animal: &impl Animal) { 
    animal.afficher(); 
} 

Cette manière d’écrire la fonction est particulièrement claire. Elle correspond à ce que l’on appelle du sucre syntaxique. Autrement dit, c’est une syntaxe plus intuitive, plus facilement utilisable que la syntaxe en théorie. En effet, en théorie, on l’écrirait ainsi :

pub fn afficher_trait<T: Animal>(animal: &T) { 
    animal.afficher(); 
} 

C’est exactement la même chose : mais comme on le voit...

Notion de trait lié

1. Introduction

Nous sommes ici dans la manière d’écrire les choses. Dans la plupart des cas, le sucre syntaxique est plus pratique pour écrire des passages de traits en paramètres, mais ce n’est pas toujours le cas. En effet, quid des situations où l’on désire un paramètre qui implémente plusieurs traits différents ?

2. Plusieurs traits liés différents pour un même paramètre

a. Introduction

Quand on veut qu’un paramètre implémente plusieurs traits, la syntaxe du trait lié est particulièrement utile. Prenons l’exemple de la structure Tortue qui va implémenter (sans surprise) le trait Animal, mais également le trait prédéfini Display.

b. Le trait prédéfini Display

Un mot sur ce dernier : la plupart des types, y compris fondamentaux comme i64, u32, etc., implémentent ce trait qui constitue une bonne alternative au trait prédéfini Debug. Il permet d’afficher une synthèse à un certain format de l’objet courant.

La documentation en ligne de ce trait se trouve à cette adresse : https://doc.rust-lang.org/std/fmt/trait.Display.html

Le code de ce trait est le suivant avec la méthode fmt (format) de formatage à implémenter :

pub trait Display { 
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>; 
} 

c. Création de la structure Tortue

Dans notre projet client, on crée un nouveau fichier nommé tortue.rs.

Dans ce dernier, on commence par référencer...

Un trait comme valeur de retour

1. Introduction

Nous avons abordé de façon approfondie le trait en tant que paramètre. Voyons à présent le trait comme valeur de retour d’une fonction. La syntaxe est assez proche de ce que nous avons vu pour le moment, en particulier lorsque nous avons travaillé avec le mot-clé impl.

2. Exemple support

On crée une fonction qui doit nous retourner un objet qui implémente le trait Animal :

fn obtenir_animal(b : bool) -> impl Animal { 
 
    let nom : String = "Mimi l'animal".to_string(); 
    animal::mod_animal::Chien::creer(nom) 
} 

Cette fonction compile et fonctionne. Comme vous pouvez le constater, on indique explicitement que la valeur de retour implémente le trait Animal (impl Animal), mais on se contente de renvoyer une instance de Chien (qui implémente effectivement le trait Animal).

Or, cette solution atteint rapidement ses limites car si la fonction est susceptible de renvoyer des types différents, bien qu’implémentant tous le même trait attendu (Animal ici), la compilation échouera.

En effet, si on écrit ceci :

fn obtenir_animal(b : bool) -> impl Animal { 
 
    let nom : String = "Mimi l'animal".to_string(); 
    if b == true { ...

Points d’architecture impliquant les traits

1. Traits, génériques et structures

Selon le contexte fonctionnel et ce que l’on souhaite faire, nous avons à présent plusieurs manières de proposer une architecture impliquant traits et génériques.

Imaginons un trait nous permettant de définir une course cycliste d’un jour :

// Course d'un jour. 
trait course_jour { 
 
} 

On aura alors plusieurs structures à même d’implémenter ce trait. On peut imaginer les structures ParisRoubaix, ParisTroyes, RedonRedon, etc.

Fort de cette première définition, on se dit que l’on peut construire à présent une structure à même d’accueillir une course par étapes. Pour cela, la structure inclurait un vecteur contenant chaque étape. Comment peut-on combiner la structure Course par étapes avec le trait Course d’un jour ?

Première proposition :

// Course par étapes. 
struct course_étapes<J : course_jour> { 
   etapes : Vec<J> 
}  

On a un vecteur contenant plusieurs courses, mais toujours issues de la même structure. Ce n’est pas idéal d’avoir n fois Paris-Auxerre à courir. En effet, on a forcément dans notre vecteur uniquement des instances d’une même structure. ...