Chapitre 08
Gestion d'erreurs : Result, Option, ?
Objectifs du chapitre
- Comprendre pourquoi Rust n'a ni exceptions, ni pointeur nul, et ce que ça change
- Manipuler
Option<T>pour représenter une valeur absente (mesure invalide, division impossible…) - Manipuler
Result<T, E>pour représenter un échec récupérable (capteur en panne, matrice singulière…) - Propager les erreurs proprement avec l'opérateur
?au lieu d'empiler desif - Savoir quand un
panic!survient et pourquoi il est inacceptable dans une boucle de contrôle temps réel - Définir son propre type d'erreur pour une bibliothèque de robotique
1. La philosophie Rust : l'erreur est une valeur
En C, une fonction qui peut échouer renvoie souvent un code magique : -1, NULL,
0… charge à l'appelant de se souvenir de la convention et de vérifier le retour — ce qu'on
oublie régulièrement. En C++, on peut lancer une exception, mais rien dans la signature de la fonction
ne le signale : il faut lire la documentation (ou le code source) pour savoir ce qui peut être lancé,
et un throw non attrapé peut dérouler la pile n'importe où, y compris dans un contexte
temps réel où ce n'est pas acceptable.
Rust n'a pas d'exceptions et n'a pas de pointeur nul. À la place, il encode l'absence de valeur et l'échec directement dans le type de retour :
Option<T>— « il y a peut-être une valeur, ou pas ». Remplace leNULL.Result<T, E>— « ça a marché et voiciT, ou ça a échoué et voici pourquoi (E) ». Remplace le code d'erreur et l'exception.
Le compilateur t'oblige à traiter les deux cas avant de pouvoir utiliser la valeur. Impossible d'« oublier »
de vérifier une erreur comme on oublie un if (ret != 0) en C : le code ne compile tout
simplement pas tant que le cas d'erreur n'est pas géré (ou explicitement ignoré, ce qui se voit).
En C, int diviser(int a, int b) qui renvoie -1 en cas d'erreur est ambigu :
comment distinguer un vrai résultat de -1 d'un code d'erreur -1 ? Une fonction
qui renvoie NULL en cas d'échec force l'appelant à tester le pointeur — et rien n'empêche
de l'oublier, ce qui produit un déréférencement nul en production. En Rust, ces deux problèmes disparaissent :
le type Option<i32> ou Result<i32, ErreurDiv> sépare complètement
« la valeur » de « l'échec », et le compilateur refuse de compiler un code qui utiliserait la valeur
sans avoir géré le cas d'erreur.
2. Option<T> : une valeur qui peut manquer
Option<T> est un enum de la bibliothèque standard, défini (conceptuellement) ainsi :
enum Option<T> {
Some(T),
None,
}
C'est le remplaçant direct du pointeur nul : là où en C tu écrirais Capteur* c = NULL;,
en Rust tu écris Option<Capteur>. La différence essentielle : le compilateur t'oblige
à distinguer le cas Some du cas None avant d'accéder à la valeur contenue.
Reprenons le fil rouge : une fonction d'inversion scalaire qui échoue si le dénominateur est trop proche de zéro.
/// Inverse une valeur, ou None si elle est trop proche de zéro
/// (typiquement un gain, un facteur d'échelle...).
fn inverser(a: f64) -> Option<f64> {
const EPSILON: f64 = 1e-9;
if a.abs() < EPSILON {
None
} else {
Some(1.0 / a)
}
}
fn main() {
let gain = 2.0;
let inv = inverser(gain);
// 1. match : la manière la plus explicite
match inv {
Some(v) => println!("inverse = {v}"),
None => println!("gain trop proche de zéro, inversion impossible"),
}
// 2. if let : quand on ne s'intéresse qu'au cas Some
if let Some(v) = inverser(4.0) {
println!("1/4 = {v}");
}
}
Méthodes utiles sur Option
Le match exhaustif est parfois lourd pour des cas simples. Option fournit
des méthodes combinatoires pour rester concis :
fn main() {
let a = inverser(0.0); // None
let b = inverser(5.0); // Some(0.2)
// unwrap() : extrait la valeur, panique si None (à réserver aux prototypes/tests)
let v = b.unwrap();
println!("{v}");
// expect() : comme unwrap, mais avec un message d'erreur explicite au panic
let v = b.expect("le gain ne devrait jamais être nul ici");
// unwrap_or(défaut) : fournit une valeur de repli, jamais de panic
let v = a.unwrap_or(0.0);
println!("valeur de repli : {v}");
// unwrap_or_else(|| ...) : le défaut est calculé paresseusement
let v = a.unwrap_or_else(|| calculer_defaut());
// map() : transforme la valeur si elle est présente, sans la « déballer »
let v_doublee: Option<f64> = b.map(|x| x * 2.0);
// and_then() : enchaîne une opération qui peut elle-même échouer
let resultat: Option<f64> = inverser(2.0).and_then(inverser);
}
fn calculer_defaut() -> f64 { 1.0 }
map et and_then sont l'équivalent, pour Option, de ce que
les chaînes de méthodes fonctionnelles apportent ailleurs : on enchaîne des transformations sans
jamais avoir à écrire de if (ptr != NULL) imbriqués.
Règle simple : unwrap() et expect() sont parfaits pour explorer une idée
au REPL ou dans un test unitaire. Dans du code qui tournera sur un robot, préfère systématiquement
match, if let, unwrap_or ou l'opérateur ? —
des façons de gérer l'absence de valeur sans jamais arrêter le programme.
3. Result<T, E> : une opération qui peut échouer, avec une raison
Option dit « il y a une valeur, ou rien ». Mais parfois on veut savoir pourquoi
ça a échoué. C'est le rôle de Result<T, E> :
enum Result<T, E> {
Ok(T),
Err(E),
}
Exemple : le calcul d'une racine carrée, qui n'a pas de sens pour un nombre négatif.
/// Racine carrée « sûre » : Err si x est négatif.
fn racine(x: f64) -> Result<f64, String> {
if x < 0.0 {
Err(format!("racine carrée d'un nombre négatif : {x}"))
} else {
Ok(x.sqrt())
}
}
fn main() {
match racine(16.0) {
Ok(r) => println!("racine = {r}"),
Err(e) => eprintln!("erreur : {e}"),
}
match racine(-4.0) {
Ok(r) => println!("racine = {r}"),
Err(e) => eprintln!("erreur : {e}"),
}
}
Même famille de méthodes que sur Option : unwrap(), expect(),
unwrap_or(), map(), mais aussi map_err() pour transformer
l'erreur, et is_ok() / is_err() pour un simple test booléen.
Cas d'usage typique en robotique : inversion de matrice
Une matrice singulière (déterminant nul) n'est pas inversible. C'est exactement le genre d'échec
qu'on modélise naturellement avec Result :
/// Vérifie qu'une matrice 2x2 [[a, b], [c, d]] est inversible
/// (déterminant non nul), condition préalable à toute inversion.
fn verifier_inversible(a: f64, b: f64, c: f64, d: f64) -> Result<f64, String> {
let det = a * d - b * c;
if det.abs() < 1e-9 {
Err(format!("matrice singulière (déterminant = {det:.2e}), non inversible"))
} else {
Ok(det)
}
}
fn inverser_matrice_2x2(a: f64, b: f64, c: f64, d: f64) -> Result<[[f64; 2]; 2], String> {
let det = verifier_inversible(a, b, c, d)?;
let inv_det = 1.0 / det;
Ok([
[ d * inv_det, -b * inv_det],
[-c * inv_det, a * inv_det],
])
}
4. L'opérateur ? : propager sans s'encombrer
Remarque, dans inverser_matrice_2x2 ci-dessus, la ligne verifier_inversible(a, b, c, d)?.
C'est l'opérateur ?, sans doute la fonctionnalité qui rend la gestion d'erreurs de Rust
agréable à écrire au quotidien.
Sans ?, il faudrait écrire :
fn inverser_matrice_2x2_verbeux(a: f64, b: f64, c: f64, d: f64) -> Result<[[f64; 2]; 2], String> {
let det = match verifier_inversible(a, b, c, d) {
Ok(valeur) => valeur,
Err(e) => return Err(e), // retour anticipé : on propage l'erreur telle quelle
};
let inv_det = 1.0 / det;
Ok([
[ d * inv_det, -b * inv_det],
[-c * inv_det, a * inv_det],
])
}
? fait exactement ceci : si la valeur est Ok(x), il s'évalue en x
et l'exécution continue normalement ; si c'est Err(e), il déclenche immédiatement un
return Err(e) depuis la fonction courante — un retour anticipé, sans panique, sans
déroulement de pile façon exception. La même règle s'applique à Option : ?
sur un None déclenche un return None immédiat.
/// Simule la lecture d'un capteur de distance. Peut échouer
/// (capteur déconnecté, valeur hors plage...).
fn lire_capteur_brut(id: u8) -> Result<f64, String> {
if id == 0 {
return Err("capteur 0 : non connecté".to_string());
}
Ok(42.0) // valeur simulée
}
/// Lit un capteur puis en dérive une distance calibrée.
/// Propage l'erreur de lecture avec ?, sans avoir à la re-matcher.
fn distance_calibree(id: u8, facteur: f64) -> Result<f64, String> {
let brut = lire_capteur_brut(id)?; // remonte l'Err telle quelle si échec
let inv = inverser(facteur)
.ok_or_else(|| "facteur de calibration nul".to_string())?; // Option -> Result puis ?
Ok(brut * inv)
}
fn main() {
match distance_calibree(1, 2.0) {
Ok(d) => println!("distance = {d}"),
Err(e) => eprintln!("échec de lecture : {e}"),
}
}
Notez ok_or_else() : elle convertit un Option<T> en Result<T, E>
en fournissant l'erreur à utiliser si la valeur était None. C'est le pont naturel entre
les deux mondes, très utile quand une fonction combine des étapes qui renvoient l'un ou l'autre.
? ne peut être utilisé que dans une fonction dont le type de retour est compatible
(Result<_, E> ou Option<_>) — le compilateur refuse de compiler
sinon, ce qui évite toute confusion sur « où » l'erreur va réellement remonter.
5. panic! : quand tout s'arrête
Un panic! est différent d'une Err : ce n'est pas une erreur récupérable,
c'est l'arrêt (par défaut, déroulement de pile puis fin du thread, voire du programme) suite à une
situation que le programme juge irrécupérable. Il survient notamment :
- en appelant
.unwrap()ou.expect()sur unNoneou unErr; - en accédant à un tableau ou un
Vechors de ses bornes :v[10]quandv.len() == 3; - lors d'une division entière par zéro ;
- lors d'un débordement arithmétique en mode debug (
overflowsur entiers).
fn main() {
let mesures: [f64; 3] = [1.0, 2.0, 3.0];
let i = 5;
let v = mesures[i]; // panique : "index out of bounds"
let x: Option<f64> = None;
let y = x.unwrap(); // panique : "called `Option::unwrap()` on a `None` value"
}
C'est une amélioration nette par rapport au C, où mesures[5] sur un tableau de taille 3
lit simplement de la mémoire hors bornes sans avertissement (comportement indéfini) — un panic Rust
est au moins détecté et signalé. Mais dans un système embarqué qui pilote un bras robotisé,
« détecté et signalé » ne suffit pas : il faut que le programme continue à fonctionner.
Dans une boucle de contrôle temps réel (asservissement, lecture de capteurs à fréquence fixe,
planification de trajectoire), un panic! est catastrophique : la
boucle s'arrête net, le robot n'est plus commandé, un actionneur peut rester figé dans un état
dangereux. Contrairement à une exception C++ qu'on peut attraper localement avec try/catch,
un panic Rust n'est pas fait pour être « récupéré » en routine — catch_unwind existe
mais reste un filet de sécurité, pas une stratégie de gestion d'erreurs.
La règle en production : bannir unwrap()/expect() (et
les indexations non vérifiées) dans tout code qui tourne dans la boucle de contrôle. Utilise
match, if let, unwrap_or, ou remonte l'erreur avec ?
jusqu'à un point où elle peut être traitée sereinement : basculer sur une valeur de repli, passer
en mode sécurisé, journaliser et continuer avec la dernière mesure valide. Le compilateur t'aide
déjà énormément en forçant à traiter Option/Result ; le dernier kilomètre
(ne pas céder à la facilité du unwrap()) reste une discipline humaine.
6. Définir son propre type d'erreur
String comme type d'erreur (utilisé plus haut) est pratique pour prototyper, mais dans
une bibliothèque de robotique digne de ce nom, on préfère un enum : l'appelant peut alors
distinguer précisément les cas d'échec avec un match, sans parser du texte.
use std::fmt;
/// Toutes les erreurs possibles d'un module de cinématique.
#[derive(Debug)]
enum ErreurCinematique {
AngleHorsPlage { articulation: usize, valeur: f64 },
MatriceSinguliere,
CapteurDeconnecte(u8),
}
// Debug (au-dessus, via #[derive]) sert au débogage : {:?}
// Display sert à l'utilisateur final : {}
impl fmt::Display for ErreurCinematique {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ErreurCinematique::AngleHorsPlage { articulation, valeur } =>
write!(f, "articulation {articulation} : angle {valeur} hors plage"),
ErreurCinematique::MatriceSinguliere =>
write!(f, "matrice jacobienne singulière, pas de solution"),
ErreurCinematique::CapteurDeconnecte(id) =>
write!(f, "capteur {id} déconnecté"),
}
}
}
fn verifier_angle(articulation: usize, valeur: f64) -> Result<(), ErreurCinematique> {
if valeur.abs() > std::f64::consts::PI {
Err(ErreurCinematique::AngleHorsPlage { articulation, valeur })
} else {
Ok(())
}
}
fn main() {
if let Err(e) = verifier_angle(2, 4.5) {
eprintln!("erreur cinématique : {e}"); // utilise Display
eprintln!("détail debug : {e:?}"); // utilise Debug
}
}
Pour des projets plus gros, deux crates de l'écosystème simplifient ce travail (à connaître de nom,
sans qu'il soit nécessaire de les détailler ici) : thiserror génère automatiquement les
impl Display/Error d'un enum d'erreur via des macros d'attributs, et
anyhow fournit un type d'erreur « boîte noire » pratique pour les applications (par
opposition aux bibliothèques, où un enum précis reste préférable).
- Rust n'a ni exceptions ni
NULL: l'échec et l'absence de valeur sont des types,Option<T>etResult<T, E>, que le compilateur t'oblige à traiter. Option<T>= valeur présente (Some) ou absente (None) ;Result<T, E>= succès (Ok) ou échec avec raison (Err).- L'opérateur
?propage l'erreur d'un appel imbriqué par retour anticipé, sans verbosité — la clé d'un code de gestion d'erreurs lisible. unwrap()/expect()et les accès hors bornes provoquent unpanic!; inacceptable dans une boucle de contrôle temps réel, réservé au prototypage et aux tests.- Un enum d'erreur personnalisé (avec
Display/Debug) donne à l'appelant une information précise et exploitable parmatch;thiserror/anyhowoutillent ce pattern en pratique.
Exercices
Exercice 1 — Division sûre
Écris une fonction fn diviser(a: f64, b: f64) -> Option<f64> qui renvoie
None si b est trop proche de zéro (utilise un seuil 1e-9
comme dans inverser), et Some(a / b) sinon. Teste-la avec 10.0 / 2.0
et 1.0 / 0.0.
Voir la solution
fn diviser(a: f64, b: f64) -> Option<f64> {
const EPSILON: f64 = 1e-9;
if b.abs() < EPSILON {
None
} else {
Some(a / b)
}
}
fn main() {
match diviser(10.0, 2.0) {
Some(r) => println!("10 / 2 = {r}"),
None => println!("division impossible"),
}
match diviser(1.0, 0.0) {
Some(r) => println!("1 / 0 = {r}"),
None => println!("division impossible"),
}
}
On applique exactement le même garde-fou que pour inverser : jamais de division brute
par une valeur qui pourrait être nulle, la vérification est encodée dans le type de retour lui-même.
Exercice 2 — Propagation avec ?
Écris une fonction fn parser_angle(s: &str) -> Result<f64, String> qui
parse une chaîne en f64 (utilise s.parse::<f64>(), qui renvoie un
Result<f64, ParseFloatError>) puis vérifie, avec la fonction racine du
cours, que ce nombre a une racine carrée valide (donc qu'il n'est pas négatif). Utilise ?
pour propager les deux échecs possibles jusqu'à l'appelant.
Voir la solution
fn racine(x: f64) -> Result<f64, String> {
if x < 0.0 {
Err(format!("racine carrée d'un nombre négatif : {x}"))
} else {
Ok(x.sqrt())
}
}
fn parser_angle(s: &str) -> Result<f64, String> {
// parse::<f64>() renvoie Result<f64, ParseFloatError> ; map_err
// convertit l'erreur en String pour rester homogène avec racine().
let valeur: f64 = s.parse::<f64>().map_err(|e| format!("nombre invalide : {e}"))?;
let r = racine(valeur)?; // propage directement si valeur < 0.0
Ok(r)
}
fn main() {
for entree in ["16.0", "-4.0", "abc"] {
match parser_angle(entree) {
Ok(r) => println!("{entree} -> racine = {r}"),
Err(e) => println!("{entree} -> erreur : {e}"),
}
}
}
Deux points de propagation, un seul opérateur ? à chaque fois : la fonction reste
courte et lisible, sans match imbriqués.
Exercice 3 — Gérer un None avec unwrap_or
En utilisant inverser du cours, écris une fonction
fn gain_securise(a: f64) -> f64 qui renvoie l'inverse de a si possible,
ou 1.0 (gain neutre) si l'inversion échoue. N'utilise ni match ni if let :
une seule expression avec unwrap_or.
Voir la solution
fn inverser(a: f64) -> Option<f64> {
const EPSILON: f64 = 1e-9;
if a.abs() < EPSILON { None } else { Some(1.0 / a) }
}
fn gain_securise(a: f64) -> f64 {
inverser(a).unwrap_or(1.0)
}
fn main() {
println!("{}", gain_securise(2.0)); // 0.5
println!("{}", gain_securise(0.0)); // 1.0 (repli, aucun panic)
}
unwrap_or est idéal ici : la valeur de repli (1.0) est constante et
bon marché à calculer, donc pas besoin de unwrap_or_else avec une closure.
Exercice 4 — Enum d'erreur ErreurCinematique
Reprends l'enum ErreurCinematique du cours et ajoute une fonction
fn decrire(e: &ErreurCinematique) -> &'static str qui utilise un match
pour renvoyer une courte catégorie textuelle selon la variante : "entrée invalide" pour
AngleHorsPlage, "échec numérique" pour MatriceSinguliere,
"matériel" pour CapteurDeconnecte.
Voir la solution
#[derive(Debug)]
enum ErreurCinematique {
AngleHorsPlage { articulation: usize, valeur: f64 },
MatriceSinguliere,
CapteurDeconnecte(u8),
}
fn decrire(e: &ErreurCinematique) -> &'static str {
match e {
ErreurCinematique::AngleHorsPlage { .. } => "entrée invalide",
ErreurCinematique::MatriceSinguliere => "échec numérique",
ErreurCinematique::CapteurDeconnecte(_) => "matériel",
}
}
fn main() {
let erreurs = [
ErreurCinematique::AngleHorsPlage { articulation: 1, valeur: 5.0 },
ErreurCinematique::MatriceSinguliere,
ErreurCinematique::CapteurDeconnecte(3),
];
for e in &erreurs {
println!("{:?} -> catégorie : {}", e, decrire(e));
}
}
Le match sur l'enum est exhaustif : si on ajoute une nouvelle variante à
ErreurCinematique plus tard, le compilateur signale immédiatement tous les endroits
(comme cette fonction decrire) qu'il faut mettre à jour — impossible d'oublier un cas
en silence, contrairement à un switch C sans default.
Récapitulatif
Option<T>remplace le pointeur nul ;Result<T, E>remplace le code d'erreur/l'exception — les deux forcent, via le système de types, à traiter le cas d'échec.?propage uneErr/unNonepar retour anticipé : c'est l'outil principal pour écrire des chaînes d'opérations faillibles sans verbosité.unwrap()/expect()sont réservés au prototypage/tests ; dans une boucle de contrôle temps réel, un panic est catastrophique — gère toujours l'erreur explicitement.- Un enum d'erreur personnalisé +
Display/Debugdonne des erreurs précises et exploitables parmatch;thiserroretanyhowoutillent ce pattern à plus grande échelle.