🦀 Rust pour la robotique · temps réel

Chapitre 07
Traits & génériques

Objectifs du chapitre

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.

TraitRôle
DebugAffichage de débogage avec {:?} / {:#?}.
CloneCopie explicite en profondeur via .clone().
CopyCopie implicite bit-à-bit (types simples : entiers, flottants, petites structures).
PartialEqComparaison d'égalité avec == et !=.
PartialOrdComparaison d'ordre avec <, <=, >, >=.
DefaultValeur « 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