Result et la gestion des erreurs

Rust n’a pas d’exceptions. Une opération susceptible d’échouer renvoie un Result<T, E>, que le compilateur oblige à traiter. Cette page traite du versant « échec » ; l’absence de valeur, elle, relève d’Option.

Le type

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

Comme Option, c’est une simple énumération de la bibliothèque standard, annotée #[must_use] : ignorer un Result déclenche un avertissement.

Le partage des rôles entre les deux types tient à une question : l’appelant a-t-il besoin de savoir pourquoi ?

Option<T>Result<T, E>
SémantiqueL’absence est normale et attendueQuelque chose a échoué
Le pourquoi importeNonOui
ExemplesHashMap::get, Iterator::nextEntrées/sorties, analyse syntaxique, validation

Les combinateurs

L’essentiel de l’interface reprend celle d’Option, avec une couche d’erreur en plus :

res.map(f)                  // Transforme la valeur, laisse l'erreur intacte
res.map_err(f)              // Transforme l'erreur, laisse la valeur intacte
res.and_then(f)             // Enchaîne une opération elle-même faillible
res.or_else(f)              // Récupération : tente autre chose en cas d'erreur
res.unwrap_or(defaut)
res.unwrap_or_else(|e| /* ... */)
res.unwrap_or_default()
res.ok()                    // Result<T, E> -> Option<T>, jette l'erreur
res.err()                   // Result<T, E> -> Option<E>
res.is_ok() / res.is_err()

map_err est le plus spécifique des deux mondes : c’est lui qui adapte une erreur de bibliothèque au type d’erreur de votre application.

let port: u16 = texte
    .parse()
    .map_err(|_| ErreurConfig::PortInvalide(texte.to_string()))?;

L’opérateur ?

? extrait la valeur en cas de succès, et retourne immédiatement l’erreur sinon.

fn lire_config(chemin: &str) -> Result<String, std::io::Error> {
    let contenu = std::fs::read_to_string(chemin)?;
    Ok(contenu)
}

Sa vraie puissance tient à ce qu’il ne se contente pas de propager : il convertit l’erreur au passage, en appelant From::from. Dès lors qu’une conversion existe du type d’erreur rencontré vers celui déclaré par la fonction, ? l’applique tout seul.

fn charger() -> Result<Config, ErreurApp> {
    let contenu = std::fs::read_to_string("config.toml")?;   // io::Error -> ErreurApp
    let port: u16 = contenu.trim().parse()?;                 // ParseIntError -> ErreurApp
    Ok(Config { port })
}

Les deux appels renvoient des erreurs de types différents, et pourtant un seul ? suffit dans chaque cas — à condition d’avoir fourni les implémentations From correspondantes.

Warning

? ne mélange pas Option et Result au sein d’une même fonction. Les passerelles sont explicites : ok_or_else pour aller d’Option vers Result, .ok() pour l’inverse.

Définir son type d’erreur

Une énumération recense les modes de défaillance du domaine :

#[derive(Debug)]
enum ErreurApp {
    Lecture(std::io::Error),
    PortInvalide(std::num::ParseIntError),
    ConfigVide,
}

Pour qu’elle s’intègre à l’écosystème, deux implémentations sont attendues. Display fournit le message destiné à l’utilisateur :

impl std::fmt::Display for ErreurApp {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ErreurApp::Lecture(e) => write!(f, "lecture impossible : {e}"),
            ErreurApp::PortInvalide(e) => write!(f, "port invalide : {e}"),
            ErreurApp::ConfigVide => write!(f, "configuration vide"),
        }
    }
}

std::error::Error marque le type comme une erreur et expose la cause sous-jacente, ce qui permet aux outils de reconstituer la chaîne complète :

impl std::error::Error for ErreurApp {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ErreurApp::Lecture(e) => Some(e),
            ErreurApp::PortInvalide(e) => Some(e),
            ErreurApp::ConfigVide => None,
        }
    }
}

Enfin, les implémentations From sont ce qui rend l’opérateur ? transparent :

impl From<std::io::Error> for ErreurApp {
    fn from(e: std::io::Error) -> Self { ErreurApp::Lecture(e) }
}

impl From<std::num::ParseIntError> for ErreurApp {
    fn from(e: std::num::ParseIntError) -> Self { ErreurApp::PortInvalide(e) }
}
Tip

Ce code est mécanique et se répète pour chaque variante. C’est précisément ce que les crates de la section suivante automatisent.

Box<dyn Error>

Quand la nature exacte de l’erreur importe peu — un prototype, un binaire, un script — Box<dyn Error> accepte n’importe quelle erreur, puisqu’une implémentation générale de la bibliothèque standard convertit tout type Error vers cette boîte.

fn charger() -> Result<Config, Box<dyn std::error::Error>> {
    let contenu = std::fs::read_to_string("config.toml")?;
    let port: u16 = contenu.trim().parse()?;
    Ok(Config { port })
}

Aucune implémentation From à écrire, mais l’appelant perd la capacité de distinguer les cas par match. À réserver aux frontières du programme, jamais au cœur d’une bibliothèque.

thiserror et anyhow

Deux crates complémentaires, écrites par le même auteur, couvrent les deux situations.

thiserror engendre Display, Error et les From à partir d’annotations. C’est le choix pour une bibliothèque, où l’appelant doit pouvoir discriminer les erreurs.

use thiserror::Error;

#[derive(Debug, Error)]
enum ErreurApp {
    #[error("lecture impossible : {0}")]
    Lecture(#[from] std::io::Error),

    #[error("port invalide : {0}")]
    PortInvalide(#[from] std::num::ParseIntError),

    #[error("configuration vide")]
    ConfigVide,
}

Ces quinze lignes remplacent la soixantaine de la section précédente, pour un résultat identique.

anyhow fournit un type d’erreur unique et opaque, enrichi de contexte. C’est le choix pour une application, où l’erreur finit journalisée ou affichée.

use anyhow::{Context, Result};

fn charger() -> Result<Config> {
    let contenu = std::fs::read_to_string("config.toml")
        .context("lecture de config.toml")?;
    let port: u16 = contenu.trim().parse()
        .context("analyse du port")?;
    Ok(Config { port })
}

context empile les explications, ce qui produit à l’affichage une trace lisible du plus général au plus précis, là où l’erreur brute d’origine n’aurait mentionné qu’un fichier introuvable.

thiserroranyhow
DestinationBibliothèquesApplications, binaires
Type d’erreurÉnumération expliciteType opaque unique
L’appelant peut discriminerOuiDifficilement
Apport principalSupprime le code répétitifAjoute du contexte

Paniquer ou retourner une erreur

panic! interrompt le fil d’exécution ; il n’est pas un mécanisme de gestion d’erreurs. Le critère de choix est simple :

SituationRéponse
L’échec est prévisible et l’appelant peut réagirResult
Un invariant du programme est rompupanic!
Entrée extérieure invalideResult — jamais une panique
Bogue de programmationpanic!, unreachable!, assert!

Un fichier absent, un JSON mal formé, un port déjà pris sont des situations attendues : ce sont des Result. Un index hors bornes sur une structure interne ou un état déclaré impossible sont des bogues : la panique est alors le bon signal, parce qu’elle échoue tôt et bruyamment.

Warning

Une bibliothèque ne devrait presque jamais paniquer sur une entrée fournie par son appelant : cela lui retire tout choix. La panique est un privilège de l’application, qui décide en dernier ressort de ce qui est irrécupérable.

unwrap et expect

res.unwrap()                                    // Panique sans explication
res.expect("config validée au démarrage")       // Panique avec un message
res.unwrap_or_default()                         // Valeur de repli
res?                                            // Propage

Le message d’expect doit expliquer pourquoi l’erreur est impossible ici, et non ce qui a échoué — cette dernière information figure déjà dans l’erreur. unwrap() nu se justifie dans les tests, les prototypes et les exemples, où l’échec doit interrompre immédiatement.

Result dans main

main peut retourner un Result, ce qui autorise l’usage de ? dès le point d’entrée. En cas d’erreur, le programme affiche la représentation Debug de celle-ci et rend un code de sortie non nul.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = charger()?;
    println!("port : {}", config.port);
    Ok(())
}
Note

C’est bien Debug et non Display qui est employé pour cet affichage — d’où l’intérêt de dériver Debug sur tout type d’erreur. Pour maîtriser exactement le message, mieux vaut traiter l’erreur dans main et appeler std::process::exit soi-même.

Result et les itérateurs

collect() sait rassembler un itérateur de Result en un Result de collection, en court-circuitant à la première erreur :

let nombres: Result<Vec<u16>, _> = textes.iter().map(|s| s.parse::<u16>()).collect();

Pour au contraire ignorer les échecs, filter_map fait l’affaire, et partition permet de conserver les deux côtés :

let valides: Vec<u16> = textes.iter().filter_map(|s| s.parse().ok()).collect();

let (succes, echecs): (Vec<_>, Vec<_>) = textes
    .iter()
    .map(|s| s.parse::<u16>())
    .partition(Result::is_ok);

Ces mécanismes sont détaillés dans Les itérateurs.

À retenir

  • Result pour un échec dont l’appelant doit connaître la cause, Option pour une absence attendue.
  • ? propage et convertit via From : c’est ce qui rend viable un type d’erreur unique par module.
  • thiserror pour une bibliothèque, anyhow pour une application.
  • panic! signale un bogue, jamais une entrée invalide.
  • Le message d’expect explique pourquoi l’erreur ne peut pas survenir.

Ressources