Aller au contenu

Les traits en Rust : des comportements hérités

Les traits en Rust servent à définir un comportement commun.

Un trait de caractère en français est un ensemble d’attitudes qui vont définir un comportement.

Eh bien là, c’est la même chose, un trait en Rust est un ensemble de fonctions qui vont définir un comportement que possède un type.

C’est un peu l’équivalent de l’héritage en orienté objet.

Pour faire cela on va créer notre premier trait

struct Pirate{
name:String,
age: u8
}
// Définition du trait
trait Parler {
fn say_hello(&self);
fn say_ciao(&self);
}
// Implémentation du trait
impl Parler for Pirate {
fn say_hello(&self){
println!("Qu'est ce que tu regardes ?")
}
fn say_ciao(&self){
println!("Tires toi d'ici")
}
}

Dans le code précédent on a défini un trait ‘Parler’ qui va obliger les types qui vont utiliser ce trait à implémenter la méthode say_hello et say_ciao.

Si on ne le fait pas, le compilateur va bien nous rappeller que Pirate doit implémenter une méthode say_hello et say_ciao.

Dans la définition du trait tu vois que les méthodes n’ont pas de corps, en effet on définit juste leur signature et après cela on termine par un ’;’.

Cela indique que les types qui vont utiliser le trait devront impérativement définir ces méthodes avec la même signature, peu importe le corps de la fonction.

Rust saura ainsi qu’on peut utiliser les méthodes .say_hello() et .say_ciao() sur tous les types qui vont implémenter le trait.

Bon à ce stade tu peux te demander pourquoi on n’a pas juste implémenté ces méthodes pour le type pirate au lieu d’utiliser un trait ?

La réponse vient du fait que lorsque plusieurs types doivent avoir les mêmes méthodes ou fonctions, il est plus simple de définir cela au niveau d’un trait afin de centraliser tout ça.

Comme ça, si on veut modifier quelque chose dans la fonction, on pourra le faire une seule fois et le changement se fera sur tous les types qui vont implémenter le trait, pas besoin de faire les modifs dans 15 types différents.

Voici concrètement ce qui se passe quand un autre type doit lui aussi implémenter les méthodes say_hello et say_ciao.

struct Pirate{
name:String,
age: u8
}
struct Policier{
name:String,
age:u8
}
trait Parler {
fn say_hello(&self);
fn say_ciao(&self);
}
impl Parler for Pirate {
fn say_hello(&self){
println!("Qu'est ce que tu regardes ?")
}
fn say_ciao(&self){
println!("Tires toi d'ici")
}
}
impl Parler for Policier {
fn say_hello(&self){
println!("Bonjour !")
}
fn say_ciao(&self){
println!("Aurevoir !")
}
}

Implémentation par défaut

On peut aussi définir une fonction avec un corps à l’intérieur d’un trait !

Ce sera le corps par défaut de la fonction si elle n’est pas redéfinie lors de l’implémentation du trait.

struct Pirate{
name:String,
age: u8
one_eyed: bool,
}
struct Policier{
name:String,
age:u8,
grade: u8
}
struct Marchand{
name:String,
age:u8,
fortune: u32
}
trait Parler {
fn say_hello(&self){
println!("Bonjour !");
}
fn say_ciao(&self){
println!("Aurevoir !");
};
}
impl Parler for Pirate {
fn say_hello(&self){
println!("Qu'est ce que tu regardes ?")
}
fn say_ciao(&self){
println!("Tires toi d'ici")
}
}
impl Parler for Policier {}
impl Parler for Marchand {}

Comme tu peux le voir au-dessus, on ne redéfinit pas say_hello ou say_ciao lors de l’implémentation du trait pour le Policier et le Marchand, donc leur méthode say_hello et say_ciao sera celle définie dans le trait.

Les traits comme paramètres de fonction

Comme Rust est maintenant au courant qu’un type qui implémente le trait ‘Parler’ possède les méthodes say_hello et say_ciao, on va pouvoir créer des fonctions qui attendent en paramètre quelque chose qui implémente un certain trait.

Pour ce faire on va utiliser la syntaxe suivante :

struct Pirate{
name:String,
age: u8,
one_eyed: bool,
}
struct Policier{
name:String,
age:u8,
grade: u8
}
struct Marchand{
name:String,
age:u8,
fortune: u32
}
fn details(character: &impl Parler){
println!("Voilà comment je dis bonjour: {}", item.say_hello());
}
trait Parler {
fn say_hello(&self){
println!("Bonjour !");
}
fn say_ciao(&self){
println!("Aurevoir !");
};
}
impl Parler for Pirate {
fn say_hello(&self){
println!("Qu'est ce que tu regardes ?")
}
fn say_ciao(&self){
println!("Tires toi d'ici")
}
}
impl Parler for Policier {}
impl Parler for Marchand {}

Encore une fois on peut se demander pourquoi ‘&impl Parler’ en paramètre ? Eh bien, c’est tout simplement pour dire qu’on attend une référence vers quelque chose qui implémente le trait Parler.

On veut une référence pour ne pas créer de move lors de l’appel de la fonction qui nous détruirait l’élément qui se cache derrière character à la sortie de la fonction.

Trait bound syntax

Il existe une autre syntaxe qui utilise la syntaxe des generics pour faire ce qu’on vient de faire juste avant, elle est plus longue et verbeuse, mais je te la montre, car tu risques de la voir passer dans certains programmes :

fn details<T: Parler>(character: &T){
println!("Voilà comment je dis bonjour: {}", item.say_hello());
}

Tout ça pour dire que T est quelque chose d’un certain type qui implémente le trait parler, et au final character est de type : référence vers T, c’est-à-dire quelque chose qui implémenter le trait Parler.

Bon c’est beaucoup moins élégant, mais ça existe aussi.

Spécifier plusieurs traits avec le +

Imaginons qu’on veuille que notre fonction ‘details’ veuille prendre en paramètre quelque chose qui implémenter le trait Parler ET le trait Debug par exemple, pour ce faire on aurait juste à écrire :

pub fn details(character: &(impl Parler + Debug)) {...}

La syntaxe avec le + nous permet d’ajouter plusieurs traits.

Encore une autre syntaxe avec where

Au lieu d’écrire le code suivant :

fn some_function(t: &(impl Display + Clone), u: &(impl Clone + impl Debug)) -> i32 {...}

On peut aussi écrire ça de cette façon, ou on utilise la syntaxe des generics, puis dans le where on explicite que T doit implémenter Display et Clone, alors que U doit implémenter Clone et Debug :

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

Personnellement je préfère la première syntaxe qui reste la plus claire.

Retourner un type qui implémente un trait

On peut aussi retourner un quelque chose qui implémente un trait dans une fonction mais en revanche il faudra retourner toujours le même type à cause des restrictions du compilateur, il existe une technique pour retourner des types différents mais on le verra plus tard.

Donc malheureusement ce code n’est pas correct:

// ❌ ne compile pas
fn create_character(text:String) -> &impl Parler{
if text == String::from("Pirate"){
return Pirate{name:String::from("Anonymous Pirate"), age:30, one_eyed:true}
}
else if text == String::from("Policier"){
return Policier{name:String::from("Anonymous Policier"), age:30, grade:4}
}
return Marchand{name:String::from("Anonymous Marchand"),age:30, fortune:500}
}

Implémentation conditionnelle

Grâce aux generics on va pouvoir utiliser un bloc impl de façon conditionnelle, alors oui ça parait bizarre, mais c’est tout à fait possible et même très pratique.

Dans l’exemple suivant on va créer un struct Pair qui possède 2 attributs de même type, x et y.

Ici seul les ‘Pair’ qui auront un type T qui va implémenter le trait ‘Display’ et ‘PartialOrd’ auront la méthode ‘cmp_display’

use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
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);
}
}
}

De la même manière on pourra conditionnellement implémenter un trait selon les traits déjà implémentés du type sur lequel on travaille, par exemple ici on implémente le trait ToString uniquement sur tous les types qui implémentent déjà le trait Display.

impl<T: Display> ToString for T {
...
}