🦀 Rust pour la robotique · temps réel

Chapitre 08
Gestion d'erreurs : Result, Option, ?

Objectifs du chapitre

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 :

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 :

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).

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