Traits en Rust : définir les comportements partagés
1. Aperçu
En Rust, un trait est une façon de décrire un comportement que plusieurs types de données partagent. C’est un peu comme une interface en Java, mais avec des capacités supplémentaires : implémentation par défaut, trait bounds pour les génériques, et un choix entre static dispatch ou dynamic dispatch.
Après avoir lu cet article, vous saurez comment définir un trait, l’implémenter pour une struct, utiliser un trait comme paramètre et valeur de retour, créer une liste d’objets de différents types avec dyn Trait. Vous saurez aussi pourquoi le compilateur refuse parfois votre code.
Tous les exemples fonctionnent avec Rust stable (édition 2021 et versions ultérieures) et ne nécessitent aucune bibliothèque externe.
2. Définir et implémenter un trait
Supposons qu’un système de vente ait deux types Order et Customer. Nous voulons que les deux puissent se « résumer » eux-mêmes en une courte chaîne.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Order {
pub id: u32,
pub total: u64, // unité : dông
}
pub struct Customer {
pub name: String,
pub email: String,
}
impl Summary for Order {
fn summarize(&self) -> String {
format!("Commande #{} - {} dông", self.id, self.total)
}
}
impl Summary for Customer {
fn summarize(&self) -> String {
format!("Client {} <{}>", self.name, self.email)
}
}
fn main() {
let order = Order { id: 42, total: 350_000 };
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
println!("{}", order.summarize());
println!("{}", customer.summarize());
}
Explication :
pub trait Summarydéclare un trait avec une méthodesummarize. Ici, il n’y a que la signature, pas le corps de la fonction.impl Summary for Orderfournit l’implémentation spécifique pourOrder. Chaque type doit implémenter toutes les méthodes qui n’ont pas de corps dans le trait.&selfsignifie que la méthode emprunte l’objet, sans prendre la propriété.
Résultat :
Commande #42 - 350000 dông
Client An <an@example.com>
Remarque : Pour appeler
order.summarize()à partir d’un autre module, le traitSummarydoit être dans la portée (use crate::Summary;). C’est une erreur très courante. Le compilateur dit « no method namedsummarizefound » même si vous avez bien implémenté.
3. Implémentation par défaut
Un trait peut fournir un corps de fonction par défaut. Chaque type implémentant le trait utilisera la version par défaut, ou peut la remplacer s’il le souhaite.
pub trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("[Résumé] {}", self.title())
}
}
impl Summary for Order {
fn title(&self) -> String {
format!("Commande #{}", self.id)
}
// Remplacer la version par défaut
fn summarize(&self) -> String {
format!("{} - {} dông", self.title(), self.total)
}
}
impl Summary for Customer {
fn title(&self) -> String {
format!("Client {}", self.name)
}
// Ne pas remplacer summarize → utiliser la version par défaut
}
En relançant main à la section 2, le résultat est :
Commande #42 - 350000 dông
[Résumé] Client An
Ce qui est remarquable, c’est que la méthode par défaut summarize peut appeler title(), bien que title() n’ait pas de corps dans le trait. C’est un motif utile. Vous demandez simplement au type concret de fournir la partie minimale (title), et le trait construit le reste dessus.
À partir de là, l’article utilise cette version du trait pour les exemples suivants.
4. Trait comme paramètre
4.1. La syntaxe impl Trait
Le moyen le plus simple d’écrire une fonction qui accepte « n’importe quel type ayant Summary » :
fn print_summary(item: &impl Summary) {
println!("Notification : {}", item.summarize());
}
fn main() {
let order = Order { id: 7, total: 120_000 };
print_summary(&order);
}
Notification : Commande #7 - 120000 dông
4.2. Trait bounds
&impl Summary est en fait une syntaxe abrégée pour un générique avec trait bound :
fn print_summary<T: Summary>(item: &T) {
println!("Notification : {}", item.summarize());
}
Les deux approches diffèrent quand il y a plusieurs paramètres :
// a et b peuvent être de deux types différents
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }
// a et b doivent être du même type T
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }
Appeler compare_strict(&order, &customer) causera une erreur de compilation car Order et Customer ne sont pas du même type.
4.3. Plusieurs bounds et la clause where
Quand vous avez besoin de plusieurs traits à la fois, joignez-les avec +. Quand la liste est longue, changez à where pour plus de lisibilité :
use std::fmt::{Debug, Display};
fn log_item<T>(item: &T)
where
T: Summary + Display + Debug,
{
println!("{item} | {item:?} | {}", item.summarize());
}
Cette fonction accepte uniquement les types implémentant les trois traits. S’il en manque un, le compilateur signale l’erreur immédiatement au site d’appel, sans attendre le runtime.
Les génériques en Rust utilisent la monomorphisation. Le compilateur génère une copie distincte de log_item pour chaque type concret. Ainsi, l’appel n’a pas de coût runtime (static dispatch). En contrepartie, le binaire peut être plus volumineux.
5. Retourner impl Trait
Vous pouvez aussi retourner « un type quelconque implémentant Summary » sans nommer le type :
fn latest_order() -> impl Summary {
Order { id: 100, total: 999_000 }
}
fn main() {
println!("{}", latest_order().summarize());
}
Commande #100 - 999000 dông
C’est particulièrement utile avec les closures et les itérateurs. Leurs vrais types sont souvent très longs ou impossibles à écrire.
Remarque :
impl Traiten position de retour est toujours un seul type concret. Le code suivant ne compile pas :fn pick(vip: bool) -> impl Summary { if vip { Customer { name: "An".into(), email: "an@example.com".into() } } else { Order { id: 1, total: 0 } // erreur : `if` and `else` have incompatible types } }Pour retourner plusieurs types différents, vous avez besoin de
Box<dyn Summary>(section 6).
6. Trait object avec dyn Trait
Les génériques exigent que le type soit connu à la compilation. Parfois, vous avez besoin d’une liste contenant à la fois Order et Customer. Utilisez alors un trait object :
fn main() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 50_000 }),
Box::new(Customer {
name: "Bình".to_string(),
email: "binh@example.com".to_string(),
}),
];
for item in &items {
println!("{}", item.summarize());
}
}
Commande #1 - 50000 dông
[Résumé] Client Bình
Explication :
dyn Summaryest un type dont la taille n’est pas connue à la compilation. C’est pourquoi il doit être placé derrière un pointeur commeBox,&ouRc.- Chaque trait object se compose de deux parties : un pointeur vers les données et un pointeur vers la vtable. L’appel à
summarize()est recherché dans la vtable au runtime (dynamic dispatch). - La fonction
pickde la section 5 peut être corrigée en changeant le type de retour enBox<dyn Summary>et en enveloppant chaque branche dansBox::new(...).
Comparaison rapide :
| Critère | Generics / impl Trait |
dyn Trait |
|---|---|---|
| Dispatch | Static, peut être inliné | Dynamic via vtable |
| Liste de plusieurs types | Non | Oui |
| Taille du binaire | Plus grande | Plus petite |
Remarque : Pas tous les traits ne peuvent être utilisés avec
dyn. Un trait doit être dyn-compatible (anciennement appelé object safe). Un trait n’est pas dyn-compatible s’il a une méthode générique, par exemplefn send<T>(&self, t: T). Un trait perd aussi cette propriété s’il a une méthode retournantSelfsans le boundwhere Self: Sized. C’est parce que la vtable ne peut pas contenir une infinité de versions d’une méthode générique, et le compilateur ne sait pas quel est le vraiSelf.
7. Derive et les traits standard
La bibliothèque standard a de nombreux traits familiers. Certains traits sont générés automatiquement avec #[derive] :
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Order {
pub id: u32,
pub total: u64,
}
impl fmt::Display for Order {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.id)
}
}
fn main() {
let a = Order { id: 5, total: 10_000 };
let b = a.clone();
println!("{a}"); // Display
println!("{a:?}"); // Debug
println!("{}", a == b); // PartialEq
println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7
Nous n’avons pas implémenté ToString, pourtant nous pouvons appeler a.to_string(). C’est grâce à l’implémentation globale (blanket implementation) dans la bibliothèque standard :
impl<T: Display + ?Sized> ToString for T { /* ... */ }
Cela signifie que tout type ayant Display a automatiquement ToString. Vous pouvez aussi écrire une implémentation globale pour votre propre trait, par exemple impl<T: Display> Summary for T. Cependant, soyez prudent, car elle entrera en conflit avec toute autre implémentation distincte.
8. Orphan rule et newtype pattern
Rust a la règle orpheline (orphan rule). Vous ne pouvez écrire impl Trait for Type que si le trait ou le type est défini dans votre crate. Cette règle empêche deux crates d’implémenter un trait pour un type de deux façons différentes.
L’exemple suivant est rejeté car Display et Vec appartiennent tous deux à la bibliothèque standard :
// erreur E0117 : seuls les traits définis dans la crate actuelle peuvent être implémentés pour les types définis en dehors de la crate
impl std::fmt::Display for Vec<Order> { /* ... */ }
La solution est le newtype pattern. Vous enveloppez le type externe dans une struct à vous :
pub struct Cart(pub Vec<Order>);
impl fmt::Display for Cart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total: u64 = self.0.iter().map(|o| o.total).sum();
write!(f, "Panier : {} commandes, total {} dông", self.0.len(), total)
}
}
fn main() {
let cart = Cart(vec![
Order { id: 1, total: 100_000 },
Order { id: 2, total: 250_000 },
]);
println!("{cart}");
}
Panier : 2 commandes, total 350000 dông
Cart est un type local, donc il est autorisé d’implémenter Display. Le newtype n’a pas de coût runtime : Cart a la même disposition en mémoire que Vec<Order>.
9. Tester un trait
Les tests en Rust se trouvent dans le même fichier, dans un module avec #[cfg(test)]. Nous testons à la fois les implémentations remplacées et par défaut, et aussi le cas d’appel via trait object :
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_overrides_default_summary() {
let order = Order { id: 42, total: 350_000 };
assert_eq!(order.summarize(), "Commande #42 - 350000 dông");
}
#[test]
fn customer_uses_default_summary() {
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
assert_eq!(customer.summarize(), "[Résumé] Client An");
}
#[test]
fn trait_objects_dispatch_to_correct_impl() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 0 }),
Box::new(Customer { name: "Bình".into(), email: "b@x.vn".into() }),
];
let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
assert_eq!(result, vec!["Commande #1 - 0 dông", "[Résumé] Client Bình"]);
}
}
Exécuter les tests :
cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Le troisième test est important. Il confirme que lors du passage via vtable, chaque élément appelle toujours la bonne implémentation de son type réel.
10. Conclusion
Les traits sont la base du partage de comportement en Rust. Les points clés :
- Utilisez
traitpour déclarer un comportement etimpl Trait for Typepour l’implémenter. Les méthodes par défaut réduisent le code répétitif. - Utilisez
impl Traitou les trait bounds (T: Trait,where) quand le type est connu à la compilation. Cela offre un static dispatch sans frais runtime. - Utilisez
Box<dyn Trait>quand vous avez besoin de stocker ou de retourner plusieurs types différents. Rappelez-vous du besoin de dyn-compatible. - N’oubliez pas d’importer le trait dans la portée avec
useavant d’appeler ses méthodes. - Face à l’orphan rule, utilisez le newtype pattern.
Quand ne pas créer un trait ? Si un comportement n’a qu’une seule implémentation et n’a pas besoin d’être remplacé aux tests, une méthode ordinaire sur une struct suffit. Un trait ne vaut la peine d’être ajouté que s’il y a vraiment plusieurs types partageant un comportement, ou si vous avez besoin d’abstraction pour découpler des parties de votre système.