Vol. 5 · № 02 · Un atelier RustA Rust workshop

Trait objects (dyn), ou la vtable et son prix Trait objects (dyn), or the vtable and its price

Une vtable, un coût d'indirection, échangé contre la flexibilité : mettre des types hétérogènes dans le même Vec. Contre-exemple : Box<dyn> réflexe alors qu'un generic suffisait. A vtable, an indirection cost, traded for flexibility: putting heterogeneous types in the same Vec. Counter-example: reflexive Box<dyn> when a generic would do.

AudienceAudience
Dev maîtrisant traits et generics (Vol 5 №1) Dev fluent in traits and generics (Vol 5 №1)
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ Traitsdynvtable
SommaireContents ·
№ 01CadrageFraming3 min

Quand le compilateur ne peut pas connaître le type, on l'efface.When the compiler can't know the type, you erase it.

Le numéro précédent a montré la moitié lumineuse du dispatch : les generics, résolus à la compilation, à coût nul. Mais cette résolution suppose que le compilateur connaisse le type concret de chaque appel. Parfois, c'est impossible — non par paresse, mais par nature. Un Vec<T> générique est homogène : un seul T pour tout le vecteur. Comment mettre un cercle et un rectangle dans la même collection, les traiter uniformément, alors qu'ils n'ont pas le même type ? La réponse est le trait object : dyn Surface, un type « effacé » qui ne dit plus que « ceci honore le contrat Surface ». On échange la connaissance du type concret — et le coût zéro qui allait avec — contre la liberté de mélanger.The previous issue showed dispatch's bright half: generics, resolved at compile time, at zero cost. But that resolution assumes the compiler knows each call's concrete type. Sometimes that's impossible — not from laziness, but by nature. A generic Vec<T> is homogeneous: a single T for the whole vector. How do you put a circle and a rectangle in the same collection, handle them uniformly, when they aren't the same type? The answer is the trait object: dyn Surface, an 'erased' type that says only 'this honors the Surface contract'. You trade knowing the concrete type — and the zero cost that came with it — for the freedom to mix.

dyn Surface — des types différents, une collection, un traitementdyn Surface — different types, one collection, one treatment
// Le générique fige UN type par usage : un Vec<T> ne contient qu'un seul T.
// Pour mélanger des types qui partagent un trait, on EFFACE le type concret :

let formes: Vec<Box<dyn Surface>> = vec![
    Box::new(Cercle { r: 1.0 }),       // un Cercle…
    Box::new(Rectangle { l: 2.0, h: 3.0 }),  // …et un Rectangle, dans le MÊME Vec
];

for f in &formes {
    println!("aire = {}", f.aire());   // l'appel trouve la bonne méthode à l'exécution
}
// dyn Surface = « un quelque-chose qui est Surface, dont j'ai oublié le type exact ».
Effacer le type, garder le contratErase the type, keep the contract

C'est le renversement exact du générique. <T: Surface> dit « un type précis, choisi au site d'appel, que je connais à la compilation ». dyn Surface dit « n'importe quel type qui est Surface, et je ne saurai lequel qu'à l'exécution ». Le premier permet la monomorphisation et interdit l'hétérogénéité ; le second permet l'hétérogénéité et interdit la monomorphisation. Ce ne sont pas deux outils concurrents pour le même problème : ce sont les deux réponses à deux questions opposées — « un type, connu » contre « plusieurs types, ou inconnu ».It's the exact reversal of the generic. <T: Surface> says 'a precise type, chosen at the call site, that I know at compile time'. dyn Surface says 'any type that is Surface, and I won't know which until runtime'. The first enables monomorphization and forbids heterogeneity; the second enables heterogeneity and forbids monomorphization. They aren't two competing tools for the same problem: they're the two answers to two opposite questions — 'one type, known' versus 'many types, or unknown'.

Le réflexe du numéroThe issue's reflex

« À ce point du code, le type concret est-il unique et connu, ou multiple/inconnu ? » Unique et connu → générique, dispatch statique, coût zéro. Plusieurs types à mélanger, ou un type décidé à l'exécution (plugin, configuration, entrée) → dyn. Le besoin d'hétérogénéité, ou d'un type différé, est le signal qui fait basculer du statique vers le dynamique.'At this point in the code, is the concrete type unique and known, or multiple/unknown?' Unique and known → generic, static dispatch, zero cost. Several types to mix, or a type decided at runtime (plugin, configuration, input) → dyn. The need for heterogeneity, or a deferred type, is the signal that tips you from static to dynamic.

Pourquoi Box ?Why Box?

Un dyn Surface n'a pas de taille connue à la compilation : un Cercle et un Rectangle n'occupent pas le même nombre d'octets, et le type est effacé. Or Rust doit connaître la taille de ce qu'il pose sur la pile. On range donc la valeur derrière un pointeur — Box<dyn Surface> (sur le tas), ou &dyn Surface (emprunt). Le pointeur, lui, a une taille fixe. Le Vol 6 reviendra en détail sur Box ; ici, il sert juste à donner une taille à l'inconnu.A dyn Surface has no compile-time size: a Cercle and a Rectangle don't occupy the same number of bytes, and the type is erased. Yet Rust must know the size of what it puts on the stack. So you store the value behind a pointer — Box<dyn Surface> (on the heap), or &dyn Surface (a borrow). The pointer itself has a fixed size. Vol 6 will return to Box in detail; here, it just gives a size to the unknown.

№ 02La surpriseThe surprise4 min

Un Vec générique ne mélange rien. Un Vec de dyn mélange tout.A generic Vec mixes nothing. A Vec of dyn mixes everything.

Voici la surprise qui révèle pourquoi dyn existe. On pourrait croire qu'un Vec<T: Surface> accepte n'importe quelle forme — après tout, ce sont toutes des Surface. C'est faux. La monomorphisation fixe un seul T pour tout le vecteur : un Vec de cercles, ou un Vec de rectangles, jamais les deux. Le générique n'abstrait pas « une forme quelconque », il abstrait « un type, choisi une fois ». Pour une collection réellement hétérogène — des formes de types divers côte à côte, chacune gardant son comportement — il n'y a qu'une voie : effacer le type concret derrière dyn. La flexibilité que le générique refuse, le trait object l'offre.Here's the surprise that reveals why dyn exists. You might think a Vec<T: Surface> accepts any shape — they're all Surfaces, after all. It's false. Monomorphization fixes a single T for the whole vector: a Vec of circles, or a Vec of rectangles, never both. The generic doesn't abstract 'some shape', it abstracts 'one type, chosen once'. For a truly heterogeneous collection — shapes of various types side by side, each keeping its behavior — there's only one path: erase the concrete type behind dyn. The flexibility the generic refuses, the trait object grants.

La limite — le générique fige un type pour toute la collectionThe limit — the generic fixes one type for the whole collection
// Générique : un Vec<T> est HOMOGÈNE — un seul type pour tout le vecteur
fn aire_totale<T: Surface>(formes: &[T]) -> f64 {
    formes.iter().map(|f| f.aire()).sum()
}

let cercles = vec![Cercle { r: 1.0 }, Cercle { r: 2.0 }];
aire_totale(&cercles);   // ✓ T = Cercle, tout le slice est du même type

let melange = vec![Cercle { r: 1.0 }, Rectangle { l: 2.0, h: 3.0 }];
//                 ^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// ✕ NE COMPILE PAS : Vec attend un seul type, Cercle ≠ Rectangle.

Le générique brille tant que chaque collection est d'un seul type — et il échoue dès qu'on veut les mélanger. aire_totale<T> sera monomorphisée en aire_totale_cercle, aire_totale_rectangle… une version par type, chacune attendant un slice homogène. Le vec![cercle, rectangle] ne compile pas, parce qu'aucun T unique ne peut désigner les deux. Ce n'est pas un défaut : c'est la conséquence directe du coût zéro. Connaître le type pour le spécialiser, c'est renoncer à en mélanger plusieurs.The generic shines as long as each collection is of one type — and fails the moment you want to mix them. aire_totale<T> will monomorphize into aire_totale_cercle, aire_totale_rectangle… one version per type, each expecting a homogeneous slice. The vec![circle, rectangle] won't compile, because no single T can denote both. It's not a flaw: it's the direct consequence of zero cost. Knowing the type to specialize it means giving up mixing several.

Le générique abstrait « un type, choisi une fois » ; le trait object abstrait « un comportement, quel que soit le type ».The generic abstracts 'one type, chosen once'; the trait object abstracts 'one behavior, whatever the type'.
№ 03La mécaniqueThe mechanics5 min

La vtable : comment l'appel retrouve la bonne méthode à l'exécution.The vtable: how the call finds the right method at runtime.

Si le type est effacé, comment f.aire() sait-il quelle aire() appeler ? Par la vtable. Un trait object n'est pas un pointeur ordinaire : c'est un pointeur gras, deux mots côte à côte. Le premier pointe vers la donnée (le Cercle, le Rectangle) ; le second vers une table de méthodes — la vtable — propre au type concret, générée par le compilateur. Appeler une méthode, c'est suivre le second pointeur, y lire l'adresse de la bonne fonction, et l'appeler. Une indirection de plus qu'un appel direct. C'est précisément ce surcoût — minuscule mais non nul — qu'on échange contre la flexibilité du chapitre précédent. Et il a un corollaire : l'appel ne peut plus être inliné, ce qui pèse parfois plus que l'indirection elle-même.If the type is erased, how does f.aire() know which aire() to call? Through the vtable. A trait object isn't an ordinary pointer: it's a fat pointer, two words side by side. The first points to the data (the Cercle, the Rectangle); the second to a method table — the vtable — specific to the concrete type, generated by the compiler. Calling a method means following the second pointer, reading the right function's address there, and calling it. One indirection more than a direct call. It's precisely that overhead — tiny but non-zero — that you trade for the previous chapter's flexibility. And it has a corollary: the call can no longer be inlined, which sometimes weighs more than the indirection itself.

Le pointeur gras — la donnée d'un côté, la table des méthodes de l'autreThe fat pointer — the data on one side, the method table on the other
// Un &dyn Surface n'est PAS un simple pointeur : c'est un pointeur « gras »
//   (fat pointer) = deux mots côte à côte.

//   ┌─────────────┬─────────────┐
//   │  data ptr   │  vtable ptr │     &dyn Surface  (2 × usize)
//   └──────┬──────┴──────┬──────┘
//          │             │
//          ▼             ▼
//      [ Cercle ]   [ vtable de Cercle: { aire, drop, size, align } ]

// f.aire()  ≡  (f.vtable.aire)(f.data)
//   → on suit le vtable ptr, on y lit l'adresse de aire(), on l'appelle.
//   UNE indirection de plus qu'un appel direct — c'est ça, le « prix ».
Statique vs dynamique, le tableau de bordStatic vs dynamic, the dashboard
<T: Trait> — statiquestaticdyn Trait — dynamiquedynamic
RésolutionResolutionÀ la compilationAt compile timeÀ l’exécution (vtable)At runtime (vtable)
Coût d’appelCall costDirect, inlinableDirect, inlinableUne indirection, non inlinéOne indirection, not inlined
BinaireBinaryN copies (bloat)N copies (bloat)Une seule copieA single copy
TypesTypesUn seul par usageOne per useHétérogènes, ou différésHeterogeneous, or deferred
Le coût, en proportionThe cost, in proportion

L'indirection d'une vtable est de l'ordre de quelques cycles — négligeable devant le travail d'une méthode qui fait quoi que ce soit de réel. Le vrai coût est l'inlining perdu : le compilateur ne pouvant savoir quelle fonction sera appelée, il ne peut pas la fondre dans l'appelant ni optimiser à travers. Dans une boucle très chaude appelant une micro-méthode des millions de fois, ça se mesure. Ailleurs — la grande majorité du code — c'est invisible. D'où la règle : dyn n'est pas « lent », il est « non-inlinable », et ça ne compte que là où l'inlining comptait.A vtable's indirection is on the order of a few cycles — negligible next to the work of a method doing anything real. The true cost is lost inlining: since the compiler can't know which function will be called, it can't fold it into the caller or optimize across it. In a very hot loop calling a micro-method millions of times, it's measurable. Elsewhere — the vast majority of code — it's invisible. Hence the rule: dyn isn't 'slow', it's 'non-inlinable', and that only matters where inlining mattered.

L'object safety, en bref.Object safety, in brief.

Tous les traits ne peuvent pas devenir des dyn. Pour être « object-safe », un trait ne doit pas avoir de méthode générique (impossible à mettre dans une vtable de taille fixe), ni renvoyer Self (sa taille est inconnue une fois le type effacé), ni de méthode statique sans self. Si le compilateur refuse ton dyn Trait, c'est presque toujours l'une de ces règles : la vtable doit être finie et uniforme, donc le trait doit l'être aussi.Not every trait can become a dyn. To be 'object-safe', a trait must have no generic method (impossible to put in a fixed-size vtable), must not return Self (its size is unknown once the type is erased), and no static method without self. If the compiler refuses your dyn Trait, it's almost always one of these rules: the vtable must be finite and uniform, so the trait must be too.

Le dispatch dynamique, ce n'est pas « plus lent » : c'est une indirection et un inlining perdu — un prix qui ne se voit que dans les boucles chaudes.Dynamic dispatch isn't 'slower': it's one indirection and lost inlining — a price visible only in hot loops.
№ 04Le piègeThe trap4 min

Le Box<dyn> réflexe : payer la flexibilité qu'on n'utilise pas.The reflexive Box<dyn>: paying for flexibility you don't use.

Le piège symétrique de la généricité gratuite, c'est le dyn gratuit. dyn se lit plus court qu'une signature générique : pas de paramètre de type, pas de borne à déclarer, une signature qui « fait propre ». La tentation est de l'employer partout, y compris là où un seul type concret passe réellement. Mais on paie alors trois fois sans rien recevoir : une allocation sur le tas pour le Box, une indirection de vtable à chaque appel, et l'inlining perdu — pour une hétérogénéité que personne n'exploite. Le dyn n'est justifié que par un besoin réel : mélanger des types, ou manipuler un type qu'on ne connaîtra qu'à l'exécution. Sans ce besoin, c'est du coût pur.The symmetric trap to gratuitous genericity is gratuitous dyn. dyn reads shorter than a generic signature: no type parameter, no bound to declare, a signature that 'looks clean'. The temptation is to use it everywhere, including where a single concrete type actually flows through. But then you pay three times for nothing: a heap allocation for the Box, a vtable indirection on every call, and lost inlining — for a heterogeneity no one exploits. dyn is justified only by a real need: mixing types, or handling a type you'll only know at runtime. Without that need, it's pure cost.

Le contre-exemple — dyn pour esquiver un paramètre génériqueThe counter-example — dyn to dodge a generic parameter
// Box<dyn> par réflexe, là où un seul type passe vraiment
fn traiter(mut source: Box<dyn Read>) -> Result<Vec<u8>, Err> {
    let mut buf = Vec::new();
    source.read_to_end(&mut buf)?;   // chaque read passe par la vtable
    Ok(buf)
}

// appelé seulement avec des File. on a payé : une allocation tas (Box),
// une indirection par appel, et l'inlining perdu — pour une flexibilité
// que personne n'utilise. le dyn ne sert ici qu'à esquiver d'écrire <R: Read>.

Ici, dyn Read n'apporte aucune flexibilité utile : la fonction n'est jamais appelée qu'avec un seul type concret. Le Box force une allocation tas, chaque read traverse la vtable, et le compilateur ne peut plus inliner ni optimiser à travers — le tout pour éviter d'écrire trois caractères, <R: Read>. C'est un marché perdant : on prend tous les inconvénients du dispatch dynamique sans le seul avantage qui les rachète.Here, dyn Read brings no useful flexibility: the function is only ever called with a single concrete type. The Box forces a heap allocation, each read goes through the vtable, and the compiler can no longer inline or optimize across — all to avoid writing three characters, <R: Read>. It's a losing deal: you take all of dynamic dispatch's drawbacks without the one advantage that redeems them.

Le bon réflexe — statique par défaut, dyn quand l'hétérogénéité l'exigeThe right reflex — static by default, dyn when heterogeneity demands it
// Générique par défaut : statique, inlinable, sans allocation
fn traiter<R: Read>(mut source: R) -> Result<Vec<u8>, Err> {
    let mut buf = Vec::new();
    source.read_to_end(&mut buf)?;   // appel direct, inliné, coût zéro
    Ok(buf)
}

// dyn se réserve aux cas où on en a VRAIMENT besoin :
struct Pipeline { etapes: Vec<Box<dyn Etape>> }   // hétérogène : justifié
fn charger_plugin(nom: &str) -> Box<dyn Plugin> { /* type connu au runtime */ }

Le générique redonne l'appel direct, inlinable, sans allocation — le coût zéro du n°1. Et dyn reprend sa juste place : la Vec<Box<dyn Etape>> d'un pipeline réellement hétérogène, le Box<dyn Plugin> dont le type n'est connu qu'au chargement. La question n'est pas « statique ou dynamique par goût », mais « ai-je besoin de mélanger des types ou d'en différer un ». Oui → dyn, et son prix est mérité. Non → générique, et garde le coût zéro.The generic restores the direct, inlinable, allocation-free call — issue n°1's zero cost. And dyn takes its rightful place: the Vec<Box<dyn Etape>> of a truly heterogeneous pipeline, the Box<dyn Plugin> whose type is known only at load time. The question isn't 'static or dynamic by taste', but 'do I need to mix types or defer one'. Yes → dyn, and its price is earned. No → generic, and keep the zero cost.

Le test mentalThe mental test

« Si je remplace ce dyn par un générique, qu'est-ce qui cesse de compiler ? » Si la réponse est « rien » — un seul type passe — alors le dyn ne servait à rien : repasse en générique. Si la réponse est « le Vec de types mélangés », « le plugin chargé au runtime », « la récursion de type infinie » — alors le dyn gagne son prix. Le besoin doit casser la version statique pour justifier la version dynamique.'If I replace this dyn with a generic, what stops compiling?' If the answer is 'nothing' — one type flows through — then the dyn served no purpose: go back to a generic. If the answer is 'the Vec of mixed types', 'the plugin loaded at runtime', 'the infinite type recursion' — then the dyn earns its price. The need must break the static version to justify the dynamic one.

Parfois dyn est le bon défaut.Sometimes dyn is the right default.

L'inverse existe : quand un générique serait monomorphisé en dizaines de copies d'une grosse fonction, dyn réduit le binaire et le temps de compilation pour un coût d'exécution négligeable. Les gros projets choisissent parfois dyn aux frontières internes (pas dans les boucles chaudes) pour contenir le bloat. Le réflexe « statique par défaut » reste juste — mais « par défaut » n'est pas « toujours ».The reverse exists: when a generic would monomorphize into dozens of copies of a large function, dyn shrinks the binary and compile time for negligible runtime cost. Big projects sometimes choose dyn at internal boundaries (not in hot loops) to contain bloat. The 'static by default' reflex stays right — but 'by default' isn't 'always'.

Un dyn qu'un générique remplacerait sans rien casser n'achète aucune flexibilité : il ne paie que ses propres inconvénients.A dyn a generic could replace without breaking anything buys no flexibility: it pays only for its own drawbacks.
№ 05Bilan & éditoWrap-up & editorial2 min

Le même comportement, résolu à l'exécution — la flexibilité contre le coût zéro.The same behavior, resolved at runtime — flexibility against zero cost.

Ce numéro a posé la seconde moitié du choix ouvert au n°1. Le trait object efface le type concret derrière dyn : on perd la connaissance qui rendait la monomorphisation possible, on gagne la liberté de mélanger des types et de n'avoir qu'une copie. Le mécanisme est le pointeur gras — donnée plus vtable — et le prix, une indirection et l'inlining perdu, visible seulement dans les boucles chaudes. Generics et dyn ne s'opposent pas comme le bon et le mauvais : ce sont les deux réponses à deux questions. Un type connu et unique appelle le statique ; des types mélangés ou différés appellent le dynamique. Savoir lequel, c'est tout le numéro.This issue laid the second half of the choice opened in n°1. The trait object erases the concrete type behind dyn: you lose the knowledge that made monomorphization possible, you gain the freedom to mix types and keep a single copy. The mechanism is the fat pointer — data plus vtable — and the price, an indirection and lost inlining, visible only in hot loops. Generics and dyn aren't opposed as good and bad: they're two answers to two questions. A known, unique type calls for static; mixed or deferred types call for dynamic. Knowing which is the whole issue.

L'outilThe toolCe qu'il installeWhat it sets upLe réflexeThe reflex
dyn TraitLe type concret effacé ; le contrat seul subsiste.The concrete type erased; only the contract remains.Types à mélanger ou différés → dyn, pas un générique.Types to mix or defer → dyn, not a generic.
Box<dyn> / &dynUne taille fixe pour un type effacé : tas, ou emprunt.A fixed size for an erased type: heap, or borrow.Possédé et hétérogène → Box ; vue temporaire → &dyn.Owned and heterogeneous → Box; temporary view → &dyn.
vtablevtableLa table qui retrouve la bonne méthode à l'exécution.The table that finds the right method at runtime.Le coût n'est pas la lenteur, c'est l'inlining perdu.The cost isn't slowness, it's lost inlining.
Object safetyObject safetyLes conditions pour qu'un trait devienne un dyn.The conditions for a trait to become a dyn.dyn refusé → méthode générique ou retour Self à retirer.dyn refused → a generic method or Self return to remove.
Statique ou dynamique n'est pas une question de goût : c'est « un type connu » contre « des types mélangés ou différés ».Static or dynamic isn't a matter of taste: it's 'a known type' versus 'mixed or deferred types'.
Mot de l'éditeurFrom the editor

On sait utiliser les traits. Reste à savoir où on a le droit de les implémenter.We know how to use traits. What remains is where we're allowed to implement them.

Deux numéros durant, on a supposé qu'implémenter un trait pour un type allait de soi. Mais une question a affleuré au n°1 : on peut implémenter un trait pour un type qu'on n'a pas écrit — « sous conditions ». Ces conditions ne sont pas un détail bureaucratique. Sans elles, deux bibliothèques pourraient implémenter le même trait pour le même type de deux façons incompatibles, et le compilateur ne saurait laquelle choisir : l'incohérence.For two issues, we assumed implementing a trait for a type was a given. But a question surfaced in n°1: you can implement a trait for a type you didn't write — 'under conditions'. Those conditions aren't a bureaucratic detail. Without them, two libraries could implement the same trait for the same type in two incompatible ways, and the compiler wouldn't know which to choose: incoherence.

Vol 5 №3 : l'orphan rule et les blanket impls. On verra pourquoi Rust interdit d'implémenter un trait étranger pour un type étranger — la règle qui garantit qu'une implémentation est unique et globale —, et le pattern qui en sort : le newtype du Vol 3, revenu ici pour contourner la règle proprement. À l'autre bout, les blanket impls — impl<T> Trait for T — qui dotent d'un coup tout un univers de types d'un comportement. On clôt le volume des traits sur sa contrainte la plus profonde : la cohérence, le prix de la confiance entre bibliothèques qui ne se connaissent pas.Vol 5 №3: the orphan rule and blanket impls. We'll see why Rust forbids implementing a foreign trait for a foreign type — the rule guaranteeing an implementation is unique and global — and the pattern it yields: Vol 3's newtype, back here to sidestep the rule cleanly. At the other end, blanket impls — impl<T> Trait for T — that endow a whole universe of types with a behavior at once. We close the traits volume on its deepest constraint: coherence, the price of trust between libraries that don't know each other.