backend / rust
Traits et génériques
Explication
Ce que vous allez apprendre
- Définir un trait comme un ensemble de comportements partagés, avec des méthodes par défaut
- Écrire une fonction générique contrainte par un trait bound (
T: Trait) - Comprendre la différence entre monomorphisation (générique) et dispatch dynamique (
dyn Trait) - Créer une struct générique réutilisable pour plusieurs types
- Utiliser un type associé dans un trait, comme le fait
Iteratordans la stdlib
Dans quel contexte ?
Un développeur veut écrire une fonction presenter qui fonctionne aussi bien avec un Chien qu'avec un Chat, deux types qui n'ont aucun lien de parenté direct mais partagent un comportement commun (faire un bruit, se présenter). Sans langage à traits, il faudrait dupliquer la fonction pour chaque type, ou passer par une hiérarchie de classes rigide. Les traits résolvent exactement ce problème, avec en plus zéro coût à l'exécution dans le cas générique.
D'abord, un trait ressemble beaucoup à une interface Go, avec un bonus
trait Animal { fn nom(&self) -> String; fn bruit(&self) -> String { String::from("...") } } déclare une méthode obligatoire (nom) et une méthode avec une implémentation par défaut (bruit), que chaque type peut choisir de redéfinir ou de laisser telle quelle. C'est une flexibilité qu'une interface Go pure, sans implémentation par défaut, n'offre pas nativement.
Une fois un trait défini, deux façons bien distinctes de l'utiliser en paramètre existent
fn presenter_animal<T: Animal>(a: &T) (générique avec trait bound) et fn presenter_animal2(a: &impl Animal) sont deux syntaxes équivalentes pour le même résultat : le compilateur génère, à la compilation, une version spécialisée de la fonction pour chaque type concret réellement utilisé. C'est la "monomorphisation", qui élimine tout coût d'indirection à l'exécution.
| Approche | Résolution | Coût à l'exécution |
|---|---|---|
T: Trait (générique) | À la compilation (monomorphisation) | Aucun, code spécialisé par type |
dyn Trait (dans un Box) | À l'exécution (dispatch dynamique) | Une indirection, comme une interface Go |
Prérequis
Il faut être à l'aise avec les structs, impl et le pattern matching (leçons précédentes) : les traits s'implémentent exactement comme des méthodes classiques, avec une syntaxe impl Trait for Type.
Il reste un cas où la monomorphisation ne suffit pas : les collections hétérogènes
Un Vec<Box<dyn Animal>> peut contenir à la fois des Chien et des Chat dans la même collection — impossible avec des generics purs, qui exigent un seul type concret par instanciation. dyn Trait sacrifie la performance de la monomorphisation contre cette flexibilité de mélanger des types différents au même endroit.
Piège fréquent
Utiliser dyn Trait systématiquement "au cas où" sans en avoir réellement besoin ajoute un coût d'indirection et empêche certaines optimisations du compilateur (comme l'inlining). Réserve dyn Trait aux cas où le type concret est réellement inconnu ou variable à l'exécution ; préfère des generics dans tous les autres cas.
Enfin, un pattern avancé mais omniprésent : les types associés
trait Conteneur { type Item; fn recuperer(&self, index: usize) -> Option<&Self::Item>; } déclare un type associé (Item), déterminé par chaque implémentation concrète plutôt que passé en paramètre générique. C'est exactement le mécanisme qu'utilise le trait Iterator de la stdlib (type Item), que tu approfondiras dans la prochaine leçon.
Bonne pratique
Ajoute #[derive(Debug, Clone, PartialEq, PartialOrd)] à tes structs simples dès leur création : ces traits dérivés automatiquement couvrent la grande majorité des besoins courants (affichage, duplication, comparaison) sans code manuel à écrire ni à maintenir.
Maintenant que tu comprends les traits, la prochaine leçon les met directement en application avec les closures et les itérateurs, l'un des piliers du code Rust idiomatique au quotidien.
Commandes & code
Traits et génériques
Les traits définissent un comportement partagé ; les génériques écrivent du code réutilisable et type-safe.
// Trait : ensemble de methodes qu'un type peut implementer
trait Animal {
fn nom(&self) -> String;
// Methode par defaut : implementation fournie, redefinissable
fn bruit(&self) -> String {
String::from("...")
}
fn presenter(&self) -> String {
format!("{} fait {}", self.nom(), self.bruit())
}
}
struct Chien { nom: String }
struct Chat { nom: String }
impl Animal for Chien {
fn nom(&self) -> String { self.nom.clone() }
fn bruit(&self) -> String { String::from("Wouf") }
}
impl Animal for Chat {
fn nom(&self) -> String { self.nom.clone() }
// utilise le bruit() par defaut ("...")
}
// Fonction generique avec contrainte de trait (trait bound)
fn presenter_animal<T: Animal>(a: &T) -> String {
a.presenter()
}
// Syntaxe equivalente, "impl Trait" en position d'argument
fn presenter_animal2(a: &impl Animal) -> String {
a.presenter()
}
// dyn Trait : dispatch dynamique, permet un Vec de types differents
fn presenter_tous(animaux: &[Box<dyn Animal>]) {
for a in animaux {
println!("{}", a.presenter());
}
}
// Struct generique
struct Paire<T> {
premier: T,
second: T,
}
impl<T: std::fmt::Display + PartialOrd> Paire<T> {
fn plus_grand(&self) -> &T {
if self.premier >= self.second { &self.premier } else { &self.second }
}
}
// Trait generique avec type associe (avance mais tres courant, ex: Iterator)
trait Conteneur {
type Item;
fn recuperer(&self, index: usize) -> Option<&Self::Item>;
}
struct MaListe<T> { elements: Vec<T> }
impl<T> Conteneur for MaListe<T> {
type Item = T;
fn recuperer(&self, index: usize) -> Option<&T> {
self.elements.get(index)
}
}
fn main() {
let chien = Chien { nom: String::from("Rex") };
let chat = Chat { nom: String::from("Felix") };
println!("{}", presenter_animal(&chien));
println!("{}", presenter_animal2(&chat));
let animaux: Vec<Box<dyn Animal>> = vec![
Box::new(Chien { nom: String::from("Rex") }),
Box::new(Chat { nom: String::from("Felix") }),
];
presenter_tous(&animaux);
let paire = Paire { premier: 10, second: 25 };
println!("{}", paire.plus_grand());
let liste = MaListe { elements: vec!["a", "b", "c"] };
println!("{:?}", liste.recuperer(1));
// Trait derives frequemment utilises
#[derive(Debug, Clone, PartialEq, PartialOrd)]
struct Version(u32, u32, u32);
let v1 = Version(1, 2, 0);
let v2 = Version(1, 3, 0);
println!("{}", v1 < v2); // true, grace a PartialOrd derive
}Résumé
- Un trait ressemble à une interface Go : méthodes requises, avec possibilité de méthodes par défaut.
T: Trait(générique + trait bound) résout au monomorphisme à la compilation, sans coût à l'exécution.dyn Trait(dans unBox) permet le dispatch dynamique quand les types concrets varient à l'exécution.#[derive(...)]génère automatiquement des implémentations de traits standards (Debug,Clone,PartialOrd...).
Exercices pratiques
Mission : un moteur de notifications multi-canal trop rigide
Objectif : Refactoriser un systeme de notifications duplique en un trait partage, et choisir a bon escient entre generique et dyn Trait.
Contexte
Un systeme de notifications gere aujourd'hui deux canaux, NotifEmail et NotifSms, avec deux fonctions quasi identiques envoyer_email(n: &NotifEmail) et envoyer_sms(n: &NotifSms), chacune formatant et 'envoyant' le message de facon similaire. Le produit veut ajouter un troisieme canal, NotifPush, et surtout pouvoir stocker les trois types de notifications dans une seule file d'attente Vec a traiter dans l'ordre d'arrivee, peu importe leur canal.