Gestion des erreurs

Dès qu’on commence à concevoir des abstractions comme des fonctions, la question de la gestion des erreurs se pose (ou du moins devrait se poser). Là où la norme C++ a trop longtemps campé sur la position du tout-exceptions, sans essayer de standardiser des alternatives pour le cas où les exceptions ne sont pas un bon choix, Rust a dès le départ adopté une approche plus flexible basée sur la dichotomie entre erreur gérable et non gérable par l’appelant.

Panique à bord !

Nous avons déjà vu une première manière de signaler les erreurs dans les chapitres précédents. Lorsque l’implémentation d’une fonction fait quelque chose de manifestement incorrect, comme indexer un tableau en-dehors de ses bornes, cela arrête le programme :

#![allow(unused)]
fn main() {
// Comme précédemment, j'ai modifié la configuration du compilateur pour
// désactiver la détection de l'erreur à la compilation.
#![allow(unconditional_panic)]
let tab = [1, 2, 3];
println!("{}", tab[4]);
}

Comme en Go, le mécanisme sous-jacent est appelé panique (panic). De façon un peu inhabituelle, le langage autorise plusieurs implémentations de cette fonctionnalité :

  • Dans l’implémentation unwind, la panique fonctionne à peu près comme une exception en C++. On remonte la pile d’appel en appelant les destructeurs des différents objets présents sur la pile, jusqu’à trouver un gestionnaire de panique (analogue au try .. catch de C++) ou la fonction principale du programme.
  • Dans l’implémentation abort, la panique provoque directement l’arrêt du programme via la fonction libc abort(), sans appeler les destructeurs.

Cette double implémentation permet deux choses :

  1. Elle rappelle que l’utilisation de gestionnaires de panique n’est pas une pratique courante en Rust. On s’en sert juste pour fixer des barrières de protection dans les programmes qui ont besoin de pouvoir récupérer des erreurs même quand elles viennent d’un mauvais code.
  2. Elle permet une implémentation qui n’a pas les surcoûts liés aux exceptions (notamment une augmentation de la taille du binaire, problématique pour le code embarqué).

Nous pouvons déclencher une panique avec la macro panic!(), qui peut s’utiliser seule…

#![allow(unused)]
fn main() {
panic!()
}

…ou, de préférence, avec un message formaté comme une écriture println!(), qui clarifie pourquoi le programme s’est arrêté :

#![allow(unused)]
fn main() {
panic!("La réponse aurait dû être {}", 42)
}

Comme le message d’erreur vous l’explique, en cas de panique, vous pouvez mettre la variable d’environnement RUST_BACKTRACE à 1 pour avoir la pile d’appel du programme au moment où il s’est arrêté. On n’est pas en C++, pas besoin d’un débogueur externe.

Petite curiosité : la macro panic à quelques synonymes qui changent le message d’erreur par défaut, pour clarifier au lecteur du code et à l’utilisateur quelques raisons d’arrêt courantes.

#![allow(unused)]
fn main() {
// Placeholder pour du code pas encore implémenté
todo!()
}
#![allow(unused)]
fn main() {
// Le programme est arrivé dans un état que le programmeur pensait impossible
unreachable!()
}

Mais le plus intéressant, ce sont les assertions, qui permettent de vérifier si une condition est vraie, et d’arrêter le programme avec une panique si la condition attendue n’est pas vérifiée :

#![allow(unused)]
fn main() {
assert!(true);  // Forme simple
assert!(false, "La condition n'est pas vérifiée");  // Forme longue
}

Un cas courant d’assertion est de vérifier si une valeur est égale ou pas à une référence. Ce type d’assertion a une syntaxe dédiée, qui permet en cas d’échec l’affichage des valeurs concernées :

#![allow(unused)]
fn main() {
// Jusqu'ici tout va bien
let x = 42;
assert_eq!(x, 42);

// Il existe aussi une version "not equal" qui échoue si c'est égal
assert_ne!(12, 12);
}

De temps en temps, il arrive aussi qu’on soit dans du code très sensible aux performances, où l’on devrait mettre une assertion mais le test associé à l’assertion se révèle être trop coûteux. Dans ce cas, on fait comme le test de débordement d’entier standard de Rust : on teste dans les builds de debug, et on désactive le test (quitte à avoir un comportement incorrect) en mode release.

#![allow(unused)]
fn main() {
debug_assert_eq!(123, 456);
// En mode release, le code continue comme si de rien n'était
}

Cette technique est parfois utilisée à l’intérieur des fonctions unsafe, lorsque l’invariant est vérifiable à l’exécution, mais qu’on a fourni une alternative unsafe à la fonction sûre parce que le coût de vérification est trop élevé dans certains cas d’utilisation. Mais c’est une bonne pratique, pas une norme universellement appliquée, donc restez prudents avec unsafe ! ;)

Type optionnel

On l’a vu, la panique est un outil assez brutal qui ne doit être utilisé que dans le cas où le code est en train de faire quelque chose de manifestement incorrect.

Pour les autres cas, on utilise des types dits “monadiques”. Il s’agit de types sommes avec deux variantes, une variante pour le cas normal et une variante pour le cas erroné. Le plus simple de ces types est le type Option. C’est un type générique défini par la bibliothèque standard comme suit :

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None
}
}

Nous n’avons pas encore vu les types génériques, mais ce code devrait quand même être assez clair : il peut contenir soit une valeur d’un type T quelconque, soit rien du tout.

Les variantes de ce type énuméré sont mises dans le scope global avec cette commande, injectée automatiquement dans les programmes utilisateur, sur laquelle nous reviendrons quand nous aborderons les modules…

#![allow(unused)]
fn main() {
use Option::*;
}

…ce qui signifie qu’on peut librement taper des choses comme Some(42) ou None dans le code et ça créera les variantes associées du type Option.

Le type optionnel est utilisé comme valeur de résultat pour les fonctions qui posent une question dont la réponse peut être “il n’y en a pas”. Par exemple la méthode str::find(), qui retourne la position d’un motif (caractère, sous-chaîne…) dans une chaîne de caractère :

#![allow(unused)]
fn main() {
let chaine = "Bonjour à tous";

// Le motif existe dans la chaîne à une certaine position
println!("Position du mot tous : {:?}", chaine.find("tous"));

// Le motif n'existe pas dans la chaîne
println!("Position du mot toutes : {:?}", chaine.find("toutes"));
}

On peut l’utiliser comme une forme légère de gestion d’erreurs…

#![allow(unused)]
fn main() {
let tab = [1u32, 2, 3];

// Forme plus "douce" de l'opérateur d'indexation standard
let elem = tab.get(4usize);
println!("{elem:?}");
}

…ou pour d’autres choses, comme les paramètres de fonction optionnels :

#![allow(unused)]
fn main() {
fn call_me_maybe(x: Option<u32>) -> u32 {
    // On l'a déjà dit, cette utilisation du shadowing est idiomatique en Rust,
    // même si elle perturbe un peu au début.
    if let Some(x) = x {
        x
    } else {
        42
    }
}
}

Notez que la fonction qui reçoint une Option doit gérer la possibilité qu’elle soit None. Il n’y a pas de raccourci pour accéder à la valeur intérieure en supposant qu’elle est là. On ne donc peut pas oublier de gérer l’absence de résultat, comme c’est le cas en C++ avec les fonctions qui retournent des types nullables comme std::unique_ptr, ou qui retournent un int comme code d’erreur.

Il y a des façons d’utiliser une Option qui reviennent souvent en Rust, et ces opérations sont disponibles dans la bibliothèque standard sous forme de méthodes du type Option. Par exemple :

  • Extraire la valeur contenue dans l’option ou retourner une valeur par défaut sinon : unwrap_or(), unwrap_or_else().
  • “Dégrader” une option en assertion en supposant qu’elle contient une valeur, et déclenchant une panique si ce n’est pas le cas : unwrap(), except().
  • Transformer la valeur éventuelle d’une Option<T> via une fonction T -> U, et retourner l’Option<U> du résultat : map().

N’hésitez donc pas à consulter régulièrement la documentation du type Option quand vous vous préparez à faire quelque chose avec, pour ne pas réimplémenter inutilement une opération qui existe déjà sous un nom connu de tous les programmeurs Rust.

C++ a récemment produit sa propre version du concept de type Option avec std::optional de C++17. Mais son API relève hélas de la publicité involontaire pour Rust : beaucoup moins ergonomique, elle facilite aussi grandement l’introduction de comportement indéfini…

Type résultat

On l’a vu, le type Option est utilisé pour implémenter des opérations qui peuvent ou non retourner un résultat en fonction de ce qu’on leur passe en paramètre. Par exemple les recherches au sein d’une collection, qui peuvent ou non trouver un élément qui correspond à la requête.

On pourrait utiliser cette absence de résultat pour signaler la survenue d’une erreur non fatale. Mais souvent, c’est trop imprécis. On ne veut pas seulement savoir qu’une erreur, on veut aussi savoir de quelle nature est l’erreur, et pourquoi elle est survenue.

Par exemple, quand on tente d’accéder à un fichier et ça échoue, on veut savoir si c’est parce que…

  • Le fichier n’existe pas.
  • Le fichier existe, mais on n’y a pas accès.
  • On a accès au fichier, mais on ne peut pas écrire car le support de stockage est plein.
  • …et cette liste de problèmes possibles n’est pas exhaustive.

Pour représenter la possibilité d’avoir différents types d’erreur, en Rust, on utilise généralement des types énumérés représentant les différentes erreurs possible. Dans l’exemple ci-dessus, par exemple, on pourrait avoir ce type erreur :

#![allow(unused)]
fn main() {
type Permissions = u8;
enum FileError {
    FileNotFound { filename: String },
    AccessDenied { requested: Permissions, actual: Permissions },
    StorageFull,
    // ...et ainsi de suite...
}
}

Une fois qu’on a une description précise de l’erreur, il faut pouvoir la propager à l’appelant. On utilise pour ça le type Result, qui est défini par la bibliothèque standard comme ceci :

// Côté bibliothèque standard, on définit ça
enum Result<T, E> {
    Ok(T),
    Err(E),
}

// Côté appelant, les variantes sont rendues disponibles automatiquement
use Result::*;

On utilise le type Result comme une généralisation du type Option, où le cas Result::Ok joue le même rôle que Option::Some, et le cas Result::Err est une forme détaillée de Option::None où on précise pourquoi on n’a pas pu retourner un résultat.

Et donc si on ignore quelques subtilités des accès aux fichiers pour simplifier l’explication, notre fonction finale pourrait ressembler à ça :

fn write_file(file_name: &str, data: &str) -> Result<(), FileError> {
    // Vérification de l'accès au fichier
    if !file_exists(file_name) {
        return Err(FileError::FileNotFound { filename: file_name.to_string() };
    }

    // Ouverture du fichier
    if !can_write_file(file_name) {
        return Err(FileError::AccessDenied {
            requested: Permissions::Write,
            actual: file_permissions(file_name),
        });
    }

    // Ecriture des données
    if let Err(_) = try_write_data(file_name, data) {
        return Err(FileError::StorageFull);
    }

    // Ok, tout s'est bien passé
    Ok(())
}

Notez au passage l’utilisation de () pour indiquer que la fonction ne retourne pas de résultat dans le cas où aucune erreur n’est survenue.

Composition des erreurs

L’exemple de code précédent montre un problème de composabilité qui doit encore être résolu avant qu’on puisse utiliser Result facilement à grande échelle :

if let Err(_) = try_write_data(file_name, data) {
    return Err(FileError::StorageFull);
}

Cela n’a pas tellement de sens de s’embêter à créer des types erreur détaillés si à la fin, l’appelant va juste jeter cette information détaillée comme ça pour construire son erreur à lui. On aimerait garder une trace de la chaîne d’événements qui a conduit à l’erreur haut niveau finale, depuis l’erreur bas niveau qui a provoqué l’arrêt du traitement.

La recherche d’une solution ergonomique à ce problème a été un travail de longue haleine dans l’écosystème Rust. De nombreuses solutions ont été proposées, et encore aujourd’hui le problème n’est pas considéré comme complètement résolu de façon satisfaisante. Donc les solutions d’interim existent au sein de bibliothèques tierces et pas du langage et de la bibliothèque standard, dont les garanties de stabilité ne se prêtent pas à l’expérimentation.

Néanmoins, deux grandes familles de solution se dessinent, qui répondent à la grande majorité des besoins et seront donc probablement intégrées à la bibliothèque standard à terme :

  • Avec des bibliothèques comme thiserror, on a une syntaxe très légère pour définir un type énuméré d’erreur de haut niveau dont les variantes “héritent” d’erreurs de plus bas niveau, en y ajoutant des clarifications spécifiques à l’utilisation. Cette approche est privilégiée pour les bibliothèques, dont les erreurs doivent fournir une vision claire de ce qui se passe.
  • Avec des bibliothèques comme eyre, on a un type erreur abstrait qui peut être créé à partir de n’importe quelle erreur de bas niveau, et propagé trivialement à travers le code pour aboutir à l’émission finale d’un rapport d’erreur détaillé pour les utilisateurs. Cette approche est privilégiée pour les applications, qui ne peuvent généralement pas récupérer des erreurs (sauf à gros grain) et n’exposent pas de type erreur dans leurs interfaces.

Ces bibliothèques sont supportées par deux mécaniques de base au niveau du langage :

  • Le trait Error définit une interface minimale que toutes les erreurs devraient implémenter, ce qui permet ensuite de les manipuler de façon homogène.
  • L’opérateur de propagation d’erreur ? fournit une syntaxe concise pour la propagation d’erreur vers l’appelant avec conversion automatique vers le type erreur de plus haut niveau. Avec cet opérateur, notre exemple de départ devient ceci :
    // Tenter d'écrire dans le fichier. Si ça échoue, l'erreur est propagée.
    try_write_data(file_name, data)?;
    // Le code n'arrive à ce point que si il n'y a pas eu d'erreur.

Grâce à cette alliance du langage et des bibliothèques tierces, on obtient un système de gestion des erreurs dont l’ergonomie est excellente, tout en permettant une gestion précise des erreurs, et le tout sans risque d’oublier de gérer les erreurs émises.