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

Traits & generics, ou le polymorphisme à coût zéro Traits & generics, or zero-cost polymorphism

La monomorphisation : zéro coût à l'exécution, mais du code dupliqué à la compilation. Rust n'a pas d'héritage — et c'est un choix, pas un manque. Monomorphization: zero runtime cost, but duplicated code at compile time. Rust has no inheritance — and that's a choice, not a lack.

AudienceAudience
Dev à l'aise avec les types et les enums (Vol 3–4) Dev comfortable with types and enums (Vol 3–4)
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ TraitsGenericsMonomorphisation
SommaireContents ·
№ 01CadrageFraming3 min

Pas d'héritage. Un contrat que le type choisit d'honorer.No inheritance. A contract the type chooses to honor.

Les quatre premiers volumes portaient sur la sûreté ; celui-ci ouvre la réutilisation. La question change : comment écrire un comportement une seule fois et l'appliquer à des types différents ? Les langages à objets répondent par l'héritage — un type dérive d'un autre et récupère ses méthodes. Rust n'a pas d'héritage, et ce n'est pas un manque : c'est un choix. Le partage de comportement y passe par les traits — des contrats. Un trait déclare ce qu'un type doit savoir faire ; un type décide, par un bloc impl, d'honorer ce contrat. Une fonction générique n'exige alors plus un type précis, mais la preuve qu'il respecte le contrat. C'est le polymorphisme sans la hiérarchie.The first four volumes were about safety; this one opens reuse. The question shifts: how do you write a behavior once and apply it to different types? Object-oriented languages answer with inheritance — a type derives from another and inherits its methods. Rust has no inheritance, and that's not a lack: it's a choice. Behavior sharing goes through traits — contracts. A trait declares what a type must know how to do; a type decides, via an impl block, to honor that contract. A generic function then no longer demands a precise type, but proof that it respects the contract. It's polymorphism without the hierarchy.

Le trait et ses impl — un contrat, des types qui y souscriventThe trait and its impls — a contract, types that subscribe to it
// Un trait : un CONTRAT de comportement, que chaque type choisit d'honorer
trait Surface {
    fn aire(&self) -> f64;          // « quiconque est Surface sait donner son aire »
}

impl Surface for Cercle {           // le cercle décide d'honorer le contrat
    fn aire(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
}
impl Surface for Rectangle {
    fn aire(&self) -> f64 { self.l * self.h }
}

// une fonction générique exige le contrat, pas un type précis :
fn afficher_aire<F: Surface>(forme: &F) {   // « tout F qui est Surface »
    println!("aire = {}", forme.aire());
}
Le contrat, pas la parentéThe contract, not the lineage

La différence avec l'héritage est profonde. Cercle et Rectangle ne descendent d'aucune classe Forme ; ils n'ont rien en commun structurellement. Ils partagent seulement de savoir faire la même chose — donner leur aire. Le trait capture exactement ce partage, et rien de plus : pas de champs hérités, pas de constructeur de base, pas de chaîne de parenté à remonter. On peut même implémenter un trait pour un type qu'on n'a pas écrit (sous conditions — c'est le sujet du n°3). Le comportement se compose par capacités, là où l'héritage l'impose par filiation.The difference from inheritance runs deep. Cercle and Rectangle descend from no Shape class; they share nothing structurally. They only share knowing how to do the same thing — give their area. The trait captures exactly that sharing, and nothing more: no inherited fields, no base constructor, no lineage to climb. You can even implement a trait for a type you didn't write (under conditions — issue n°3's subject). Behavior composes by capability, where inheritance imposes it by descent.

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

« Ce code a-t-il besoin d'un type précis, ou d'une capacité ? » S'il ne fait qu'appeler .aire(), il n'a que faire de savoir si c'est un cercle : il lui faut « quelque chose qui sait donner son aire ». On l'écrit générique, borné par le trait — une fois, pour tous les types présents et futurs qui honoreront le contrat.'Does this code need a precise type, or a capability?' If it only calls .aire(), it has no use for knowing it's a circle: it needs 'something that can give its area'. You write it generic, bounded by the trait — once, for all present and future types that will honor the contract.

Tu connais déjà les traits.You already know traits.

Toute la série en a croisé : Display formatait nos messages, Error structurait nos pannes, From convertissait nos erreurs sous le ?, PartialEq permettait le ==. Ce ne sont pas des mécanismes du langage à part : ce sont des traits de la bibliothèque standard, exactement de la forme qu'on dessine ici. Ce numéro nomme et généralise ce que tu utilisais déjà sans le savoir.The whole series has met some: Display formatted our messages, Error structured our failures, From converted our errors under the ?, PartialEq enabled ==. These aren't separate language mechanisms: they're standard-library traits, exactly the shape we draw here. This issue names and generalizes what you were already using without knowing it.

№ 02La surpriseThe surprise4 min

Écrit une fois, valable pour mille types — et pourtant aussi rapide que copié-collé.Written once, valid for a thousand types — yet as fast as copy-paste.

Sans abstraction, partager une logique entre types se paie en copier-coller : une fonction max par type comparable, identiques au type de l'élément près. C'est le terreau du bug qu'on corrige à deux endroits sur trois. Le générique borné par un trait écrit la logique une fois, pour tout type qui honore le contrat. La surprise, pour qui vient d'un langage à objets, n'est pas la concision — c'est que ça ne coûte rien à l'exécution. On s'attend à payer une indirection, un test de type au runtime, comme avec une méthode virtuelle. Il n'en est rien : un générique Rust n'existe pas à l'exécution sous forme générique. Le compilateur le transforme. Le chapitre suivant montre comment.Without abstraction, sharing logic between types costs copy-paste: one max per comparable type, identical down to the element type. It's the breeding ground for the bug you fix in two places out of three. The trait-bounded generic writes the logic once, for any type that honors the contract. The surprise, for someone from an object-oriented language, isn't the concision — it's that it costs nothing at runtime. You expect to pay an indirection, a runtime type check, as with a virtual method. Not so: a Rust generic doesn't exist at runtime in generic form. The compiler transforms it. The next chapter shows how.

Le contre-exemple — la même logique, recopiée par typeThe counter-example — the same logic, recopied per type
// Sans abstraction : une copie de la logique par type concret
fn max_i32(liste: &[i32]) -> Option<&i32> {
    let mut m = liste.first()?;
    for x in liste { if x > m { m = x; } }
    Some(m)
}
fn max_f64(liste: &[f64]) -> Option<&f64> { /* ... le même corps, mot pour mot ... */ }
fn max_str(liste: &[&str]) -> Option<&&str> { /* ... encore une fois ... */ }

// trois fonctions identiques à l'élément près. chaque nouveau type
// comparable = un copier-coller de plus, et un bug à corriger en trois endroits.

Trois corps de fonction rigoureusement identiques, séparés par le seul type de l'élément. Le coût n'est pas qu'esthétique : une correction (gérer la liste vide, changer la condition) doit être reportée partout, et l'oubli d'un seul endroit est un bug silencieux. Chaque nouveau type comparable rouvre le copier-coller. C'est l'absence d'abstraction qui se paie — non pas en lenteur, mais en maintenance et en risque.Three strictly identical function bodies, separated only by the element type. The cost isn't merely aesthetic: a fix (handle the empty list, change the condition) must be carried everywhere, and missing a single spot is a silent bug. Each new comparable type reopens the copy-paste. It's the absence of abstraction being paid for — not in slowness, but in maintenance and risk.

Un générique borné dit ce dont il a besoin — une capacité — et non de qui il l'exige — un type précis.A bounded generic states what it needs — a capability — not whom it demands it of — a precise type.
№ 03La mécaniqueThe mechanics5 min

La monomorphisation : le générique disparaît, remplacé par des copies concrètes.Monomorphization: the generic vanishes, replaced by concrete copies.

Voici le secret du « coût zéro ». Quand tu appelles une fonction générique avec un type donné, le compilateur en fabrique une version spécialisée pour ce type — une copie où T est remplacé par i32, par &str, par ce que tu veux. C'est la monomorphisation : transformer un code poly-morphe (plusieurs formes) en plusieurs codes mono-morphes (une forme chacun). À l'exécution, il n'y a plus de générique du tout : juste des fonctions concrètes, appelées directement, sans indirection ni recherche de méthode. La performance est exactement celle du code écrit à la main pour chaque type. Le polymorphisme a été entièrement résolu à la compilation.Here's the 'zero-cost' secret. When you call a generic function with a given type, the compiler builds a specialized version for that type — a copy where T is replaced by i32, by &str, by whatever. That's monomorphization: turning poly-morphic code (many forms) into several mono-morphic codes (one form each). At runtime, there's no generic at all: just concrete functions, called directly, with no indirection or method lookup. Performance is exactly that of hand-written code for each type. The polymorphism was entirely resolved at compile time.

Avant / après — le générique remplacé par une copie par typeBefore / after — the generic replaced by one copy per type
// Ce qu'on écrit — une seule fonction générique
fn afficher<T: std::fmt::Display>(x: T) { println!("{x}"); }

afficher(42);        // T = i32
afficher("salut");   // T = &str

// Ce que le compilateur GÉNÈRE (monomorphisation) — une copie par type utilisé
fn afficher_i32(x: i32)  { println!("{x}"); }     // version spécialisée pour i32
fn afficher_str(x: &str) { println!("{x}"); }     // version spécialisée pour &str
// les appels pointent vers la bonne copie. plus aucun « générique » à l'exécution :
// que des fonctions concrètes, appelées en direct, comme si tu les avais écrites.
afficher<T>
1 écriture générique1 generic source
à la compilationat compile time──→
afficher_i32(x: i32)
afficher_str(x: &str)
afficher_f64(x: f64)
N copies concrètes dans le binaireN concrete copies in the binary
Le prix se paie à la compilation, pas à l'exécutionThe price is paid at compile time, not runtime

Rien n'est gratuit ; le coût a seulement changé de moment. Une fonction générique utilisée avec dix types donne dix copies dans le binaire : plus de code à compiler (temps de build qui s'allonge) et un exécutable plus gros (ce qu'on appelle le code bloat). C'est l'échange fondamental du dispatch statique : on dépense de l'espace et du temps de compilation pour n'avoir aucun coût à l'exécution. Le prochain numéro montrera le marché inverse — dyn, une seule copie partagée, au prix d'une indirection au runtime.Nothing is free; the cost has only moved in time. A generic function used with ten types yields ten copies in the binary: more code to compile (build time grows) and a larger executable (what's called code bloat). That's static dispatch's fundamental trade: you spend space and compile time to have no runtime cost. The next issue will show the opposite deal — dyn, a single shared copy, at the price of a runtime indirection.

Borne inline, where, impl TraitInline bound, where, impl Trait
// Trois écritures de bornes de trait — à choisir selon la lisibilité

fn somme<T: Add<Output = T> + Copy>(a: T, b: T) -> T { a + b }   // (1) bornes inline

fn fusion<T>(a: &[T], b: &[T]) -> Vec<T>                         // (2) clause where :
where                                                            //     respire quand les
    T: Clone + PartialOrd,                                       //     bornes s'accumulent
{ /* ... */ }

fn afficher(x: impl Display) { println!("{x}"); }               // (3) impl Trait, le sucre
impl Trait, le générique discret.impl Trait, the quiet generic.

fn f(x: impl Display) est du sucre pour fn f<T: Display>(x: T) : même monomorphisation, écriture plus légère quand on n'a pas besoin de nommer T. En position de retour — -> impl Iterator — il dit « je rends un type concret qui implémente ce trait, sans en révéler le nom », utile pour cacher des types d'itérateurs imprononçables. Toujours du dispatch statique, jamais d'indirection : impl Trait n'est pas dyn Trait.fn f(x: impl Display) is sugar for fn f<T: Display>(x: T): same monomorphization, lighter syntax when you don't need to name T. In return position — -> impl Iterator — it says 'I return a concrete type implementing this trait, without revealing its name', handy for hiding unpronounceable iterator types. Still static dispatch, never indirection: impl Trait is not dyn Trait.

Le générique est une abstraction de compilation : à l'exécution, il n'en reste que du code concret, écrit pour toi par le compilateur.The generic is a compile-time abstraction: at runtime, only concrete code remains, written for you by the compiler.
№ 04Le piègeThe trap4 min

La généricité gratuite : un coût de lecture et de binaire pour une réutilisation qui n'existe pas.Gratuitous genericity: a reading and binary cost for reuse that doesn't exist.

Une fois la puissance des generics comprise, la tentation est de tout abstraire. Chaque fonction reçoit des paramètres de type, chaque argument devient « un quelconque T borné ». Mais l'abstraction a un prix, et c'est le même refrain que le sur-typage du Vol 3 : du boilerplate de bornes, des signatures illisibles, des messages d'erreur qui triplent de longueur, et — propre aux generics — une monomorphisation qui gonfle le binaire et ralentit la compilation. Quand un paramètre générique n'est jamais instancié qu'avec un seul type, il n'abstrait rien : il déguise un type concret en variable, et fait payer à tout le monde une flexibilité que personne n'utilise.Once you grasp generics' power, the temptation is to abstract everything. Every function gets type parameters, every argument becomes 'some bounded T'. But abstraction has a price, and it's the same refrain as Vol 3's over-typing: bound boilerplate, unreadable signatures, error messages that triple in length, and — specific to generics — monomorphization that bloats the binary and slows compilation. When a generic parameter is only ever instantiated with one type, it abstracts nothing: it disguises a concrete type as a variable, and makes everyone pay for a flexibility no one uses.

Le contre-exemple — trois paramètres de type pour un seul appelThe counter-example — three type parameters for a single call
// Tout générifier « au cas où » — abstraction sans réutilisation
fn enregistrer<T, W, S>(donnee: T, sortie: &mut W, serialiseur: S) -> Result<(), Err>
where
    T: Serialize + Clone + Debug + Send + Sync + 'static,
    W: Write + Seek,
    S: Serialiseur<T>,
{ /* ... appelé À UN SEUL endroit, avec un seul jeu de types ... */ }

// trois paramètres de type et six bornes pour un appel unique. la signature
// est illisible, les messages d'erreur monstrueux, et le binaire monomorphise
// un cas qui n'existe qu'une fois. de la généricité qui ne sert personne.

Cette signature exige un effort de lecture considérable — déchiffrer trois paramètres et leurs bornes — pour une fonction appelée une seule fois, avec des types fixes. La généricité n'y achète aucune réutilisation : elle ne fait qu'ajouter du bruit, allonger les diagnostics du compilateur, et générer une copie monomorphisée pour un cas unique. C'est la cérémonie du Vol 3 transposée aux types : de l'abstraction qui ne prévient ni ne permet rien.This signature demands considerable reading effort — decoding three parameters and their bounds — for a function called once, with fixed types. The genericity buys no reuse: it only adds noise, lengthens the compiler's diagnostics, and generates a monomorphized copy for a single case. It's Vol 3's ceremony transposed to types: abstraction that neither prevents nor enables anything.

Le bon réflexe — concret par défaut, générique quand ça réutiliseThe right reflex — concrete by default, generic when it reuses
// On fixe ce qui ne varie pas ; générique seulement là où ça réutilise
fn enregistrer(donnee: &Rapport, sortie: &mut File) -> Result<(), Err> {
    let bytes = serde_json::to_vec(donnee)?;   // types concrets : clairs, directs
    sortie.write_all(&bytes)?;
    Ok(())
}

// si DEMAIN un second type de donnée doit passer ici, alors — et seulement
// alors — on extrait un paramètre générique borné par le trait utile. la
// généricité se mérite par un besoin réel de réutilisation, pas par anticipation.

Des types concrets rendent la fonction immédiatement lisible : on voit ce qui entre, ce qui sort, sans déchiffrer de bornes. La généricité s'introduit le jour où un vrai second cas d'usage apparaît — pas par anticipation d'un besoin hypothétique. La règle est la même qu'au Vol 3 : la flexibilité est un coût, et on ne le dépense que là où une réutilisation réelle le rembourse.Concrete types make the function immediately readable: you see what goes in, what comes out, without decoding bounds. Genericity enters the day a real second use case appears — not by anticipating a hypothetical need. The rule is the same as Vol 3's: flexibility is a cost, spent only where real reuse repays it.

Le test mentalThe mental test

« Combien de types concrets traversent réellement ce paramètre ? » Un seul → ce n'est pas un générique, c'est un type concret déguisé : fixe-le. Plusieurs, aujourd'hui → le générique gagne son poids. Plusieurs, mais peut-être un jour → attends ce jour. Comme pour le newtype, la question n'est pas « puis-je abstraire », mais « cette abstraction rembourse-t-elle son coût de lecture et de binaire ».'How many concrete types actually flow through this parameter?' One → it's not a generic, it's a disguised concrete type: fix it. Several, today → the generic earns its weight. Several, but maybe someday → wait for that day. As with the newtype, the question isn't 'can I abstract', but 'does this abstraction repay its reading and binary cost'.

Quand la monomorphisation gêne.When monomorphization hurts.

Parfois, même avec plusieurs types, on ne VEUT pas N copies : un binaire embarqué à la taille contrainte, une fonction énorme instanciée pour vingt types, une collection hétérogène d'objets de types différents au même endroit. C'est exactement là que le dispatch dynamique entre en jeu — une seule copie, un type effacé derrière dyn. Le piège de la généricité a une sortie nommée : c'est le sujet du n°2.Sometimes, even with several types, you DON'T want N copies: a size-constrained embedded binary, a huge function instantiated for twenty types, a heterogeneous collection of differently-typed objects in one place. That's exactly where dynamic dispatch comes in — a single copy, a type erased behind dyn. The genericity trap has a named exit: it's issue n°2's subject.

Un paramètre générique instancié par un seul type n'abstrait rien : c'est un type concret déguisé, payé par tous.A generic parameter instantiated by one type abstracts nothing: it's a disguised concrete type, paid for by all.
№ 05Bilan & éditoWrap-up & editorial2 min

Partager un comportement par contrat, et le résoudre à la compilation.Share a behavior by contract, and resolve it at compile time.

Ce numéro ouvre le Vol 5 par la première moitié d'un choix. Le trait remplace l'héritage par un contrat : un comportement qu'un type décide d'honorer, et qu'une fonction générique exige par une borne. La résolution se fait à la compilation, par monomorphisation — le générique se mue en copies concrètes, et le coût à l'exécution tombe à zéro. Le prix se déplace vers le binaire et le temps de build, ce qui dessine la limite : on génère, mais on ne génère pas pour rien. Reste l'autre moitié du choix, le jour où l'on veut justement une seule copie, ou un type qu'on ne connaît qu'à l'exécution.This issue opens Vol 5 with the first half of a choice. The trait replaces inheritance with a contract: a behavior a type chooses to honor, that a generic function demands via a bound. Resolution happens at compile time, by monomorphization — the generic turns into concrete copies, and the runtime cost drops to zero. The price moves to the binary and build time, which draws the limit: you generate, but not for nothing. What remains is the other half of the choice, for the day you want exactly one copy, or a type you only know at runtime.

L'outilThe toolCe qu'il installeWhat it sets upLe réflexeThe reflex
TraitTraitUn contrat de comportement, sans héritage ni filiation.A behavior contract, without inheritance or lineage.Partager une capacité → trait, pas une classe de base.Share a capability → trait, not a base class.
<T: Trait>Une fonction qui exige une capacité, pas un type précis.A function that demands a capability, not a precise type.Besoin d'un comportement → borne le générique par le trait.Need a behavior → bound the generic by the trait.
MonomorphisationMonomorphizationUne copie concrète par type — zéro coût à l'exécution.One concrete copy per type — zero runtime cost.Le générique disparaît au build ; le prix est binaire + temps.The generic vanishes at build; the price is binary + time.
impl TraitUn générique léger, en argument ou en retour.A lightweight generic, in argument or return position.Pas besoin de nommer T → impl Trait, toujours statique.No need to name T → impl Trait, still static.
Le générique borné, c'est exiger une capacité et la faire résoudre à la compilation — la flexibilité d'un côté, le coût zéro de l'autre.The bounded generic means demanding a capability and having it resolved at compile time — flexibility on one side, zero cost on the other.
Mot de l'éditeurFrom the editor

Le compilateur sait tout à l'avance. Et quand il ne le peut pas ?The compiler knows everything in advance. And when it can't?

La monomorphisation suppose une chose : que le compilateur connaisse, à la compilation, le type concret de chaque appel. C'est presque toujours vrai — et c'est ce qui rend le dispatch statique gratuit. Mais pas toujours. Parfois on veut une liste d'objets de types différents traités uniformément : une collection de formes hétérogènes, un tableau de gestionnaires d'événements, un système de plugins chargés au lancement. Le type exact n'est pas connu à l'avance, ou pas unique. La monomorphisation ne peut alors plus opérer.Monomorphization assumes one thing: that the compiler knows, at compile time, the concrete type of each call. That's almost always true — and it's what makes static dispatch free. But not always. Sometimes you want a list of differently-typed objects handled uniformly: a collection of heterogeneous shapes, an array of event handlers, a plugin system loaded at startup. The exact type isn't known in advance, or isn't unique. Monomorphization can no longer operate.

Vol 5 №2 : les trait objects, dyn Trait. On échange le coût zéro contre la flexibilité : au lieu de N copies spécialisées, une seule fonction qui reçoit un pointeur « gras » — la donnée, plus une vtable, la table des méthodes à appeler. La résolution passe de la compilation à l'exécution : une indirection par appel, en échange de la liberté de mélanger les types et de n'avoir qu'une copie. Le même comportement, l'autre moitié de l'arbitrage — et la fin de l'idée reçue que Rust serait « toujours statique ».Vol 5 №2: trait objects, dyn Trait. You trade zero cost for flexibility: instead of N specialized copies, a single function receiving a 'fat' pointer — the data, plus a vtable, the table of methods to call. Resolution moves from compile time to runtime: one indirection per call, in exchange for the freedom to mix types and keep a single copy. The same behavior, the other half of the trade-off — and the end of the myth that Rust is 'always static'.