Les trait objects (dyn) en Rust
À la page Traits, vous avez écrit des fonctions qui acceptent « n’importe quel type qui implémente Speak ». À la page Génériques, vous avez vu que Rust fabrique une copie séparée d’une fonction générique pour chaque type. Les deux approches marchent très bien quand vous connaissez le type à la compilation. Mais que faire si vous voulez une seule liste qui contient plein de types différents ?
C’est justement le rôle d’un trait object (objet trait). Imaginez un professeur de musique avec une salle pleine d’élèves. L’un joue du piano, un autre du violon, un troisième de la batterie. Le professeur n’a pas besoin de savoir quel instrument a chaque élève : il dit simplement « jouez ! », et chacun joue à sa façon. Un trait object permet à votre code de faire pareil : « je ne connais pas le type exact, mais je sais qu’il sait faire ça ».
Le problème : un seul Vec, des types différents
Section intitulée « Le problème : un seul Vec, des types différents »Disons que vous avez deux formes, et que vous voulez les ranger dans un même Vec :
struct Circle { radius: f64 }struct Square { side: f64 }
fn main() { let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];}Ce code ne compile pas :
error[E0308]: mismatched types --> src/main.rs:5:47 |5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }]; | ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`Le compilateur vous dit que les types ne correspondent pas : il attendait un Circle et a trouvé un Square. Un Vec ne peut contenir qu’un seul type. Tous les éléments doivent avoir la même taille en mémoire, pour que Rust puisse les ranger les uns à la suite des autres. Un Circle et un Square sont deux types différents, ils ne peuvent donc pas partager un Vec ordinaire.
Box<dyn Trait> : une liste de formes différentes
Section intitulée « Box<dyn Trait> : une liste de formes différentes »La solution se fait en deux temps. D’abord, donnez aux deux formes un trait commun. Ensuite, mettez chaque forme dans une Box et indiquez que le type est Box<dyn Shape> :
trait Shape { fn name(&self) -> String; fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn name(&self) -> String { String::from("cercle") } fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn name(&self) -> String { String::from("carré") } fn area(&self) -> f64 { self.side * self.side }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 }), Box::new(Circle { radius: 1.0 }), ];
for shape in &shapes { println!("Le {} a une aire de {:.2}", shape.name(), shape.area()); }}Sortie :
Le cercle a une aire de 12.57Le carré a une aire de 9.00Le cercle a une aire de 3.14Lisons Box<dyn Shape> morceau par morceau :
dyn Shapeveut dire « un type qui implémenteShape; on saura lequel quand le programme tournera ».dynest l’abréviation de dynamic (dynamique).Boxplace la valeur sur le tas et garde un pointeur vers elle (vous avez découvertBoxà la page Pointeurs intelligents). Toutes lesBoxont la même taille, quel que soit leur contenu. C’est pour ça que leVecest content maintenant.
La valeur dans la boîte (plus un pointeur caché qui dit « celle-ci est un Circle ») forme ce qu’on appelle un trait object.
&dyn Trait : emprunter au lieu de mettre en boîte
Section intitulée « &dyn Trait : emprunter au lieu de mettre en boîte »Vous n’avez pas toujours besoin d’une Box. Si les valeurs vivent déjà ailleurs, une référence fonctionne aussi. &dyn Shape veut dire « une référence empruntée vers un type qui implémente Shape » :
trait Shape { fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side }}
fn print_area(shape: &dyn Shape) { println!("Aire : {:.1}", shape.area());}
fn main() { let circle = Circle { radius: 1.0 }; let square = Square { side: 4.0 };
print_area(&circle); print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square]; println!("Nous avons {} formes", both.len());}Sortie :
Aire : 3.1Aire : 16.0Nous avons 2 formesUne règle simple : utilisez Box<dyn Trait> quand la liste doit posséder ses valeurs, et &dyn Trait quand vous avez seulement besoin de les emprunter pour un moment.
Dispatch statique et dispatch dynamique
Section intitulée « Dispatch statique et dispatch dynamique »Le mot « dispatch » veut simplement dire « choisir quelle méthode appeler ». Rust peut le faire de deux façons.
Le dispatch statique a lieu avec les génériques et impl Trait. Le compilateur connaît le type exact, donc il fabrique une copie spéciale de la fonction pour chaque type (comme vous l’avez vu à la page Génériques). L’appel va directement à la bonne méthode. C’est très rapide.
Le dispatch dynamique a lieu avec dyn Trait. Le compilateur ne connaît pas le type exact. Chaque trait object transporte donc une petite table cachée (appelée vtable) qui dit « pour cette valeur, area se trouve ici ». À l’exécution, Rust cherche la méthode dans cette table, puis l’appelle. Cette recherche a un tout petit coût supplémentaire.
// Dispatch statique : Rust fabrique une copie de cette fonction par typefn static_area(shape: &impl Shape) -> f64 { shape.area()}
// Dispatch dynamique : une seule fonction, la méthode est cherchée à l'exécutionfn dynamic_area(shape: &dyn Shape) -> f64 { shape.area()}Les deux fonctions donnent la même réponse pour la même forme. La seule différence, c’est comment Rust trouve la bonne méthode area. Alors, laquelle choisir ?
Utilisez les génériques / impl Trait quand… |
Utilisez dyn Trait quand… |
|---|---|
| Chaque appel travaille avec un seul type connu | Vous devez mélanger des types différents dans une même collection |
| Vous voulez le code le plus rapide possible | Vous choisissez le type à l’exécution (par exemple d’après ce que saisit l’utilisateur) |
| Un programme un peu plus gros ne vous dérange pas | Vous voulez un code plus compact et une compilation plus rapide |
Un bon réflexe pour débuter : commencez par les génériques ou impl Trait. Passez à dyn quand vous tombez sur le problème des « types différents dans une même liste ». Ne vous inquiétez pas trop de la différence de vitesse : dans la plupart des programmes, vous ne la remarquerez jamais.
Renvoyer une Box<dyn Trait> depuis une fonction
Section intitulée « Renvoyer une Box<dyn Trait> depuis une fonction »Parfois, une fonction doit décider à l’exécution quel type renvoyer. Vous pourriez essayer -> impl Shape, mais cela ne marche que si la fonction renvoie toujours le même type. Si des branches différentes renvoient des types différents, renvoyez plutôt une Box<dyn Shape> :
trait Shape { fn describe(&self) -> String;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn describe(&self) -> String { format!("un cercle de rayon {}", self.radius) }}
impl Shape for Square { fn describe(&self) -> String { format!("un carré de côté {}", self.side) }}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> { if kind == "circle" { Box::new(Circle { radius: size }) } else { Box::new(Square { side: size }) }}
fn main() { let a = make_shape("circle", 1.5); let b = make_shape("square", 2.0); println!("J'ai créé {}", a.describe()); println!("J'ai créé {}", b.describe());}Sortie :
J'ai créé un cercle de rayon 1.5J'ai créé un carré de côté 2C’est un schéma courant pour les fonctions « usines » (factory), les systèmes de plugins et le code piloté par la configuration : partout où le type exact dépend de données que vous ne découvrez qu’à l’exécution.
Un exemple concret : envoyer des notifications
Section intitulée « Un exemple concret : envoyer des notifications »Voici quelque chose de plus proche d’une vraie application. Votre programme doit prévenir un utilisateur, mais chaque utilisateur a choisi ses propres canaux : e-mail, SMS, ou les deux. Chaque canal est un type à part, et ils partagent tous un trait Notifier :
trait Notifier { fn send(&self, message: &str) -> String;}
struct Email { address: String }struct Sms { phone: String }
impl Notifier for Email { fn send(&self, message: &str) -> String { format!("E-mail à {} : {}", self.address, message) }}
impl Notifier for Sms { fn send(&self, message: &str) -> String { format!("SMS au {} : {}", self.phone, message) }}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) { for channel in channels { println!("{}", channel.send(message)); }}
fn main() { let channels: Vec<Box<dyn Notifier>> = vec![ Box::new(Sms { phone: String::from("+1-555-0100") }), ];
notify_all(&channels, "Votre commande a été expédiée !");}Sortie :
E-mail à [email protected] : Votre commande a été expédiée !SMS au +1-555-0100 : Votre commande a été expédiée !Le plus agréable : si plus tard vous ajoutez un notificateur Push, vous écrivez une nouvelle struct et un impl Notifier for Push. La fonction notify_all ne change pas du tout.
Les limites : la compatibilité dyn
Section intitulée « Les limites : la compatibilité dyn »Tous les traits ne peuvent pas devenir des trait objects. Un trait doit être dyn compatible (les livres et articles plus anciens parlent d’object safety). Les règles complètes sont longues, mais deux d’entre elles couvrent la plupart des cas réels :
- Une méthode ne peut pas renvoyer
Self. (Derrière undyn, Rust ne sait pas ce qu’estSelf, donc il ne peut pas connaître la taille de la valeur renvoyée.) - Une méthode ne peut pas avoir ses propres paramètres de type génériques, comme
fn process<T>(&self, item: T).
Voici ce qui se passe si vous enfreignez la première règle :
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self;}// ... plus loin :let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];error[E0038]: the trait `Shape` is not dyn compatible |14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })]; | ^^^^^ `Shape` is not dyn compatible | 1 | trait Shape { | ----- this trait is not dyn compatible... 2 | fn area(&self) -> f64; 3 | fn duplicate(&self) -> Self; | ^^^^ ...because method `duplicate` references the `Self` type in its return type = help: consider moving `duplicate` to another traitL’erreur est honnête et utile : elle indique exactement quelle méthode pose problème (duplicate, parce qu’elle mentionne Self dans son type de retour), et suggère de la déplacer dans un autre trait. Vous avez deux options : déplacer cette méthode dans un trait différent, ou ajouter where Self: Sized à la méthode. Cela dit à Rust « cette méthode n’existe que sur les vrais types connus, pas sur les trait objects » :
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self where Self: Sized;}C’est aussi pour ça que certains traits standard, comme Clone, ne peuvent pas s’utiliser en dyn Clone : clone() renvoie Self.
Erreurs courantes
Section intitulée « Erreurs courantes »Oublier la Box. On est tenté d’écrire Vec<dyn Shape> :
let shapes: Vec<dyn Shape> = Vec::new();error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time --> src/main.rs:3:17 |3 | let shapes: Vec<dyn Shape> = Vec::new(); | ^^^^^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `dyn Shape`Le compilateur explique que la taille d’un dyn Shape ne peut pas être connue à la compilation. En effet, un dyn Shape pourrait avoir n’importe quelle taille : un petit Square ou une grosse struct avec plein de champs. Rust a besoin d’une taille fixe, donc un trait object doit toujours vivre derrière un pointeur : Box<dyn Shape>, &dyn Shape ou Rc<dyn Shape>.
Mettre du dyn partout. Si une fonction ne reçoit jamais qu’un seul type à la fois, impl Trait ou les génériques sont plus simples et plus rapides (la page Traits avancés en dit plus sur impl Trait). Gardez dyn pour les cas où vous avez vraiment besoin de mélanger les types.
Exercices
Section intitulée « Exercices »1. Un zoo dans un Vec
Section intitulée « 1. Un zoo dans un Vec »Définissez un trait Animal avec fn name(&self) -> String et fn sound(&self) -> String. Implémentez-le pour Dog, Cow et Duck, rangez un exemplaire de chacun dans un Vec<Box<dyn Animal>>, et parcourez-le pour afficher le cri de chaque animal. Indice : les structs peuvent être vides (struct Dog;), et chacune s’ajoute avec Box::new(...).
Voir la solution
trait Animal { fn name(&self) -> String; fn sound(&self) -> String;}
struct Dog;struct Cow;struct Duck;
impl Animal for Dog { fn name(&self) -> String { String::from("Le chien") } fn sound(&self) -> String { String::from("ouaf") }}
impl Animal for Cow { fn name(&self) -> String { String::from("La vache") } fn sound(&self) -> String { String::from("meuh") }}
impl Animal for Duck { fn name(&self) -> String { String::from("Le canard") } fn sound(&self) -> String { String::from("coin-coin") }}
fn main() { let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo { println!("{} fait {}", animal.name(), animal.sound()); }}Sortie :
Le chien fait ouafLa vache fait meuhLe canard fait coin-coin2. Choisir une salutation à l’exécution
Section intitulée « 2. Choisir une salutation à l’exécution »Définissez un trait Greeter avec fn hello(&self, name: &str) -> String, et deux types : English et Spanish. Écrivez fn make_greeter(lang: &str) -> Box<dyn Greeter> qui renvoie Spanish pour "es" et English pour tout le reste. Saluez "Mia" pour chaque code de ["en", "es", "fr"]. Indice : les deux branches du if doivent renvoyer le même type, donc enveloppez chacune dans Box::new(...).
Voir la solution
trait Greeter { fn hello(&self, name: &str) -> String;}
struct English;struct Spanish;
impl Greeter for English { fn hello(&self, name: &str) -> String { format!("Hello, {name}!") }}
impl Greeter for Spanish { fn hello(&self, name: &str) -> String { format!("¡Hola, {name}!") }}
fn make_greeter(lang: &str) -> Box<dyn Greeter> { if lang == "es" { Box::new(Spanish) } else { Box::new(English) }}
fn main() { for lang in ["en", "es", "fr"] { let greeter = make_greeter(lang); println!("{lang}: {}", greeter.hello("Mia")); }}Sortie :
en: Hello, Mia!es: ¡Hola, Mia!fr: Hello, Mia!Les salutations restent en anglais et en espagnol, puisque c’est tout l’intérêt de l’exercice. Et oui, "fr" tombe dans le cas « tout le reste » et reçoit donc l’anglais : à vous d’ajouter un type French si vous voulez aller plus loin !
Récapitulatif
Section intitulée « Récapitulatif »- Un
Vecne peut contenir qu’un seul type. Pour mélanger des types, utilisez un trait object commeBox<dyn Trait>. Box<dyn Trait>possède la valeur ;&dyn Traitl’emprunte. Un trait object vit toujours derrière un pointeur.- Les génériques et
impl Traitutilisent le dispatch statique (rapide, décidé à la compilation).dyn Traitutilise le dispatch dynamique (une recherche dans la vtable à l’exécution). - Renvoyez une
Box<dyn Trait>quand une fonction choisit entre plusieurs types à l’exécution. - Seuls les traits dyn compatibles peuvent devenir des trait objects : pas de méthodes qui renvoient
Selfni de méthodes génériques (sauf si elles ontwhere Self: Sized).
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Pourquoi ne peut-on pas mettre un Circle et un Square dans le même Vec ordinaire ?
2. Que dit dyn à Rust dans Box<dyn Shape> ?
3. Lequel de ces types utilise le dispatch statique ?
4. Pourquoi Vec<dyn Shape> (sans Box) provoque-t-il une erreur ?
5. Quelle méthode empêche un trait d'être dyn compatible ?
Score : 0 / 5
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Using Trait Objects to Abstract over Shared Behavior — The Rust Book (en anglais) : le chapitre officiel, qui construit une liste de composants d’interface graphique avec
Box<dyn Draw>. - Returning Traits with dyn — Rust by Example (en anglais) : un court exemple exécutable d’une fonction qui renvoie
Box<dyn Trait>. - Trait objects — The Rust Reference (en anglais) : les règles exactes des types
dyn Trait, y compris les lifetimes et les traits marqueurs supplémentaires commeSend. - Dyn compatibility — The Rust Reference (en anglais) : la liste complète des règles qu’un trait doit respecter pour être utilisé avec
dyn. - Le mot-clé
dyn— documentation de std (en anglais) : une explication rapide dedyn, des vtables, et des compromis par rapport aux génériques.