Acheter version papier

Rust

Fork me on GitHub

II. Spécificités de Rust

2. Les traits

Commençons par donner une rapide définition : un trait est une interface abstraite que des types peuvent implémenter et qui est composé d'éléments associés (méthodes, types et constantes).

Dans le chapitre sur les structures, il nous fallait implémenter la méthode drop pour pouvoir implémenter le trait Drop. Et au cas où vous ne vous en doutiez pas, sachez que les traits sont utilisés partout en Rust et en sont une des briques fondamentales. On en retrouve même sur des types primitifs comme les i32 ou les f64 !

On va prendre un exemple tout simple : additionner deux f64. La doc nous dit ici que le trait Add a été implémenté sur le type f64. Ce qui nous permet de faire :

Runlet valeur = 1f64;

println!("{}", valeur + 3f64);

Add était un trait importé "par défaut". Il n'est donc pas nécessaire de l'importer pour pouvoir se servir de lui. Cependant, dans la majorité des cas, il faudra importer un trait pour pouvoir utiliser les méthodes/constantes/types qui y sont associées. Exemple :

Run// On importe le trait FromStr...
use std::str::FromStr;

// Ce qui nous permet d'avoir accès à la méthode from_str.      
println!("{}", f64::from_str("3.6").expect("conversion failed"));

Facile n'est-ce pas ? Les traits fournis par la bibliothèque standard et implémentés sur les types standards apportent beaucoup de fonctionnalités. Si jamais vous avez besoin de quelque chose, il y a de fortes chances que ça existe déjà. À vous de chercher.

Je vous ai montré comment importer et utiliser un trait, maintenant il est temps de voir comment en créer un !

Créer un trait

C'est relativement similaire à la création d'une structure :

Runtrait Animal {
    fn get_espece(&self) -> &str;
}

Facile, n'est-ce pas ? Maintenant un petit exemple :

Runtrait Animal {
    fn get_espece(&self) -> &str;
    fn get_nom(&self) -> &str;
}

struct Chien {
    nom: String,
}

impl Animal for Chien {
    fn get_espece(&self) -> &str {
        "Chien"
    }

    fn get_nom(&self) -> &str {
        &self.nom
    }
}

struct Chat {
    nom: String,
}

impl Animal for Chat {
    fn get_espece(&self) -> &str {
        "Chat"
    }

    fn get_nom(&self) -> &str {
        &self.nom
    }
}

let chat = Chat { nom: String::from("Fifi") };
let chien = Chien { nom: String::from("Loulou") };

println!("{} est un {}", chat.get_nom(), chat.get_espece());
println!("{} est un {}", chien.get_nom(), chien.get_espece());

Je tiens à vous rappeler qu'il est tout à fait possible d'implémenter un trait disponible dans la bibliothèque standard comme je l'ai fait avec le trait Drop.

Il est aussi possible d'écrire une implémentation "par défaut" de la méthode directement dans le trait. Ça permet d'éviter d'avoir à réécrire la méthode pour chaque objet sur lequel le trait est implémenté. Exemple :

Runtrait Animal {
    fn get_espece(&self) -> &str;

    fn presentation(&self) -> String {
        format!("Je suis un {} !", self.get_espece())
    }
}

impl Animal for Chat {
    fn get_espece(&self) -> &str {
        "Chat"
    }
}

Ici, je ne définis que la méthode get_espece car presentation fait déjà ce que je veux.

Vous n'en voyez peut-être pas encore l'intérêt mais sachez cependant que c'est vraiment très utile. Quoi de mieux qu'un autre exemple pour vous le prouver ?

Runfn afficher_infos<T: Animal>(animal: &T) {
    println!("{} est un {}", animal.get_nom(), animal.get_espece());
}

"C'est quoi ce <T: Animal> ?!"

Pour ceux qui ont fait du C++ ou du Java, c'est relativement proche des templates. Pour les autres, sachez juste que les templates ont été inventés pour permettre d'avoir du code générique (aussi appelé polymorphisme). Prenons un autre exemple :

Runfn affiche_chat(chat: &Chat) {
    println!("{} est un {}", chat.get_nom(), chat.get_espece());
}

fn affiche_chien(chien: &Chien) {
    println!("{} est un {}", chien.get_nom(), chien.get_espece());
}

Dans le cas présent, ça va, cela ne représente que deux fonctions. Maintenant si on veut ajouter 40 autres espèces d'animaux, on devrait écrire une fonction pour chacune ! Pas très pratique... Utiliser la généricité est donc la meilleure solution. Et c'est ce dont il sera question dans le prochain chapitre !

Les supertraits

On appelle supertrait (en un seul mot) les traits qui sont requis pour l'implémentation d'un trait.

Runtrait Machine {}

// On ajoute "Machine" en tant que supertrait de "Car".
trait Car: Machine {}

struct FastCar;

impl Car for FastCar {}

Si on essaie de compiler ce code, nous aurons cette erreur:

error[E0277]: the trait bound `FastCar: Machine` is not satisfied
the trait `Machine` is not implemented for `FastCar`

Donc si l'on souhaite implémenter le trait Car sur un type, il faudra obligatoirement que ce type implémente aussi le trait Machine. Prenons l'exemple de la crate sysinfo : elle fournit des informations système, cependant chaque système supporté doit avoir sa propre implémentation (car chacun fournit des APIs très différente pour récupérer les même informations). Pour s'assurer que chaque plateforme fournit bien les même fonctionnalités, elle utilise des traits. Cependant, on veut aussi que ces types implémentent aussi certains traits comme Debug. Hé bien c'est possible grâce aux supertraits.

Autre information intéressante, le trait peut utiliser tout ce qui est défini dans le supertrait dans ses implémentations par défaut :

Runtrait Machine {
    fn serial_id(&self) -> u32;
}

trait Car: Machine {
    fn modele(&self) -> String;
    fn type_de_voiture(&self) -> String {
        // Ici nous utilisons la méthode "serial_id" qui vient du
        // supertrait "Machine".
        format!("{} (serial ID: {})", self.modele(), self.serial_id())
    }
}

Ce n'est donc pas de l'héritage bien que cela puisse y ressembler. Plutôt un moyen d'ajouter des conditions d'implémentation sur un trait pour s'assurer qu'il a bien tous les pré-requis souhaités.

Les derive traits

Rust fournit la possibilité d'avoir des implémentations de traits "par défaut". Si tous les champs d'une structure implémentent le trait Debug, il est possible de ne pas avoir à implémenter le trait avec une implémentation "normale" mais d'utiliser à la place un derive trait :

Run// Le trait Debug est implémenté avec le "derive".
#[derive(Debug)]
struct Foo {
    a: u32,
    b: f64,
}

let foo = Foo { a: 0, b: 1. };
// On peut donc s'en servir directement.
println!("{:?}", foo);

Il y a plusieurs traits qui peuvent être implémentés de la sorte tels que Display, Clone, Ord, PartialOrd, Eq, PartialEq... Et certaines crates en ajoutent encore d'autres ! Tout cela est possible grâce aux macros procédurales (aussi appelées "proc-macros") mais c'est un concept avancé de Rust donc nous y reviendrons dans la dernière partie de ce livre.

Utilisation de traits

Avant de conclure ce chapitre, j'en profite maintenant pour vous montrer quelques utilisations de traits comme Range (que l'on avait déjà rapidement abordé dans le chapitre des boucles) et Index. Ce dernier peut vous permettre de faire :

Runlet s = "hello";

println!("{}", s);
println!("{}", &s[0..2]);
println!("{}", &s[..3]);
println!("{}", &s[3..]);

Ce qui donnera :

hello
he
hel
lo

Cela fonctionne aussi sur les slices :

Run// On crée un slice contenant 10 '\0'.
let v: &[u8] = &[0; 10];

println!("{:?}", &v[0..2]);
println!("{:?}", &v[..3]);
println!("{:?}", &v[3..]);

Ce qui donne :

[0, 0]
[0, 0, 0]
[0, 0, 0, 0, 0, 0, 0]

Voilà qui devrait vous donner un petit aperçu de tout ce qu'il est possible de faire avec les traits. Il est maintenant temps de parler de la généricité.