Chapitre 09
Modules, crates & nalgebra
Objectifs du chapitre
- Organiser un projet Rust en modules (
mod,pub, chemins) plutôt qu'en un uniquemain.rsmonolithique. - Répartir un module sur plusieurs fichiers (
src/algebre.rsoualgebre/mod.rs). - Comprendre l'écosystème des crates (crates.io,
cargo add, bibliothèque vs binaire). - Écrire des tests unitaires fiables pour du calcul numérique, avec tolérance sur les flottants.
- Découvrir
nalgebra, le crate de référence pour l'algèbre linéaire en robotique Rust.
1. Pourquoi découper en modules ?
En C/C++, on sépare le code en .h/.c et on gère la visibilité avec des en-têtes et
parfois static. En Rust, l'unité d'organisation s'appelle un module
(mod) : elle regroupe des fonctions, structures, énumérations, constantes, et contrôle
leur visibilité de façon beaucoup plus fine et vérifiée par le compilateur.
Par défaut, tout est privé au module (et à ses enfants). Le mot-clé pub
expose un élément aux modules parents. Il n'y a pas de fichier d'en-tête séparé à maintenir en synchronisation :
le compilateur connaît toute l'arborescence des modules directement depuis le code source.
// src/main.rs
mod capteurs {
// Privé au module `capteurs` : invisible depuis `main`.
fn calibrer_offset() -> f64 {
0.02
}
// `pub` : visible depuis l'extérieur du module.
pub fn lire_distance_brute() -> f64 {
1.234 + calibrer_offset()
}
pub struct Lidar {
pub portee_max_m: f64,
angle_deg: f64, // champ privé : encapsulation à la C++ avec des accesseurs
}
impl Lidar {
pub fn new(portee_max_m: f64) -> Self {
Lidar { portee_max_m, angle_deg: 0.0 }
}
pub fn angle(&self) -> f64 {
self.angle_deg
}
}
}
fn main() {
let d = capteurs::lire_distance_brute();
let lidar = capteurs::Lidar::new(12.0);
println!("distance = {d}, portée max = {}", lidar.portee_max_m);
// capteurs::calibrer_offset() ; // ERREUR : fonction privée
// lidar.angle_deg ; // ERREUR : champ privé
}
Piège venant du C/C++ : il n'y a pas de règle « un fichier = une unité de compilation
indépendante ». Un module Rust peut être défini inline (comme ci-dessus) ou dans un fichier séparé,
mais dans les deux cas il fait partie du même arbre de compilation, vérifié en une seule passe par le
compilateur. Il n'existe pas non plus de #include qui copie-colle du texte : mod
déclare qu'un module existe et où le trouver, use sert seulement à raccourcir des chemins déjà
visibles.
2. Chemins : crate::, self::, super::
Rust identifie tout élément par un chemin dans l'arbre de modules, un peu comme un chemin de fichier. Trois racines sont utiles :
crate::— chemin absolu, part de la racine de la crate courante.self::— chemin relatif, part du module courant.super::— chemin relatif, part du module parent (comme..en chemin de fichiers).
mod cinematique {
pub mod directe {
pub fn position_effecteur() -> [f64; 3] {
// Appel vers le module voisin `inverse` via `super::`
super::inverse::verifie_bornes();
[0.1, 0.2, 0.3]
}
}
pub mod inverse {
pub fn verifie_bornes() {
// ...
}
pub fn resoudre() {
// Chemin absolu explicite : toujours valide, quel que soit
// l'endroit d'où on écrit ce code.
let _p = crate::cinematique::directe::position_effecteur();
}
}
}
use cinematique::directe::position_effecteur; // raccourci pour `main`
fn main() {
let p = position_effecteur();
println!("{p:?}");
}
En pratique, on utilise surtout use en tête de fichier pour importer ce dont on a besoin, et on
réserve les chemins complets (crate::…) aux cas où plusieurs éléments importés portent le même
nom, ou pour la clarté dans du code générique.
3. Découper un module en fichier séparé
Dès qu'un module grossit, on le sort de main.rs. Deux conventions sont possibles pour un module
nommé algebre :
src/algebre.rs— un simple fichier (style moderne, recommandé pour un module « feuille »).src/algebre/mod.rs— un dossier avecmod.rs, utile quand le module a lui-même des sous-modules dans des fichiers voisins (src/algebre/vecteur.rs,src/algebre/matrice.rs…).
Dans les deux cas, on déclare le module une seule fois dans le parent avec mod algebre;
(sans corps de bloc, terminé par un point-virgule) : c'est ce qui indique au compilateur d'aller chercher le
fichier correspondant.
// src/main.rs
mod algebre; // -> cherche src/algebre.rs ou src/algebre/mod.rs
mod cinematique; // -> cherche src/cinematique.rs ou src/cinematique/mod.rs
mod kalman; // -> cherche src/kalman.rs ou src/kalman/mod.rs
use algebre::Vecteur3;
fn main() {
let v = Vecteur3::new(1.0, 0.0, 0.0);
println!("norme = {}", v.norme());
}
// src/algebre.rs
pub struct Vecteur3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl Vecteur3 {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Vecteur3 { x, y, z }
}
pub fn norme(&self) -> f64 {
(self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
}
}
Voici l'arborescence d'un petit projet robotique organisé en modules — c'est celle que nous allons faire évoluer pour le fil rouge du cours :
mon_robot/
├── Cargo.toml
└── src/
├── main.rs // mod algebre; mod cinematique; mod kalman;
├── algebre.rs // Vecteur3, Matrice3, opérations de base
├── cinematique.rs // modèle direct / inverse du bras robotique
└── kalman.rs // filtre de Kalman pour la fusion capteurs
Chaque fichier de module peut lui-même contenir des sous-modules avec mod, y compris son
propre module de tests #[cfg(test)] mod tests { … } (section 5). Le découpage en fichiers
n'affecte ni la visibilité ni la vitesse de compilation de façon significative : c'est avant tout une
question de lisibilité et de responsabilité claire par fichier.
4. Crates : bibliothèques, binaires et crates.io
Une crate est l'unité de compilation et de distribution en Rust — l'équivalent grossier
d'une bibliothèque statique/dynamique ou d'un exécutable en C/C++, mais gérée par un outil unique :
cargo. Un projet cargo new mon_robot génère un binaire
(src/main.rs, avec une fonction fn main()) ; cargo new --lib ma_lib
génère une bibliothèque (src/lib.rs, sans main, pensée pour être
réutilisée par d'autres crates). Un même projet peut avoir les deux à la fois.
crates.io est le registre public officiel de crates (l'équivalent de PyPI ou npm). Pour ajouter une dépendance, deux méthodes équivalentes :
# Cargo.toml — méthode manuelle
[package]
name = "mon_robot"
version = "0.1.0"
edition = "2021"
[dependencies]
nalgebra = "0.33"
# méthode recommandée : cargo add fait la même chose automatiquement
cargo add nalgebra
cargo add nalgebra --features serde-serialize # avec des fonctionnalités optionnelles
cargo add résout automatiquement la dernière version compatible et met à jour
Cargo.toml ainsi que Cargo.lock (le fichier qui fige les versions exactes
utilisées, à committer pour un binaire — comparable à un package-lock.json). Pas besoin de
chercher un numéro de version à la main.
5. Tests unitaires et calcul numérique
Rust intègre un framework de tests directement dans le langage et dans cargo : pas besoin de
dépendance externe comme Google Test en C++. La convention est de placer les tests dans un sous-module
tests, annoté #[cfg(test)] pour qu'il ne soit compilé que lors des tests
(donc absent du binaire final).
// src/algebre.rs (suite)
pub fn produit_scalaire(a: &[f64; 3], b: &[f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[cfg(test)]
mod tests {
use super::*; // importe tout ce qui est visible dans le module parent
#[test]
fn produit_scalaire_orthogonal() {
let x = [1.0, 0.0, 0.0];
let y = [0.0, 1.0, 0.0];
assert_eq!(produit_scalaire(&x, &y), 0.0);
}
#[test]
fn norme_vecteur_unitaire() {
let v = Vecteur3::new(1.0, 2.0, 2.0);
// ATTENTION : ne jamais comparer des f64 avec == !
let attendu = 3.0;
assert!(
(v.norme() - attendu).abs() < 1e-9,
"norme calculée = {}, attendue = {attendu}",
v.norme()
);
}
}
Piège classique : comme en C/C++, comparer deux f64 avec == ou
assert_eq! échoue souvent à cause des erreurs d'arrondi en virgule flottante, même quand le
calcul est mathématiquement correct. Pour un test numérique, comparez toujours avec une tolérance :
assert!((a - b).abs() < epsilon). Un epsilon typique pour des calculs de robotique en
f64 est 1e-9 à 1e-6 selon la propagation d'erreurs attendue.
On lance tous les tests d'un projet avec :
cargo test # tous les tests
cargo test norme_vecteur # filtre par nom
cargo test -- --nocapture # affiche les println! même si le test passe
Pour du code temps réel, les tests unitaires ne remplacent pas la mesure de latence sur cible, mais ils garantissent la correction fonctionnelle des calculs (cinématique, filtrage, contrôle) avant même de déployer sur le robot — un aller-retour bien moins coûteux qu'un test au banc.
6. nalgebra : l'algèbre linéaire prête à l'emploi
nalgebra est le crate de référence pour l'algèbre linéaire en Rust, très utilisé en robotique
(cinématique, SLAM, filtrage de Kalman, planification). Il fournit des types à taille fixe connue à la
compilation (rapides, sans allocation) et des types à taille dynamique.
Vector3<f64>,Matrix3<f64>,Matrix4<f64>— tailles fixes, typiques pour un point 3D, une matrice de rotation, une transformation homogène.DVector<f64>,DMatrix<f64>— tailles dynamiques (déterminées à l'exécution), pour des états de taille variable (ex. un filtre de Kalman générique).Isometry3<f64>,Rotation3<f64>— transformations rigides (rotation + translation) et rotations pures, avec composition et inversion sûres.
[package]
name = "mon_robot"
version = "0.1.0"
edition = "2021"
[dependencies]
nalgebra = "0.33"
use nalgebra::{Matrix3, Vector3};
fn main() {
// Une matrice de rotation approximative autour de Z (petits angles)
let r = Matrix3::new(
0.995, -0.0998, 0.0,
0.0998, 0.995, 0.0,
0.0, 0.0, 1.0,
);
let p = Vector3::new(1.0, 0.0, 0.5);
// Produit matrice-vecteur : orientation appliquée à un point
let p_tourne = r * p;
println!("point tourné = {p_tourne}");
// Transpose : pour une matrice de rotation, transpose == inverse
let r_t = r.transpose();
// Inverse générale : renvoie Option>, jamais de panique
// ni d'undefined behavior comme diviser par un déterminant nul en C.
match r.try_inverse() {
Some(r_inv) => println!("inverse trouvée, déterminant = {}", r.determinant()),
None => println!("matrice non inversible (déterminant ≈ 0)"),
}
println!("R^T proche de R^-1 ? {}", (r_t - r.try_inverse().unwrap()).norm() < 1e-6);
}
use nalgebra::{Isometry3, Vector3, Rotation3};
fn transformation_bras() {
// Une transformation homogène = rotation + translation, composables
// sans jamais construire une matrice 4x4 "à la main".
let rotation = Rotation3::from_axis_angle(&Vector3::z_axis(), 0.3);
let translation = Vector3::new(0.2, 0.0, 0.15);
let t_base_vers_effecteur = Isometry3::from_parts(translation.into(), rotation.into());
let point_local = Vector3::new(0.05, 0.0, 0.0);
let point_monde = t_base_vers_effecteur * point_local;
println!("point dans le repère monde = {point_monde}");
// Inverse d'une isométrie : toujours définie (contrairement à une
// matrice quelconque), car une rotation + translation est toujours inversible.
let t_inverse = t_base_vers_effecteur.inverse();
println!("retour au repère local = {}", t_inverse * point_monde);
}
Réutilisez nalgebra plutôt que de réécrire vos propres routines d'algèbre linéaire : c'est du
code testé, optimisé, et qui couvre déjà l'essentiel (décompositions, résolution de systèmes linéaires,
quaternions…). Cela dit, il reste indispensable de comprendre ce que font ces opérations
(produit matriciel, déterminant, inversion) pour interpréter correctement les résultats et déboguer un
problème numérique — c'est l'objet du chapitre 10, consacré à l'algèbre
linéaire appliquée à la robotique.
7. Fil rouge : structurer le projet du cours
À partir de ce chapitre, le projet du fil rouge se découpe en modules dédiés, préfigurant les prochains chapitres :
mon_robot/
├── Cargo.toml # dépendance : nalgebra = "0.33"
└── src/
├── main.rs # point d'entrée, assemble les modules
├── algebre.rs # wrappers/utilitaires autour de nalgebra
├── cinematique.rs # modèle géométrique du bras (chapitre 10)
└── kalman.rs # fusion de capteurs (chapitres suivants)
// src/main.rs
mod algebre;
mod cinematique;
mod kalman;
use nalgebra::{Matrix3, Vector3};
fn main() {
let m = Matrix3::identity();
let v = Vector3::new(1.0, 2.0, 3.0);
println!("m * v = {}", m * v);
match algebre::inverser(&m) {
Some(inv) => println!("inverse = {inv}"),
None => println!("matrice non inversible"),
}
}
// src/algebre.rs
use nalgebra::Matrix3;
/// Tente d'inverser une matrice 3x3 ; renvoie `None` si elle est singulière
/// (déterminant proche de zéro), sans jamais paniquer.
pub fn inverser(m: &Matrix3<f64>) -> Option<Matrix3<f64>> {
m.try_inverse()
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::Matrix3;
#[test]
fn identite_est_sa_propre_inverse() {
let id = Matrix3::identity();
let inv = inverser(&id).expect("l'identité est toujours inversible");
assert!((inv - id).norm() < 1e-9);
}
#[test]
fn matrice_singuliere_renvoie_none() {
let singuliere = Matrix3::new(
1.0, 2.0, 3.0,
2.0, 4.0, 6.0, // ligne 2 = 2 * ligne 1 -> déterminant nul
0.0, 1.0, 1.0,
);
assert!(inverser(&singuliere).is_none());
}
}
Exercices
Exercice 1 — Créer un module et l'appeler depuis main
Créez un module capteurs (dans un fichier src/capteurs.rs) contenant une fonction
publique pub fn temperature_moyenne(mesures: &[f64]) -> f64 qui renvoie la moyenne d'un
tableau de mesures (0.0 si le tableau est vide). Déclarez le module dans main.rs et appelez la
fonction.
Voir la solution
// src/capteurs.rs
pub fn temperature_moyenne(mesures: &[f64]) -> f64 {
if mesures.is_empty() {
return 0.0;
}
let somme: f64 = mesures.iter().sum();
somme / mesures.len() as f64
}
// src/main.rs
mod capteurs;
fn main() {
let mesures = [20.1, 20.4, 19.8, 20.0];
let moyenne = capteurs::temperature_moyenne(&mesures);
println!("température moyenne = {moyenne:.2} °C");
}
Le module est déclaré une seule fois avec mod capteurs; dans main.rs, ce qui dit
au compilateur d'aller lire src/capteurs.rs. La fonction doit être pub pour être
appelable depuis main via le chemin capteurs::temperature_moyenne.
Exercice 2 — Test unitaire avec tolérance sur des f64
Reprenez la fonction temperature_moyenne de l'exercice 1 et ajoutez un module de tests
#[cfg(test)] mod tests { … } avec au moins deux tests : un cas normal, et le cas d'un tableau
vide. Comparez les résultats flottants avec une tolérance, pas avec ==.
Voir la solution
// src/capteurs.rs (ajout en bas du fichier)
#[cfg(test)]
mod tests {
use super::*;
fn presque_egal(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn moyenne_de_quatre_mesures() {
let mesures = [20.1, 20.4, 19.8, 20.0];
let m = temperature_moyenne(&mesures);
assert!(presque_egal(m, 20.075), "moyenne obtenue = {m}");
}
#[test]
fn moyenne_tableau_vide_vaut_zero() {
let mesures: [f64; 0] = [];
assert_eq!(temperature_moyenne(&mesures), 0.0);
}
}
assert_eq!(…, 0.0) est acceptable ici car 0.0 est un cas particulier renvoyé
littéralement par le code (pas le résultat d'un calcul flottant accumulé) : il n'y a donc pas d'erreur
d'arrondi possible. Dès qu'un résultat provient d'une somme ou d'une division en virgule flottante, on
compare toujours avec une tolérance, comme dans moyenne_de_quatre_mesures.
Exercice 3 — Ajouter nalgebra et multiplier deux matrices
Ajoutez nalgebra comme dépendance (via cargo add nalgebra ou directement dans
Cargo.toml). Écrivez une fonction qui construit deux Matrix3<f64> et affiche
leur produit.
Voir la solution
cargo add nalgebra
use nalgebra::Matrix3;
fn main() {
let a = Matrix3::new(
1.0, 2.0, 0.0,
0.0, 1.0, 3.0,
4.0, 0.0, 1.0,
);
let b = Matrix3::identity() * 2.0; // matrice diagonale (2, 2, 2)
let produit = a * b;
println!("A * B =\n{produit}");
}
Matrix3::new prend les coefficients ligne par ligne. Le produit a * b utilise
l'opérateur * surchargé par nalgebra pour effectuer un vrai produit matriciel
(pas un produit terme à terme).
Exercice 4 — Inverser une matrice avec try_inverse et gérer le None
Écrivez une fonction pub fn inverse_ou_identite(m: &Matrix3<f64>) -> Matrix3<f64>
qui renvoie l'inverse de m si elle existe, ou la matrice identité sinon (sans jamais paniquer).
Testez-la avec une matrice inversible et une matrice singulière.
Voir la solution
use nalgebra::Matrix3;
pub fn inverse_ou_identite(m: &Matrix3<f64>) -> Matrix3<f64> {
match m.try_inverse() {
Some(inv) => inv,
None => Matrix3::identity(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matrice_inversible() {
let m = Matrix3::new(
2.0, 0.0, 0.0,
0.0, 2.0, 0.0,
0.0, 0.0, 2.0,
);
let inv = inverse_ou_identite(&m);
let attendu = Matrix3::identity() * 0.5;
assert!((inv - attendu).norm() < 1e-9);
}
#[test]
fn matrice_singuliere_donne_identite() {
let m = Matrix3::new(
1.0, 1.0, 0.0,
2.0, 2.0, 0.0, // ligne 2 = 2 * ligne 1
0.0, 0.0, 1.0,
);
let resultat = inverse_ou_identite(&m);
assert!((resultat - Matrix3::identity()).norm() < 1e-9);
}
}
try_inverse renvoie un Option<Matrix3<f64>> : c'est l'équivalent
sûr d'une fonction C qui renverrait un code d'erreur ou un pointeur nul en cas de matrice singulière, mais
ici le compilateur force à traiter le cas None avant d'utiliser le résultat — pas de
risque d'oublier la vérification et d'utiliser une matrice invalide par erreur.
Récapitulatif
moddéclare un module (inline ou dans un fichier séparé) ;pubcontrôle sa visibilité ;useraccourcit les chemins.- Un module peut vivre dans
src/nom.rsousrc/nom/mod.rs; on le déclare une seule fois dans son parent avecmod nom;. - Une crate est soit une bibliothèque (
src/lib.rs), soit un binaire (src/main.rs) ; crates.io héberge l'écosystème,cargo addsimplifie l'ajout de dépendances. #[cfg(test)] mod tests { #[test] fn … }+cargo testpour valider le code, y compris les calculs numériques — toujours avec une tolérance sur lesf64, jamais==.nalgebrafournitVector3,Matrix3,Matrix4,DVector,DMatrix,Isometry3,Rotation3et les opérations courantes (produit, transpose,try_inverse,determinant) : à réutiliser plutôt qu'à réécrire, tout en comprenant les bases (chapitre 10).