Apprendre Rust de zéro jusqu'à un vrai outil en ligne de commande : ownership, emprunts, enums, gestion d'erreurs, traits, génériques, durées de vie, itérateurs, tests, threads et async avec Tokio.
Un programme réel ne tient pas dans main.rs. Rust l'organise en trois niveaux : le package (un projet Cargo, un Cargo.toml), la crate (une unité de compilation, binaire ou bibliothèque) et le module (un espace de noms à l'intérieur d'une crate). Ce chapitre couvre ces trois niveaux, les dépendances, et les tests, intégrés au langage et à Cargo.
Un module regroupe des fonctions, des types et d'autres modules. Tout y est privé par défaut ; pub rend un élément visible de l'extérieur.
mod geometrie {
pub struct Point {
pub x: f64,
pub y: f64,
}
pub fn distance(a: &Point, b: &Point) -> f64 {
carre(a.x - b.x) + carre(a.y - b.y)
}
fn carre(v: f64) -> f64 { // privé : invisible hors du module
v * v
}
}
fn main() {
use geometrie::{distance, Point};
let a = Point { x: 0.0, y: 0.0 };
let b = Point { x: 3.0, y: 4.0 };
println!("{}", distance(&a, &b).sqrt()); // 5
}Rendre une struct pub ne rend pas ses champs publics : chacun a son propre pub. C'est ce qui permet de garantir des invariants. Une struct aux champs privés ne peut être créée que par les fonctions de son module, qui vérifient ce qu'il faut.
En pratique, chaque module vit dans son fichier. mod nom; (avec un point-virgule) dit au compilateur d'aller chercher nom.rs ou nom/mod.rs :
src/
├── main.rs
├── config.rs
└── stockage/
├── mod.rs (ou stockage.rs à côté du dossier)
├── fichier.rs
└── memoire.rsmod config;
mod stockage;
fn main() {
let c = config::charger();
stockage::fichier::sauver(&c);
}pub mod fichier;
pub mod memoire;Les chemins se lisent comme des dossiers : crate:: part de la racine de la crate, super:: remonte d'un niveau, self:: désigne le module courant. use crée un raccourci local :
use crate::stockage::fichier::sauver;
use std::collections::{HashMap, HashSet};
use std::io::{self, Write}; // importe io et io::WriteUn package peut contenir une crate binaire (src/main.rs) et une crate bibliothèque (src/lib.rs). C'est l'organisation que je recommande dès qu'un projet grossit : toute la logique dans lib.rs, testable et réutilisable, et un main.rs réduit à lire les arguments et appeler la bibliothèque.
pub mod calcul;
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}fn main() {
println!("mon_projet {}", mon_projet::version());
}Le binaire importe la bibliothèque sous le nom du package, comme n'importe quelle dépendance. La macro env! lit la version de Cargo.toml à la compilation.
Les bibliothèques Rust s'appellent des crates et se publient sur crates.io. La documentation de chacune est générée automatiquement sur docs.rs.
cargo add serde --features derive
cargo add rand
cargo add --dev pretty_assertions # seulement pour les tests[dependencies]
rand = "0.9"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
pretty_assertions = "1""1" signifie « toute version 1.x compatible », selon le versionnage sémantique. Les versions exactes retenues sont figées dans Cargo.lock : commitez-le pour une application, pour que tout le monde compile la même chose. Les features activent des parties optionnelles d'une crate, ici la génération automatique de code de sérialisation.
Quelques crates qu'on retrouve dans presque tous les projets :
| Crate | Rôle |
|---|---|
serde, serde_json | Sérialisation : JSON, TOML, YAML |
clap | Arguments en ligne de commande |
anyhow, thiserror | Gestion d'erreurs (chapitre 5) |
tokio | Exécution asynchrone (chapitre 10) |
reqwest | Client HTTP |
axum | Serveur web |
sqlx | Base de données SQL avec requêtes vérifiées |
tracing | Journaux structurés |
rand | Nombres aléatoires |
Avant d'ajouter une dépendance, je regarde trois choses sur crates.io : le nombre de téléchargements récents, la date de la dernière version, et le nombre de dépendances qu'elle tire. Chaque crate ajoutée est du code d'inconnu compilé dans votre programme.
Les tests vivent dans le même fichier que le code, dans un module marqué #[cfg(test)], qui n'est compilé que pour les tests :
pub fn moyenne(valeurs: &[f64]) -> Option<f64> {
if valeurs.is_empty() {
return None;
}
Some(valeurs.iter().sum::<f64>() / valeurs.len() as f64)
}
fn arrondir(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn moyenne_simple() {
assert_eq!(moyenne(&[10.0, 20.0]), Some(15.0));
}
#[test]
fn moyenne_vide() {
assert_eq!(moyenne(&[]), None);
}
#[test]
fn arrondi_prive() {
// les tests unitaires voient aussi les fonctions privées
assert_eq!(arrondir(3.14159), 3.14);
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn acces_hors_limites() {
let v: Vec<i32> = Vec::new();
let _ = v[0];
}
}cargo testrunning 4 tests
test calcul::tests::acces_hors_limites - should panic ... ok
test calcul::tests::arrondi_prive ... ok
test calcul::tests::moyenne_simple ... ok
test calcul::tests::moyenne_vide ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outLes macros à connaître : assert!(condition), assert_eq!(a, b) et assert_ne!(a, b). Elles acceptent un message en plus : assert!(x > 0, "x vaut {x}"). Un test peut aussi renvoyer Result<(), E>, ce qui permet d'utiliser ? dedans.
cargo test moyenne ne lance que les tests dont le nom contient « moyenne ». Les tests tournent en parallèle par défaut : ils ne doivent pas dépendre les uns des autres.
Le dossier tests/ à la racine contient des tests qui utilisent la bibliothèque comme un utilisateur externe, donc seulement son API publique :
use mon_projet::calcul::moyenne;
#[test]
fn moyenne_depuis_l_exterieur() {
assert_eq!(moyenne(&[1.0, 2.0, 3.0]), Some(2.0));
}Chaque fichier de tests/ est compilé comme une crate séparée. C'est une autre raison de mettre la logique dans lib.rs : un binaire seul ne peut pas être testé ainsi.
Les commentaires /// documentent l'élément qui suit, en Markdown. Les blocs de code qu'ils contiennent sont compilés et exécutés par cargo test :
/// Renvoie la moyenne des valeurs, ou `None` si la tranche est vide.
///
/// ```
/// use mon_projet::calcul::moyenne;
/// assert_eq!(moyenne(&[2.0, 4.0]), Some(3.0));
/// ```
pub fn moyenne(valeurs: &[f64]) -> Option<f64> {
// ...
}Une documentation dont les exemples ne compilent plus fait échouer les tests. Elle ne peut donc pas mentir longtemps. cargo doc --open affiche le résultat, avec le même rendu que docs.rs.
Trois commandes suffisent à une CI Rust sérieuse :
cargo fmt --check
cargo clippy -- -D warnings
cargo test-D warnings transforme chaque avertissement de Clippy en erreur. Branchées dans un workflow GitHub Actions, elles bloquent toute pull request mal formatée, maladroite ou cassée.
Créez une bibliothèque cargo new --lib texte_utils avec une fonction publique est_palindrome(s: &str) -> bool qui ignore la casse, les espaces et la ponctuation. Écrivez au moins quatre tests, dont un avec des accents, et un exemple dans la documentation.
/// Indique si `s` se lit pareil dans les deux sens, en ignorant
/// la casse, les espaces et la ponctuation.
///
/// ```
/// assert!(texte_utils::est_palindrome("Engage le jeu que je le gagne"));
/// ```
pub fn est_palindrome(s: &str) -> bool {
let lettres: Vec<char> = s
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect();
lettres.iter().eq(lettres.iter().rev())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mot_simple() {
assert!(est_palindrome("radar"));
}
#[test]
fn avec_casse_et_ponctuation() {
assert!(est_palindrome("Esope reste ici et se repose."));
}
#[test]
fn avec_accents() {
assert!(est_palindrome("Ésé"));
}
#[test]
fn pas_un_palindrome() {
assert!(!est_palindrome("rust"));
}
}Le test avec accents passe parce qu'on compare des char, pas des octets. "été" aurait échoué si on avait inversé les octets de la chaîne : le é en occupe deux.
Modules privés par défaut, logique dans lib.rs, dépendances déclarées dans Cargo.toml, tests à côté du code et documentation vérifiée : l'outillage de Rust pousse vers des projets propres sans effort particulier. Le chapitre suivant aborde ce qui fait la réputation du langage : la concurrence sans peur.