🦀 Rust pour la robotique · temps réel

Chapitre 05
Structs, enums & méthodes

Objectifs du chapitre

1. Structs : regrouper des données liées

En C, on regroupe des données liées dans un struct. Rust reprend exactement cette idée, mais y ajoute des méthodes, des garanties de mutabilité et un système de dérivation automatique (#[derive(...)]) qui évite beaucoup de code répétitif.

1.1 Struct nommée

struct Vec2 {
    x: f64,
    y: f64,
}

fn main() {
    let v = Vec2 { x: 1.0, y: 2.0 };
    println!("v.x = {}, v.y = {}", v.x, v.y);
}

On instancie une struct avec la syntaxe NomDeStruct { champ: valeur, ... }. L'ordre des champs n'a pas d'importance, seuls les noms comptent — contrairement à un struct C initialisé positionnellement ({1.0, 2.0}).

1.2 Tuple structs

Une tuple struct a un nom mais des champs anonymes, accessibles par index (.0, .1, ...). Pratique pour donner un type distinct à une donnée simple, sans avoir besoin de nommer ses champs.

struct Angle(f64);        // radians, mais avec un type dédié
struct Couleur(u8, u8, u8);

fn main() {
    let a = Angle(1.5708);
    let rouge = Couleur(255, 0, 0);
    println!("angle = {} rad, canal rouge = {}", a.0, rouge.0);
}

1.3 Unit structs

Une unit struct n'a aucun champ. Elle sert de marqueur de type, souvent pour implémenter un trait sans avoir besoin de stocker de donnée (on approfondira ça au chapitre 7 sur les traits).

struct CapteurSimule;

fn main() {
    let _c = CapteurSimule;
}

1.4 Mutabilité : tout ou rien

En Rust, la mutabilité se déclare sur la variable, pas sur le champ individuellement. Si v n'est pas mut, aucun de ses champs n'est modifiable — même partiellement.

let mut v = Vec2 { x: 0.0, y: 0.0 };
v.x = 3.0;   // OK : v est mut
v.y += 1.0;  // OK

let w = Vec2 { x: 0.0, y: 0.0 };
// w.x = 3.0;  // ERREUR de compilation : w n'est pas mut

En C++, on peut avoir un champ const à l'intérieur d'une struct par ailleurs mutable (mutabilité fine, champ par champ). En Rust, la mutabilité est une propriété de la liaison (let vs let mut), pas du champ : il n'existe pas de « champ const » dans une struct normale. Si tu veux qu'un champ précis soit protégé après construction, il faut le rendre privé et n'exposer que des méthodes de lecture (pattern « encapsulation », voir chapitre 9 sur les modules).

2. Blocs impl : méthodes et fonctions associées

Contrairement au C où les fonctions qui manipulent un struct flottent séparément (souvent préfixées vec2_norme(...)), Rust permet d'attacher directement des fonctions au type via un bloc impl. C'est similaire aux méthodes d'une classe C++, sans héritage d'implémentation.

struct Vec2 {
    x: f64,
    y: f64,
}

impl Vec2 {
    // Fonction associée : pas de "self", sert de constructeur.
    // Convention : Self::new(...)
    fn new(x: f64, y: f64) -> Self {
        Self { x, y }
    }

    // Méthode en lecture seule : emprunte &self, ne consomme rien.
    fn norme(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    // Méthode qui modifie l'instance : emprunte &mut self.
    fn normalise(&mut self) {
        let n = self.norme();
        self.x /= n;
        self.y /= n;
    }

    // Méthode qui consomme self : transforme la valeur et la rend.
    fn vers_tuple(self) -> (f64, f64) {
        (self.x, self.y)
    }
}

fn main() {
    let mut v = Vec2::new(3.0, 4.0);
    println!("norme = {}", v.norme());   // 5.0
    v.normalise();
    println!("normalise = ({}, {})", v.x, v.y);

    let t = v.vers_tuple();  // v est déplacée ici, plus utilisable après
    println!("{:?}", t);
}

Trois formes de self possibles dans une méthode, exactement comme les emprunts vus au chapitre 4 :

Une fonction associée (sans self, comme Vec2::new) s'appelle avec la syntaxe Type::fonction(...). Une méthode (avec self) s'appelle avec la syntaxe instance.methode(...) — Rust insère automatiquement les & ou &mut nécessaires (« auto-référencement »), pas besoin d'écrire (&v).norme().

3. Enums : bien plus qu'en C

En C, un enum n'est qu'une liste d'entiers nommés, et une union permet de superposer plusieurs représentations en mémoire sans qu'aucune information ne dise laquelle est active (le programmeur doit s'en souvenir lui-même, source classique de bugs). Rust fusionne les deux idées dans un seul concept sûr : chaque variante d'un enum peut porter ses propres données, et le compilateur sait toujours quelle variante est active.

3.1 Variantes simples

#[derive(Debug, PartialEq)]
enum Etat {
    Arrete,
    EnMouvement,
    Erreur,
}

fn main() {
    let e = Etat::EnMouvement;
    if e == Etat::EnMouvement {
        println!("Le robot bouge.");
    }
}

3.2 Variantes avec données

Chaque variante peut embarquer des champs différents — impossible à exprimer nativement avec un enum C. C'est l'équivalent sûr d'une union taguée.

#[derive(Debug)]
enum Articulation {
    Rotoide(f64),     // angle en radians
    Prismatique(f64), // déplacement en mètres
}

fn main() {
    let j1 = Articulation::Rotoide(0.785);
    let j2 = Articulation::Prismatique(0.12);
    println!("{:?} {:?}", j1, j2);
}

3.3 match sur un enum

match force à traiter toutes les variantes (exhaustivité vérifiée à la compilation). Impossible d'oublier un cas comme on oublierait un case dans un switch C — le compilateur refuse de compiler sinon.

fn decrire(j: &Articulation) -> String {
    match j {
        Articulation::Rotoide(angle) => format!("rotoïde, angle = {:.3} rad", angle),
        Articulation::Prismatique(dist) => format!("prismatique, déplacement = {:.3} m", dist),
    }
}

fn main() {
    let j = Articulation::Rotoide(1.2);
    println!("{}", decrire(&j));
}

Un switch C sans default compile même s'il ne couvre pas tous les cas possibles ; l'oubli se paie à l'exécution. Un match Rust qui ne couvre pas toutes les variantes d'un enum est une erreur de compilation. C'est l'une des raisons pour lesquelles les enums Rust sont particulièrement adaptés à une machine à états de robot (arrêt d'urgence, calibration, etc.) : ajouter un nouvel état fait apparaître, à la compilation, tous les endroits du code qu'il faut mettre à jour.

3.4 Aperçu : Option<T> et Result<T, E>

Deux enums de la bibliothèque standard, omniprésents en Rust, ne sont rien d'autre que des enums avec des variantes génériques :

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option<T> remplace l'usage d'un pointeur nul (pas de NULL en Rust) et Result<T, E> remplace les codes d'erreur ad hoc (errno, valeurs sentinelles -1). On y reviendra en détail au chapitre 8 ; pour l'instant, retiens simplement que ce sont des enums que l'on traite avec match comme n'importe quel autre.

4. Derive : générer du code automatiquement

L'attribut #[derive(...)] demande au compilateur de générer automatiquement l'implémentation de certains traits standards, en analysant les champs de la struct ou de l'enum. C'est l'équivalent Rust d'écrire soi-même un opérateur de comparaison ou une fonction de copie en C++, mais sans risque d'oubli ou d'incohérence.

#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 {
    x: f64,
    y: f64,
}

fn main() {
    let a = Vec2 { x: 1.0, y: 2.0 };
    let b = a;              // Copy : b est une copie, a reste utilisable
    println!("{:?}", a);    // Debug : affichage avec {:?}
    println!("{}", a == b); // PartialEq : comparaison structurelle
}

En robotique, presque tous les petits types numériques (Vec2, Vec3, Quaternion, un état articulaire) gagnent à être #[derive(Debug, Clone, Copy, PartialEq)] : Debug facilite le diagnostic dans les logs, Copy évite de se soucier de l'ownership pour des données de quelques octets, et PartialEq simplifie les tests unitaires (assert_eq!). Résultat : du code aussi rapide qu'en C, mais où le compilateur vérifie pour toi qu'on ne mélange jamais un état « non initialisé » avec un état valide.

5. Fil rouge : géométrie et état du robot

On construit maintenant les briques de base qui serviront tout au long du cours : un vecteur 2D, un vecteur 3D, l'état d'un robot mobile, et le type d'une articulation.

#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 {
    x: f64,
    y: f64,
}

impl Vec2 {
    fn new(x: f64, y: f64) -> Self {
        Self { x, y }
    }

    fn norme(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    fn produit_scalaire(&self, autre: &Vec2) -> f64 {
        self.x * autre.x + self.y * autre.y
    }

    fn plus(&self, autre: &Vec2) -> Vec2 {
        Vec2::new(self.x + autre.x, self.y + autre.y)
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec3 {
    x: f64,
    y: f64,
    z: f64,
}

impl Vec3 {
    fn new(x: f64, y: f64, z: f64) -> Self {
        Self { x, y, z }
    }

    fn norme(&self) -> f64 {
        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
    }

    fn produit_scalaire(&self, autre: &Vec3) -> f64 {
        self.x * autre.x + self.y * autre.y + self.z * autre.z
    }

    fn plus(&self, autre: &Vec3) -> Vec3 {
        Vec3::new(self.x + autre.x, self.y + autre.y, self.z + autre.z)
    }
}

/// État d'un robot mobile 2D : position (x, y) et cap theta (radians).
#[derive(Debug, Clone, Copy, PartialEq)]
struct EtatRobot {
    x: f64,
    y: f64,
    theta: f64,
}

impl EtatRobot {
    fn new(x: f64, y: f64, theta: f64) -> Self {
        Self { x, y, theta }
    }

    /// Avance le robot de `d` mètres dans la direction de son cap actuel
    /// (modèle cinématique très simplifié, sans dérive latérale).
    fn avance(&mut self, d: f64) {
        self.x += d * self.theta.cos();
        self.y += d * self.theta.sin();
    }
}

/// Type d'articulation d'un bras robotique.
#[derive(Debug, Clone, Copy)]
enum Articulation {
    Rotoide(f64),     // angle en radians
    Prismatique(f64), // déplacement en mètres
}

fn main() {
    let a = Vec2::new(1.0, 0.0);
    let b = Vec2::new(0.0, 1.0);
    println!("a . b = {}", a.produit_scalaire(&b));  // 0.0 (orthogonaux)
    println!("norme de a + b = {}", a.plus(&b).norme());

    let mut robot = EtatRobot::new(0.0, 0.0, 0.0);
    robot.avance(2.0);
    println!("{:?}", robot);  // EtatRobot { x: 2.0, y: 0.0, theta: 0.0 }

    let joints = [Articulation::Rotoide(0.5), Articulation::Prismatique(0.1)];
    for j in &joints {
        println!("{:?}", j);
    }
}

Sans méthodes, le code de cinématique ressemblerait vite à une suite de fonctions libres avec des noms à rallonge (etat_robot_avance(&mut EtatRobot, f64)), comme en C. En attachant les opérations aux types via impl, on écrit robot.avance(2.0) : c'est plus lisible, l'auto-complétion de l'éditeur devient utile, et surtout le compilateur vérifie que seule une &mut EtatRobot peut appeler avance — impossible de modifier l'état du robot par erreur via une référence en lecture seule, contrairement à un pointeur C brut qui ne distingue pas les deux.

Exercices

Exercice 1 — Compléter Vec2 : produit scalaire et norme

Sans regarder la section 5, écris depuis zéro une struct Vec2 { x: f64, y: f64 } avec un bloc impl contenant : new(x, y), norme(&self) -> f64 et produit_scalaire(&self, autre: &Vec2) -> f64. Vérifie dans main que le produit scalaire de (1.0, 0.0) et (0.0, 1.0) vaut bien 0.0.

Voir la solution
struct Vec2 {
    x: f64,
    y: f64,
}

impl Vec2 {
    fn new(x: f64, y: f64) -> Self {
        Self { x, y }
    }

    fn norme(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    fn produit_scalaire(&self, autre: &Vec2) -> f64 {
        self.x * autre.x + self.y * autre.y
    }
}

fn main() {
    let a = Vec2::new(1.0, 0.0);
    let b = Vec2::new(0.0, 1.0);
    println!("norme de a = {}", a.norme());               // 1.0
    println!("a . b = {}", a.produit_scalaire(&b));      // 0.0
}

Le produit scalaire de deux vecteurs orthogonaux vaut zéro : c'est une bonne façon de vérifier rapidement que la formule est correcte.

Exercice 2 — Méthode distance

Ajoute à Vec2 une méthode distance(&self, autre: &Vec2) -> f64 qui calcule la distance euclidienne entre deux points. Réutilise norme et plus (ou une soustraction) plutôt que de tout réécrire.

Voir la solution
impl Vec2 {
    // ... new, norme, produit_scalaire déjà définis ...

    fn moins(&self, autre: &Vec2) -> Vec2 {
        Vec2::new(self.x - autre.x, self.y - autre.y)
    }

    fn distance(&self, autre: &Vec2) -> f64 {
        self.moins(autre).norme()
    }
}

fn main() {
    let a = Vec2::new(0.0, 0.0);
    let b = Vec2::new(3.0, 4.0);
    println!("distance = {}", a.distance(&b));  // 5.0
}

distance ne fait presque rien elle-même : elle compose deux méthodes déjà écrites (moins puis norme). C'est le style idiomatique en Rust — de petites méthodes réutilisables plutôt qu'une grosse fonction qui recalcule tout.

Exercice 3 — Enum TypeCapteur avec match

Définis un enum TypeCapteur avec trois variantes : Lidar(f64) (portée max en mètres), Camera { largeur: u32, hauteur: u32 } (résolution) et Imu (sans donnée). Écris une fonction decrire(c: &TypeCapteur) -> String qui utilise match pour produire une description adaptée à chaque variante.

Voir la solution
#[derive(Debug)]
enum TypeCapteur {
    Lidar(f64),
    Camera { largeur: u32, hauteur: u32 },
    Imu,
}

fn decrire(c: &TypeCapteur) -> String {
    match c {
        TypeCapteur::Lidar(portee) => format!("Lidar, portée max {:.1} m", portee),
        TypeCapteur::Camera { largeur, hauteur } => {
            format!("Caméra {}x{}", largeur, hauteur)
        }
        TypeCapteur::Imu => "IMU (accéléromètre + gyroscope)".to_string(),
    }
}

fn main() {
    let capteurs = [
        TypeCapteur::Lidar(12.0),
        TypeCapteur::Camera { largeur: 1920, hauteur: 1080 },
        TypeCapteur::Imu,
    ];
    for c in &capteurs {
        println!("{}", decrire(c));
    }
}

La variante Camera utilise la syntaxe « struct-like » (champs nommés) au lieu d'un tuple : c'est utile dès qu'une variante porte plusieurs valeurs et que leur ordre serait sujet à confusion. Le match déstructure directement largeur et hauteur.

Exercice 4 — Mettre à jour EtatRobot

En reprenant la struct EtatRobot { x, y, theta } du fil rouge, écris une méthode tourne(&mut self, dtheta: f64) qui ajoute dtheta au cap actuel, puis une méthode avance_et_tourne(&mut self, d: f64, dtheta: f64) qui combine avance et tourne en un seul appel (ordre : on avance d'abord dans la direction actuelle, puis on tourne).

Voir la solution
#[derive(Debug, Clone, Copy, PartialEq)]
struct EtatRobot {
    x: f64,
    y: f64,
    theta: f64,
}

impl EtatRobot {
    fn new(x: f64, y: f64, theta: f64) -> Self {
        Self { x, y, theta }
    }

    fn avance(&mut self, d: f64) {
        self.x += d * self.theta.cos();
        self.y += d * self.theta.sin();
    }

    fn tourne(&mut self, dtheta: f64) {
        self.theta += dtheta;
    }

    fn avance_et_tourne(&mut self, d: f64, dtheta: f64) {
        self.avance(d);
        self.tourne(dtheta);
    }
}

fn main() {
    let mut robot = EtatRobot::new(0.0, 0.0, 0.0);
    robot.avance_et_tourne(1.0, std::f64::consts::FRAC_PI_2); // avance de 1m, puis tourne de 90°
    println!("{:?}", robot);
}

Comme avance_et_tourne prend &mut self, elle peut appeler d'autres méthodes &mut self du même type sans souci d'emprunt : on est toujours à l'intérieur du même emprunt mutable exclusif sur robot.

Récapitulatif