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 :
- 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.
- 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 fonctionT -> 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.