Chapitre 07
Traits & génériques
Objectifs du chapitre
- Écrire des fonctions et des structures génériques et comprendre la monomorphisation (zéro coût à l'exécution).
- Définir et implémenter des traits (l'équivalent Rust des interfaces), avec méthodes par défaut.
- Contraindre le générique via des bornes de traits (
T: Trait,where,impl Trait). - Connaître les traits standards utiles :
Debug,Clone,Copy,PartialEq,Default,PartialOrd. - Surcharger les opérateurs (
+,-,*) pour écrire de l'algèbre linéaire lisible et sûre.
1. Les génériques
En C++ vous connaissez les templates. Rust a un mécanisme très proche dans l'esprit : le
polymorphisme paramétrique. On écrit du code une seule fois, valable pour tout type
T, et le compilateur génère une version spécialisée par type réellement utilisé.
1.1 Fonctions génériques
Une fonction qui renvoie le maximum de deux valeurs, quel que soit leur type — pourvu qu'on sache les
comparer. La contrainte T: PartialOrd (une borne de trait, cf. §3) dit « T
doit savoir se comparer avec <, >, etc. »
fn max<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
fn main() {
println!("{}", max(3, 7)); // T = i32 -> 7
println!("{}", max(2.5_f64, 1.0)); // T = f64 -> 2.5
println!("{}", max('a', 'z')); // T = char -> z
}
Sans la borne : PartialOrd, le code ne compilerait pas : Rust refuse d'utiliser
> sur un T dont il ne sait rien. C'est une différence majeure avec les templates C++.
Templates C++ vs génériques Rust. En C++, un template n'est vérifié qu'au moment de
l'instanciation : si vous passez un type sans operator>, l'erreur explose au fond de la
bibliothèque, souvent illisible. En Rust, la signature déclare ses exigences (T: PartialOrd) :
le corps est vérifié une fois pour toutes, et l'erreur pointe l'appel fautif. Les concepts C++20
rapprochent enfin C++ de ce modèle.
1.2 Structures génériques
Une structure peut elle aussi être paramétrée par un type. Un point 2D valable pour des entiers comme pour des flottants :
struct Point<T> {
x: T,
y: T,
}
// Un bloc `impl` générique : méthodes valables pour tout T
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
Point { x, y }
}
}
fn main() {
let entier = Point::new(1, 2); // Point<i32>
let reel = Point::new(1.0, 2.0); // Point<f64>
println!("{} {}", entier.x, reel.y);
}
1.3 Monomorphisation : le zéro-coût
À la compilation, Rust monomorphise : pour chaque combinaison de types réellement utilisée,
il génère une copie spécialisée du code. max(3, 7) produit un max pour i32,
max(2.5, 1.0) un max pour f64. À l'exécution, il n'y a
aucun dispatch dynamique, aucune indirection, aucun boxing : le code généré est identique à
celui que vous auriez écrit à la main pour chaque type.
Pourquoi c'est capital en temps réel. La monomorphisation garantit des génériques à coût nul : pas d'allocation cachée, pas d'appel virtuel, temps d'exécution déterministe. Vous pouvez écrire une bibliothèque d'algèbre générique et l'utiliser dans une boucle de contrôle à 1 kHz sans surcoût, exactement comme les templates C++. (Le revers : le binaire grossit, une version par type — rarement un problème en robotique embarquée.)
2. Les traits
Un trait décrit un ensemble de comportements qu'un type peut fournir. C'est l'équivalent Rust d'une interface (Java) ou d'une classe abstraite purement virtuelle (C++). Contrairement au C++, Rust n'a pas d'héritage de classe : on compose des comportements via des traits.
2.1 Définir et implémenter un trait
// Le contrat : « tout Capteur sait donner une mesure »
trait Capteur {
fn lire(&self) -> f64;
}
struct Thermometre {
tension: f64,
}
// On implémente le trait POUR un type précis
impl Capteur for Thermometre {
fn lire(&self) -> f64 {
self.tension * 100.0 - 273.15 // conversion bidon
}
}
fn main() {
let t = Thermometre { tension: 3.0 };
println!("{:.2} °C", t.lire());
}
2.2 Méthodes par défaut
Un trait peut fournir une implémentation par défaut, que chaque type peut réutiliser telle quelle ou redéfinir. Cela ressemble aux méthodes concrètes d'une classe abstraite C++.
trait Capteur {
fn lire(&self) -> f64;
// Méthode par défaut : s'appuie sur `lire`
fn est_actif(&self) -> bool {
self.lire().is_finite()
}
}
Règle d'orphelin. Pour implémenter un trait pour un type, il faut que le trait ou le type vous appartienne (soit défini dans votre crate). Cela empêche deux bibliothèques de fournir des implémentations contradictoires du même trait pour le même type.
3. Bornes de traits
Une borne contraint un paramètre générique. Trois syntaxes équivalentes, du plus léger au plus lisible quand les contraintes s'accumulent :
use std::fmt::Display;
// 1) Borne inline
fn afficher<T: Display>(x: T) {
println!("{}", x);
}
// 2) Plusieurs bornes avec `+`
fn afficher_clone<T: Display + Clone>(x: T) {
let copie = x.clone();
println!("{} {}", x, copie);
}
// 3) Clause `where` : plus lisible quand il y a beaucoup de bornes
fn traiter<T, U>(a: T, b: U)
where
T: Display + Clone,
U: Display,
{
println!("{} {}", a, b);
}
Enfin, impl Trait est un raccourci pour « un type quelconque qui implémente ce trait », en
argument comme en valeur de retour :
// En argument : équivalent à fn afficher<T: Display>(x: T)
fn afficher(x: impl Display) {
println!("{}", x);
}
// En retour : « je renvoie quelque chose qui implémente Iterator,
// sans nommer le type concret »
fn compteur() -> impl Iterator<Item = u32> {
(0..5).map(|n| n * 2)
}
4. Traits standards utiles
La bibliothèque standard fournit des traits que l'on active le plus souvent via #[derive(...)] :
le compilateur génère l'implémentation pour vous.
| Trait | Rôle |
|---|---|
Debug | Affichage de débogage avec {:?} / {:#?}. |
Clone | Copie explicite en profondeur via .clone(). |
Copy | Copie implicite bit-à-bit (types simples : entiers, flottants, petites structures). |
PartialEq | Comparaison d'égalité avec == et !=. |
PartialOrd | Comparaison d'ordre avec <, <=, >, >=. |
Default | Valeur « par défaut » via T::default(). |
#[derive(Debug, Clone, Copy, PartialEq, Default)]
struct Vec2 {
x: f64,
y: f64,
}
fn main() {
let a = Vec2 { x: 1.0, y: 2.0 };
let b = a; // Copy : `a` reste utilisable
println!("{:?}", a); // Debug : Vec2 { x: 1.0, y: 2.0 }
println!("{}", a == b); // PartialEq : true
let z = Vec2::default(); // Default : Vec2 { x: 0.0, y: 0.0 }
println!("{:?}", z);
}
Copy vs Clone. Copy ne s'applique qu'aux types dont
la copie est un simple memcpy (pas de propriété à gérer, donc pas de String, pas de
Vec). Dès qu'un type est Copy, l'affectation ne « déplace » plus la valeur : elle la
duplique, comme en C++ par défaut. Clone, lui, est explicite et peut être coûteux.
5. Surcharge d'opérateurs
En Rust, les opérateurs sont des traits, dans le module std::ops. Implémenter
Add pour votre type, c'est donner un sens à +. C'est exactement ce qu'il faut pour
écrire de l'algèbre linéaire lisible : a + b plutôt que add(a, b).
5.1 Addition et soustraction de vecteurs
use std::ops::{Add, Sub};
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 {
x: f64,
y: f64,
}
impl Add for Vec2 {
type Output = Vec2; // le type du résultat de a + b
fn add(self, autre: Vec2) -> Vec2 {
Vec2 { x: self.x + autre.x, y: self.y + autre.y }
}
}
impl Sub for Vec2 {
type Output = Vec2;
fn sub(self, autre: Vec2) -> Vec2 {
Vec2 { x: self.x - autre.x, y: self.y - autre.y }
}
}
fn main() {
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b; // grâce à la surcharge : Vec2 { x: 4.0, y: 6.0 }
let d = b - a; // Vec2 { x: 2.0, y: 2.0 }
println!("{:?} {:?}", c, d);
}
5.2 Mise à l'échelle par un scalaire
Mul<f64> permet de multiplier un Vec2 par un flottant. On paramètre le trait par
le type de l'opérande de droite : Mul<f64> se lit « je sais me multiplier par un f64 ».
use std::ops::Mul;
impl Mul<f64> for Vec2 {
type Output = Vec2;
fn mul(self, k: f64) -> Vec2 {
Vec2 { x: self.x * k, y: self.y * k }
}
}
fn main() {
let v = Vec2 { x: 1.0, y: -2.0 };
let w = v * 0.5; // Vec2 { x: 0.5, y: -1.0 }
println!("{:?}", w);
}
L'ordre compte. v * 0.5 appelle Vec2::mul(v, 0.5). Pour écrire
0.5 * v, il faudrait implémenter Mul<Vec2> for f64 : Rust ne devine pas la
commutativité, chaque sens est une implémentation distincte.
5.3 Produit matrice-vecteur
Une matrice 2×2 qui sait transformer un Vec2 — le cœur d'une rotation ou d'un changement de repère :
#[derive(Debug, Clone, Copy)]
struct Mat2 {
// lignes
a: f64, b: f64,
c: f64, d: f64,
}
impl Mul<Vec2> for Mat2 {
type Output = Vec2;
fn mul(self, v: Vec2) -> Vec2 {
Vec2 {
x: self.a * v.x + self.b * v.y,
y: self.c * v.x + self.d * v.y,
}
}
}
fn main() {
// Rotation de 90° : (x, y) -> (-y, x)
let rot = Mat2 { a: 0.0, b: -1.0, c: 1.0, d: 0.0 };
let v = Vec2 { x: 1.0, y: 0.0 };
println!("{:?}", rot * v); // Vec2 { x: 0.0, y: 1.0 }
}
5.4 Un trait maison : le produit scalaire
Rien n'oblige à réutiliser un trait standard : on définit le sien pour exprimer un concept métier.
trait ProduitScalaire {
fn dot(&self, autre: &Self) -> f64;
}
impl ProduitScalaire for Vec2 {
fn dot(&self, autre: &Vec2) -> f64 {
self.x * autre.x + self.y * autre.y
}
}
fn main() {
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
println!("{}", a.dot(&b)); // 1*3 + 2*4 = 11
}
Une bibliothèque d'algèbre réutilisable et sûre. En combinant génériques, traits standards
(Copy, PartialEq) et surcharge d'opérateurs (std::ops), vous construisez
une algèbre linéaire qui se lit comme les maths (let c = a + b;), sans surcoût runtime
grâce à la monomorphisation, et dont le compilateur vérifie la cohérence des types. C'est exactement le socle
d'un contrôleur robotique fiable — et c'est ainsi que sont bâties des caisses comme nalgebra ou
glam.
6. Généricité numérique (aperçu)
Vous voudrez souvent écrire une algèbre valable à la fois pour f32 (léger, embarqué) et
f64 (précis). Naïvement, on tente un Vec2<T> générique… mais on bute vite :
Rust ne connaît pas la constante « zéro » ou l'opération « racine carrée » pour un T abstrait.
use std::ops::Add;
// On doit borner T par tout ce dont le corps a besoin.
#[derive(Debug, Clone, Copy)]
struct Vec2<T> {
x: T,
y: T,
}
impl<T: Add<Output = T> + Copy> Add for Vec2<T> {
type Output = Vec2<T>;
fn add(self, autre: Vec2<T>) -> Vec2<T> {
Vec2 { x: self.x + autre.x, y: self.y + autre.y }
}
}
Pour aller plus loin (avoir 0, 1, sqrt… de façon générique sur les types
numériques), la caisse num-traits fournit des traits prêts à l'emploi comme
Zero, One ou Float :
// Cargo.toml : num-traits = "0.2"
use num_traits::Float; // Float couvre f32 ET f64
fn norme<T: Float>(x: T, y: T) -> T {
(x * x + y * y).sqrt()
}
Restez pragmatique : tant que votre boucle de contrôle tourne en f64, un type concret suffit
et évite un enfer de bornes. Ne passez au générique numérique (via num-traits) que le jour où
vous devez vraiment supporter f32 et f64 avec le même code.
Exercices
Exercice 1 — Add pour Vec3
Définissez une structure Vec3 à trois champs f64 (x, y,
z), dérivez Debug et Copy, puis implémentez Add pour que
a + b additionne composante par composante.
Voir la solution
use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec3 {
x: f64,
y: f64,
z: f64,
}
impl Add for Vec3 {
type Output = Vec3;
fn add(self, autre: Vec3) -> Vec3 {
Vec3 {
x: self.x + autre.x,
y: self.y + autre.y,
z: self.z + autre.z,
}
}
}
fn main() {
let a = Vec3 { x: 1.0, y: 2.0, z: 3.0 };
let b = Vec3 { x: 4.0, y: 5.0, z: 6.0 };
println!("{:?}", a + b); // Vec3 { x: 5.0, y: 7.0, z: 9.0 }
}
Le champ type Output = Vec3; déclare le type de retour de l'opérateur. Comme Vec3
est Copy, a et b restent utilisables après l'addition.
Exercice 2 — Un trait Norme
Définissez un trait Norme avec une méthode norme(&self) -> f64, puis
implémentez-le pour Vec2 et Vec3 (norme euclidienne, c.-à-d. la racine de la somme
des carrés).
Voir la solution
trait Norme {
fn norme(&self) -> f64;
}
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
#[derive(Debug, Clone, Copy)]
struct Vec3 { x: f64, y: f64, z: f64 }
impl Norme for Vec2 {
fn norme(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
}
impl Norme for Vec3 {
fn norme(&self) -> f64 {
(self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
}
}
fn main() {
let a = Vec2 { x: 3.0, y: 4.0 };
let b = Vec3 { x: 1.0, y: 2.0, z: 2.0 };
println!("{}", a.norme()); // 5
println!("{}", b.norme()); // 3
}
Un même trait, deux implémentations distinctes. On peut ensuite écrire une fonction générique
fn plus_long<T: Norme>(a: &T, b: &T) qui compare la norme de deux vecteurs, quel que
soit leur type.
Exercice 3 — Fonction générique plus_grand
Écrivez une fonction générique plus_grand<T: PartialOrd>(a: T, b: T) -> T qui renvoie
le plus grand des deux arguments. Testez-la avec des entiers, des flottants et des char.
Voir la solution
fn plus_grand<T: PartialOrd>(a: T, b: T) -> T {
if a >= b { a } else { b }
}
fn main() {
println!("{}", plus_grand(3, 7)); // 7
println!("{}", plus_grand(2.5_f64, 1.0)); // 2.5
println!("{}", plus_grand('a', 'z')); // z
}
La borne T: PartialOrd autorise l'usage de >= dans le corps. Sans elle, le
compilateur refuse : il ne suppose jamais qu'un type inconnu sait se comparer. Grâce à la
monomorphisation, chaque appel produit une version spécialisée, sans coût à l'exécution.
Exercice 4 — Mul<f64> pour mettre à l'échelle un Vec2
Implémentez Mul<f64> pour Vec2 afin d'écrire v * k, qui multiplie
chaque composante par le scalaire k. Vérifiez que v * 2.0 double bien le vecteur.
Voir la solution
use std::ops::Mul;
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Mul<f64> for Vec2 {
type Output = Vec2;
fn mul(self, k: f64) -> Vec2 {
Vec2 { x: self.x * k, y: self.y * k }
}
}
fn main() {
let v = Vec2 { x: 1.5, y: -3.0 };
println!("{:?}", v * 2.0); // Vec2 { x: 3.0, y: -6.0 }
}
Mul<f64> précise le type de l'opérande droit. Pour obtenir aussi k * v, il
faudrait ajouter impl Mul<Vec2> for f64 : Rust traite chaque sens séparément.
Récapitulatif
- Les génériques (
fn f<T>,struct S<T>) écrivent du code valable pour plusieurs types ; la monomorphisation les rend à coût nul — crucial en temps réel. - Un trait est une interface : on le définit, on l'implémente pour un type, et il peut fournir des méthodes par défaut. Pas d'héritage de classe : on compose.
- Les bornes de traits (
T: Trait,where,impl Trait) déclarent les exigences d'un générique — l'erreur pointe l'appel fautif, pas le fond de la bibliothèque. - Traits standards à
#[derive(...)]:Debug,Clone,Copy,PartialEq,PartialOrd,Default. - La surcharge d'opérateurs via
std::ops(Add,Sub,Mul) rend l'algèbre lisible :let c = a + b;, mise à l'échelle scalaire, produit matrice-vecteur. - Pour être générique sur
f32/f64, bornez le type ou utilisez la caissenum-traits— mais ne le faites que si le besoin est réel.