Gestion des erreurs#

La gestion des erreurs est un aspect fondamental de tout langage de programmation. Rust adopte une approche distinctive : plutôt que de recourir à un mécanisme d’exceptions comme Java ou Python, le langage encode les erreurs directement dans le système de types. Cette stratégie oblige le programmeur à traiter chaque erreur de manière explicite, sous peine de ne pas compiler.

Philosophie : erreurs récupérables et irrécupérables#

Définition 66 (Classification des erreurs en Rust)

Rust distingue deux catégories d’erreurs :

  • Les erreurs récupérables sont des situations prévisibles dont le programme peut se remettre (fichier introuvable, entrée invalide, connexion refusée). Elles sont représentées par le type Result<T, E>.

  • Les erreurs irrécupérables sont des bogues qui révèlent un état incohérent du programme (accès hors limites, invariant violé). Elles provoquent un panic, qui interrompt l’exécution.

Il n’existe pas de mécanisme try/catch en Rust. Cette absence est un choix de conception délibéré : le compilateur force le programmeur à décider, pour chaque opération faillible, comment l’erreur doit être traitée.

panic! : erreurs irrécupérables#

La macro panic! interrompt immédiatement l’exécution du thread courant. Elle est réservée aux situations où poursuivre l’exécution n’aurait pas de sens.

Exemple 65 (Déclenchement d’un panic)

Voir le code ci-dessous.

panic!("quelque chose de terrible s'est produit");
// thread 'main' panicked at 'quelque chose de terrible s'est produit'

Déroulement et abandon#

Proposition 11 (Comportement du panic)

Lorsqu’un panic! survient, Rust offre deux stratégies configurables :

  • Le déroulement (unwinding) : le programme remonte la pile d’appels en libérant les ressources de chaque cadre. C’est le comportement par défaut.

  • L”abandon (abort) : le programme se termine immédiatement sans nettoyage, laissant au système d’exploitation le soin de libérer la mémoire.

Le mode abort produit des binaires plus petits et peut être activé dans Cargo.toml par panic = 'abort' dans le profil souhaité.

Remarque 49

La variable d’environnement RUST_BACKTRACE=1 permet d’afficher la pile d’appels lors d’un panic. Avec RUST_BACKTRACE=full, on obtient une trace complète incluant les cadres internes de la bibliothèque standard. Cet outil est précieux pour localiser l’origine d’un panic dans un programme non trivial.

Panics implicites#

Certaines opérations provoquent un panic sans appel explicite à la macro. L’accès hors limites d’un tableau en est l’exemple le plus courant.

let v = vec![1, 2, 3];
let _x = v[10]; // panic : index hors limites
// thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10'

Result<T, E> : erreurs récupérables#

Le type Result<T, E>, introduit au chapitre 9, est une énumération à deux variantes : Ok(T) pour le succès et Err(E) pour l’erreur. Rappelons sa définition :

Définition 67 (Rappel — Result<T, E>)

enum Result<T, E> { Ok(T), Err(E) }

Toute opération susceptible d’échouer retourne un Result. Le compilateur oblige l’appelant à traiter la valeur retournée : ignorer un Result produit un avertissement.

Gestion explicite avec match#

La manière la plus fondamentale de traiter un Result est le filtrage par motifs (chapitre 4). Cette approche est exhaustive par construction.

Exemple 66 (Traitement d’un Result par match)

Voir le code ci-dessous.

fn diviser(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("division par zéro"))
    } else {
        Ok(a / b)
    }
}

match diviser(10.0, 3.0) {
    Ok(resultat) => println!("10 / 3 = {resultat:.4}"),
    Err(e)       => println!("Erreur : {e}"),
}

match diviser(5.0, 0.0) {
    Ok(resultat) => println!("5 / 0 = {resultat}"),
    Err(e)       => println!("Erreur : {e}"),
}
10 / 3 = 3.3333
Erreur : division par zéro
()

unwrap et expect#

Les méthodes unwrap et expect extraient la valeur Ok ou paniquent si le Result est Err. Elles sont commodes pour le prototypage mais dangereuses en production.

{
let ok: Result<i32, String> = Ok(42);
println!("unwrap : {}", ok.unwrap());

let aussi_ok: Result<i32, String> = Ok(7);
println!("expect : {}", aussi_ok.expect("devrait contenir une valeur"));
}
unwrap : 42
expect : 7
()
let erreur: Result<i32, String> = Err(String::from("échec"));
erreur.unwrap(); // panic avec le message d'erreur
// thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "échec"'

Remarque 50

On préfère expect à unwrap car le message personnalisé fournit un contexte précieux lors du débogage. La convention idiomatique est d’expliquer pourquoi la valeur devrait être présente : expect("le fichier de configuration a été vérifié au démarrage").

L’opérateur ?#

L’opérateur ?, placé après une expression de type Result, extrait la valeur Ok si elle est présente. Si l’expression est Err, il provoque un retour anticipé de la fonction englobante avec cette erreur, éventuellement convertie grâce au trait From.

Définition 68 (Opérateur de propagation ?)

L’expression expr? est équivalente au match suivant :
match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }
La conversion automatique via From::from permet de propager des erreurs de types différents, à condition qu’une implémentation From<ErreurSource> for ErreurCible existe.

Exemple 67 (Propagation avec ?)

Voir le code ci-dessous.

fn parser_et_doubler(texte: &str) -> Result<i32, std::num::ParseIntError> {
    let n: i32 = texte.parse()?;
    Ok(n * 2)
}

println!("\"21\" -> {:?}", parser_et_doubler("21"));
println!("\"abc\" -> {:?}", parser_et_doubler("abc"));
"21" -> Ok(42)
"abc" -> Err(ParseIntError { kind: InvalidDigit })

Chaînage d’opérations faillibles#

L’opérateur ? excelle lorsque plusieurs opérations faillibles s’enchaînent. Sans lui, chaque étape nécessiterait un match imbriqué.

Exemple 68 (Chaînage de plusieurs opérations avec ?)

Voir le code ci-dessous.

fn somme_de_textes(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
    let x: i32 = a.parse()?;
    let y: i32 = b.parse()?;
    Ok(x + y)
}

println!("\"10\" + \"20\" = {:?}", somme_de_textes("10", "20"));
println!("\"10\" + \"xy\" = {:?}", somme_de_textes("10", "xy"));
"10" + "20" = Ok(30)
"10" + "xy" = Err(ParseIntError { kind: InvalidDigit })

Remarque 51

L’opérateur ? ne peut être utilisé que dans une fonction dont le type de retour est compatible — typiquement Result<T, E> ou Option<T>. L’utiliser dans main nécessite de déclarer fn main() -> Result<(), E>.

Combinateurs sur Result#

Les combinateurs sont des méthodes qui transforment un Result sans recourir à un match explicite. Ils permettent d’écrire du code fonctionnel et composable.

map et map_err#

La méthode map transforme la valeur Ok en appliquant une fonction, sans affecter la variante Err. Symétriquement, map_err transforme la valeur Err sans toucher à Ok.

{
let succes: Result<i32, String> = Ok(5);
let echec: Result<i32, String> = Err(String::from("erreur"));

let double = succes.map(|x| x * 2);
println!("map sur Ok  : {:?}", double);   // Ok(10)

let ignore = echec.as_ref().map(|x| x * 2);
println!("map sur Err : {:?}", ignore);   // Err("erreur")

let enrichi = echec.map_err(|e| format!("[CRITIQUE] {e}"));
println!("map_err     : {:?}", enrichi);  // Err("[CRITIQUE] erreur")
}
map sur Ok  : Ok(10)
map sur Err : Err("erreur")
map_err     : Err("[CRITIQUE] erreur")
()

and_then et or_else#

La méthode and_then enchaîne une opération qui retourne elle-même un Result — l’équivalent d’un flatmap. La méthode or_else permet de tenter une opération de repli en cas d’erreur.

Exemple 69 (Combinateurs and_then et or_else)

Voir le code ci-dessous.

fn parser(texte: &str) -> Result<i32, String> {
    texte.parse::<i32>().map_err(|e| format!("parse échouée : {e}"))
}

fn valider(n: i32) -> Result<i32, String> {
    if n > 0 { Ok(n) } else { Err(String::from("doit être positif")) }
}

let resultat = parser("42").and_then(valider);
println!("and_then(\"42\")  : {:?}", resultat);  // Ok(42)

let resultat = parser("-3").and_then(valider);
println!("and_then(\"-3\") : {:?}", resultat);  // Err("doit être positif")

let resultat = parser("abc").and_then(valider);
println!("and_then(\"abc\"): {:?}", resultat);  // Err("parse échouée : ...")
and_then("42")  : Ok(42)
and_then("-3") : Err("doit être positif")
and_then("abc"): Err("parse échouée : invalid digit found in string")
fn lire_config() -> Result<String, String> {
    Err(String::from("fichier absent"))
}

fn config_par_defaut() -> Result<String, String> {
    Ok(String::from("valeur_par_defaut"))
}

let config = lire_config().or_else(|_| config_par_defaut());
println!("or_else : {:?}", config);  // Ok("valeur_par_defaut")
or_else : Ok("valeur_par_defaut")

unwrap_or et unwrap_or_else#

Ces méthodes extraient la valeur Ok ou fournissent une valeur de repli, sans jamais paniquer.

{
let err1: Result<i32, String> = Err(String::from("erreur"));
println!("unwrap_or : {}", err1.unwrap_or(0));

// unwrap_or_else : la valeur de repli est calculée paresseusement
let err2: Result<i32, String> = Err(String::from("erreur"));
println!("unwrap_or_else : {}", err2.unwrap_or_else(|e| {
    println!("  (repli après : {e})");
    -1
}));
}
unwrap_or : 0
  (repli après : erreur)
unwrap_or_else : -1
()

Types d’erreurs personnalisés#

Dans un programme non trivial, les fonctions peuvent échouer pour des raisons variées. Définir un type d’erreur personnalisé permet de regrouper ces cas sous une même énumération.

Définition 69 (Type d’erreur personnalisé)

Un type d’erreur personnalisé est généralement une enum dont chaque variante représente une cause d’erreur distincte. Pour s’intégrer à l’écosystème Rust, ce type doit implémenter les traits std::fmt::Display (pour l’affichage lisible) et std::error::Error (pour la composition avec d’autres erreurs).

Exemple 70 (Enum d’erreurs avec Display et Error)

Voir le code ci-dessous.

use std::fmt;

#[derive(Debug)]
enum ErreurCalcul {
    DivisionParZero,
    Overflow,
    EntreeInvalide(String),
}

impl fmt::Display for ErreurCalcul {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErreurCalcul::DivisionParZero =>
                write!(f, "division par zéro"),
            ErreurCalcul::Overflow =>
                write!(f, "dépassement de capacité"),
            ErreurCalcul::EntreeInvalide(msg) =>
                write!(f, "entrée invalide : {msg}"),
        }
    }
}

impl std::error::Error for ErreurCalcul {}

fn diviser_entiers(a: i32, b: i32) -> Result<i32, ErreurCalcul> {
    if b == 0 {
        return Err(ErreurCalcul::DivisionParZero);
    }
    a.checked_div(b).ok_or(ErreurCalcul::Overflow)
}

println!("{:?}", diviser_entiers(10, 3));
println!("{}", diviser_entiers(10, 0).unwrap_err());
Ok(3)
division par zéro

Conversion automatique avec From#

Pour que l’opérateur ? puisse propager des erreurs de types différents, on implémente le trait From pour chaque type d’erreur source.

Exemple 71 (Implémentation de From pour la conversion d’erreurs)

Voir le code ci-dessous.

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum ErreurApp {
    Calcul(String),
    Parsing(ParseIntError),
}

impl fmt::Display for ErreurApp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErreurApp::Calcul(msg) => write!(f, "erreur de calcul : {msg}"),
            ErreurApp::Parsing(e)  => write!(f, "erreur de parsing : {e}"),
        }
    }
}

impl std::error::Error for ErreurApp {}

impl From<ParseIntError> for ErreurApp {
    fn from(e: ParseIntError) -> Self {
        ErreurApp::Parsing(e)
    }
}

fn parser_et_inverser(texte: &str) -> Result<f64, ErreurApp> {
    let n: i32 = texte.parse()?; // ParseIntError converti automatiquement
    if n == 0 {
        Err(ErreurApp::Calcul(String::from("division par zéro")))
    } else {
        Ok(1.0 / n as f64)
    }
}

println!("\"4\"   -> {:?}", parser_et_inverser("4"));
println!("\"0\"   -> {:?}", parser_et_inverser("0"));
println!("\"abc\" -> {:?}", parser_et_inverser("abc"));
"4"   -> Ok(0.25)
"0"   -> Err(Calcul("division par zéro"))
"abc" -> Err(Parsing(ParseIntError { kind: InvalidDigit }))

Remarque 52

Pour les projets de taille importante, des bibliothèques comme thiserror (pour les bibliothèques) et anyhow (pour les applications) automatisent la création de types d’erreurs et la propagation. thiserror génère les implémentations de Display, Error et From à partir d’attributs dérivés. anyhow fournit un type anyhow::Error effaçant le type concret, adapté aux applications où l’on souhaite propager toute erreur sans définir d’enum exhaustif.

Quand paniquer, quand retourner Result#

Proposition 12 (Lignes directrices pour le choix entre panic et Result)

Les conventions idiomatiques de Rust distinguent clairement les cas d’usage :

Utiliser panic! (ou unwrap/expect) lorsque :

  • un invariant du programme est violé (bogue avéré) ;

  • poursuivre l’exécution corromprait des données ;

  • le contexte est un prototype, un test ou un exemple ;

  • une condition a été vérifiée en amont et l’erreur est logiquement impossible.

Retourner Result lorsque :

  • l’échec est une issue prévisible et raisonnable (entrée utilisateur, réseau, fichier) ;

  • l’appelant peut décider de la stratégie de récupération ;

  • la fonction fait partie d’une bibliothèque publique.

En règle générale, les bibliothèques ne doivent jamais paniquer au nom de l’appelant. Les applications peuvent se permettre un unwrap dans les cas où l’erreur a déjà été éliminée par le contexte.

Exemple 72 (Panic justifié vs Result approprié)

Voir le code ci-dessous.

// Panic justifié : l'invariant « le tableau n'est pas vide »
// est une précondition documentée.
fn moyenne(valeurs: &[f64]) -> f64 {
    assert!(!valeurs.is_empty(), "le tableau ne doit pas être vide");
    let somme: f64 = valeurs.iter().sum();
    somme / valeurs.len() as f64
}

println!("Moyenne : {}", moyenne(&[2.0, 4.0, 6.0]));
Moyenne : 4
// Result approprié : l'entrée vient de l'extérieur,
// l'appelant décide quoi faire en cas d'erreur.
fn parser_age(texte: &str) -> Result<u8, String> {
    let n: u8 = texte.parse().map_err(|e| format!("format invalide : {e}"))?;
    if n > 150 {
        Err(String::from("âge irréaliste"))
    } else {
        Ok(n)
    }
}

println!("\"25\"  -> {:?}", parser_age("25"));
println!("\"xyz\" -> {:?}", parser_age("xyz"));
println!("\"200\" -> {:?}", parser_age("200"));
"25"  -> Ok(25)
"xyz" -> Err("format invalide : invalid digit found in string")
"200" -> Err("âge irréaliste")

Remarque 53

La frontière entre panic et Result est en définitive une question de contrat. Si la documentation d’une fonction établit des préconditions, leur violation est un bogue de l’appelant et justifie un panic. Si la fonction accepte une entrée arbitraire et doit signaler les cas invalides, elle retourne un Result. Ce principe guide la conception de la bibliothèque standard : Vec::push ne peut pas échouer, str::parse retourne un Result, et slice[index] panique si l’index est hors limites — car accéder à un index invalide est considéré comme un bogue.