Aller au contenu

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 ».

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.

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.57
Le carré a une aire de 9.00
Le cercle a une aire de 3.14
▶ Essayez par vous-même

Lisons Box<dyn Shape> morceau par morceau :

  • dyn Shape veut dire « un type qui implémente Shape ; on saura lequel quand le programme tournera ». dyn est l’abréviation de dynamic (dynamique).
  • Box place la valeur sur le tas et garde un pointeur vers elle (vous avez découvert Box à la page Pointeurs intelligents). Toutes les Box ont la même taille, quel que soit leur contenu. C’est pour ça que le Vec est 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.1
Aire : 16.0
Nous avons 2 formes
▶ Essayez par vous-même

Une 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.

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 type
fn static_area(shape: &impl Shape) -> f64 {
shape.area()
}
// Dispatch dynamique : une seule fonction, la méthode est cherchée à l'exécution
fn 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.

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.5
J'ai créé un carré de côté 2
▶ Essayez par vous-même

C’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.

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(Email { address: String::from("[email protected]") }),
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 !
▶ Essayez par vous-même

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.

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 un dyn, Rust ne sait pas ce qu’est Self, 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 trait

L’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.

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.

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 ouaf
La vache fait meuh
Le canard fait coin-coin
▶ Essayez par vous-même

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!
▶ Essayez par vous-même

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 !

  • Un Vec ne peut contenir qu’un seul type. Pour mélanger des types, utilisez un trait object comme Box<dyn Trait>.
  • Box<dyn Trait> possède la valeur ; &dyn Trait l’emprunte. Un trait object vit toujours derrière un pointeur.
  • Les génériques et impl Trait utilisent le dispatch statique (rapide, décidé à la compilation). dyn Trait utilise 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 Self ni de méthodes génériques (sauf si elles ont where Self: Sized).

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