Chapitre 05
Structs, enums & méthodes
Objectifs du chapitre
- Définir des structs (nommées, tuple, unit) et comprendre la mutabilité de l'instance entière
- Écrire des blocs
impl: méthodes (&self,&mut self,self) et fonctions associées - Modéliser des alternatives avec des enums, y compris des variantes porteuses de données
- Utiliser
matchpour traiter tous les cas d'un enum de façon exhaustive - Reconnaître que
Option<T>etResult<T, E>sont eux-mêmes des enums - Construire les types géométriques et d'état d'un robot :
Vec2,Vec3,EtatRobot,Articulation
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 :
&self— emprunt immuable : la méthode lit l'instance sans la modifier (le cas le plus courant).&mut self— emprunt mutable : la méthode modifie l'instance en place.self— prise de possession : la méthode consomme l'instance (souvent pour une conversion finale).
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
}
Debug— permet d'afficher la valeur avec{:?}(utile enprintln!pour déboguer).Clone— ajoute une méthode.clone()qui duplique explicitement la valeur.Copy— rend le type copiable implicitement (bit à bit), comme les types numériques de base ; possible seulement si tous les champs sont eux-mêmesCopy.PartialEq— permet de comparer avec==et!=, champ par champ.
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
- Les structs (nommées, tuple, unit) regroupent des données ; la mutabilité se déclare sur la variable entière, pas champ par champ.
- Un bloc
implattache des méthodes (&self,&mut self,self) et des fonctions associées (souventSelf::new) à un type. - Les enums Rust combinent enum et union taguée du C : chaque variante peut porter des données différentes, et le compilateur sait toujours laquelle est active.
matchsur un enum est exhaustif : oublier une variante est une erreur de compilation, pas un bug d'exécution.Option<T>etResult<T, E>sont de simples enums de la bibliothèque standard — détaillés au chapitre 8.#[derive(Debug, Clone, Copy, PartialEq)]génère automatiquement affichage, copie, clonage et comparaison, sans code répétitif ni risque d'oubli.- Le fil rouge (
Vec2,Vec3,EtatRobot,Articulation) servira de base aux chapitres suivants (collections, traits, algèbre linéaire, cinématique).