Paniquer ou ne pas paniquer, telle est la question
Comment décider si vous devez utiliser panic!
ou si vous devez retourner un
Result
? Quand un code panique, il n'y a pas de moyen de récupérer la
situation. Vous pourriez utiliser panic!
pour n'importe quelle situation
d'erreur, peu importe s'il est possible de récupérer la situation ou non, mais
vous prenez alors la décision de tout arrêter à la place du code appellant.
Lorsque vous choisissez de retourner une valeur Result
, vous donnez le choix
au code appelant. Le code appelant peut choisir d'essayer de récupérer l'erreur
de manière appropriée à la situation, ou il peut décider que dans ce cas une
valeur Err
est irrécupérable, et va donc utiliser panic!
et transformer
votre erreur récupérable en erreur irrécupérable. Ainsi, retourner Result
est
un bon choix par défaut lorsque vous définissez une fonction qui peut échouer.
Dans certains cas comme les exemples, les prototypes, et les tests, il est plus
approprié d'écrire du code qui panique plutôt que de retourner un Result
.
Nous allons voir pourquoi, puis nous verrons des situations dans lesquelles
vous savez en tant qu'humain qu'un code ne peut pas échouer, mais que le
compilateur ne peut pas le déduire par lui-même. Enfin, nous allons conclure le
chapitre par quelques lignes directrices générales pour décider s'il faut
paniquer dans le code d'une bibliothèque.
Les exemples, les prototypes et les tests
Lorsque vous écrivez un exemple pour illustrer un concept, y rajouter un code
de gestion des erreurs très résilient peut nuire à la clarté de l'exemple. Dans
les exemples, il est courant d'utiliser une méthode comme unwrap
(qui peut
faire un panic) pour remplacer le code de gestion de l'erreur que vous
utiliseriez en temps normal dans votre application, et qui peut changer en
fonction de ce que le reste de votre code va faire.
De la même manière, les méthodes unwrap
et expect
sont très pratiques pour
coder des prototypes, avant même de décider comment gérer les erreurs. Ce sont
des indicateurs clairs dans votre code pour plus tard quand vous serez prêt à
rendre votre code plus résilient aux échecs.
Si l'appel à une méthode échoue dans un test, nous voulons que tout le test
échoue, même si cette méthode n'est pas la fonctionnalité que nous testons.
Puisque c'est panic!
qui indique qu'un test a échoué, utiliser unwrap
ou
expect
est exactement ce qu'il faut faire.
Les cas où vous avez plus d'informations que le compilateur
Vous pouvez utiliser unwrap
lorsque vous avez une certaine logique qui
garantit que le Result
sera toujours une valeur Ok
, mais que ce n'est pas le
genre de logique que le compilateur arrive à comprendre. Vous aurez quand même
une valeur Result
à gérer : l'opération que vous utilisez peut échouer de
manière générale, même si dans votre cas c'est logiquement impossible. Si en
inspectant manuellement le code vous vous rendez compte que vous n'aurez jamais
une variante Err
, vous pouvez tout à fait utiliser unwrap
. Voici un
exemple :
fn main() { use std::net::IpAddr; let home: IpAddr = "127.0.0.1".parse().unwrap(); }
Nous créons une instance de IpAddr
en interprétant une chaîne de caractères
codée en dur dans le code. Nous savons que 127.0.0.1
est une adresse IP
valide, donc il est acceptable d'utiliser unwrap
ici. Toutefois, avoir une
chaîne de caractères valide et codée en dur ne change pas le type de retour de
la méthode parse
: nous obtenons toujours une valeur de type Result
et le
compilateur va nous demander de gérer le Result
comme si on pouvait obtenir la
variante Err
, car le compilateur n'est pas suffisamment intelligent pour
comprendre que cette chaîne de caractères est toujours une adresse IP valide. Si
le texte de l'adresse IP provient de l'utilisateur au lieu d'être codé en dur
dans le programme et donc qu'il y a désormais une possibilité d'erreur, alors
nous devrions vouloir gérer le Result
d'une manière plus résiliente.
Recommandations pour gérer les erreurs
Il est recommandé de faire paniquer votre code dès qu'il risque d'aboutir à un état invalide. Dans ce contexte, un état invalide est lorsqu'un postulat, une garantie, un contrat ou un invariant a été rompu, comme des valeurs invalides, contradictoires ou manquantes qui sont fournies à votre code, ainsi qu'un ou plusieurs des éléments suivants :
- L'état invalide est quelque chose qui est inattendu, contrairement à quelque chose qui devrait arriver occasionnellement, comme par exemple un utilisateur qui saisit une donnée dans un mauvais format.
- Après cette instruction, votre code a besoin de ne pas être dans cet état invalide, plutôt que d'avoir à vérifier le problème à chaque étape.
- Il n'y a pas de bonne façon d'encoder cette information dans les types que vous utilisez. Nous allons pratiquer ceci via un exemple dans une section du chapitre 17.
Si une personne utilise votre bibliothèque et lui fournit des valeurs qui n'ont
pas de sens, la meilleure des choses à faire est d'utiliser panic!
et
d'avertir cette personne du bogue dans son code afin qu'elle le règle pendant la
phase de développement. De la même manière, panic!
est parfois approprié si
vous appelez du code externe sur lequel vous n'avez pas la main, et qu'il
retourne un état invalide que vous ne pouvez pas corriger.
Cependant, si l'on s'attend à rencontrer des échecs, il est plus approprié de
retourner un Result
plutôt que de faire appel à panic!
. Il peut s'agir par
exemple d'un interpréteur qui reçoit des données erronées, ou une requête HTTP
qui retourne un statut qui indique que vous avez atteint une limite de débit.
Dans ces cas-là, vous devriez indiquer qu'il est possible que cela puisse
échouer en retournant un Result
afin que le code appelant puisse décider quoi
faire pour gérer le problème.
Lorsque votre code effectue des opérations sur des valeurs, votre code devrait
d'abord vérifier que ces valeurs sont valides, et faire un panic si les valeurs
ne sont pas correctes. C'est essentiellement pour des raisons de sécurité :
tenter de travailler avec des données invalides peut exposer votre code à des
vulnérabilités. C'est la principale raison pour laquelle la bibliothèque
standard va appeler panic!
si vous essayez d'accéder à la mémoire hors
limite : essayer d'accéder à de la mémoire qui n'appartient pas à la structure
de données actuelle est un problème de sécurité fréquent. Les fonctions ont
souvent des contrats : leur comportement est garanti uniquement si les données
d'entrée remplissent des conditions particulières. Paniquer lorsque le contrat
est violé est justifié, car une violation de contrat signifie toujours un bogue
du côté de l'appelant, et ce n'est pas le genre d'erreur que vous voulez que le
code appelant gère explicitement. En fait, il n'y a aucun moyen rationnel pour
que le code appelant se corrige : le développeur du code appelant doit
corriger le code. Les contrats d'une fonction, en particulier lorsqu'une
violation va causer un panic, doivent être expliqués dans la documentation de
l'API de ladite fonction.
Cependant, avoir beaucoup de vérifications d'erreurs dans toutes vos fonctions
serait verbeux et pénible. Heureusement, vous pouvez utiliser le système de
types de Rust (et donc la vérification de type que fait le compilateur) pour
assurer une partie des vérifications à votre place. Si votre fonction a un
paramètre d'un type précis, vous pouvez continuer à écrire votre code en
sachant que le compilateur s'est déjà assuré que vous avez une valeur valide.
Par exemple, si vous obtenez un type de valeur plutôt qu'une Option
, votre
programme s'attend à obtenir quelque chose plutôt que rien. Votre code n'a
donc pas à gérer les deux cas de variantes Some
et None
: la seule
possibilité est qu'il y a une valeur. Du code qui essaye de ne rien fournir à
votre fonction ne compilera même pas, donc votre fonction n'a pas besoin de
vérifier ce cas-là lors de l'exécution. Un autre exemple est d'utiliser un type
d'entier non signé comme u32
, qui garantit que le paramètre n'est jamais
strictement négatif.
Créer des types personnalisés pour la vérification
Allons plus loin dans l'idée d'utiliser le système de types de Rust pour s'assurer d'avoir une valeur valide en créant un type personnalisé pour la vérification. Souvenez-vous du jeu du plus ou du moins du chapitre 2 dans lequel notre code demandait à l'utilisateur de deviner un nombre entre 1 et 100. Nous n'avons jamais validé que le nombre saisi par l'utilisateur était entre ces nombres avant de le comparer à notre nombre secret ; nous avons seulement vérifié que le nombre était positif. Dans ce cas, les conséquences ne sont pas très graves : notre résultat “C'est plus !” ou “C'est moins !” sera toujours correct. Mais ce serait une amélioration utile pour aider l'utilisateur à faire des suppositions valides et pour avoir un comportement différent selon qu'un utilisateur propose un nombre en dehors des limites ou qu'il saisit, par exemple, des lettres à la place.
Une façon de faire cela serait de stocker le nombre saisi dans un i32
plutôt
que dans un u32
afin de permettre d'obtenir potentiellement des nombres
négatifs, et ensuite vérifier que le nombre est dans la plage autorisée, comme
ceci :
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
loop {
// -- partie masquée ici --
println!("Veuillez saisir un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de la saisie");
let supposition: i32 = match supposition.trim().parse() {
Ok(nombre) => nombre,
Err(_) => continue,
};
if supposition < 1 || supposition > 100 {
println!("Le nombre secret est entre 1 et 100.");
continue;
}
match supposition.cmp(&nombre_secret) {
// -- partie masquée ici --
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => {
println!("Gagné !");
break;
}
}
}
}
L'expression if
vérifie si la valeur est en dehors des limites et informe
l'utilisateur du problème le cas échéant, puis utilise continue
pour passer à
la prochaine itération de la boucle et ainsi demander de saisir une nouvelle
supposition. Après l'expression if
, nous pouvons continuer avec la comparaison
entre supposition
et le nombre secret tout en sachant que supposition
est
entre 1 et 100.
Cependant, ce n'est pas une solution idéale : si c'était absolument critique que le programme ne travaille qu'avec des valeurs entre 1 et 100 et qu'il aurait de nombreuses fonctions qui reposent sur cette condition, cela pourrait être fastidieux (et cela impacterait potentiellement la performance) de faire une vérification comme celle-ci dans chacune de ces fonctions.
À la place, nous pourrions construire un nouveau type et intégrer les
vérifications dans la fonction de création d'une instance de ce type plutôt que
de répéter partout les vérifications. Il est ainsi plus sûr pour les fonctions
d'utiliser ce nouveau type dans leurs signatures et d'utiliser avec confiance
les valeurs qu'elles reçoivent. L'encart 9-13 montre une façon de définir un
type Supposition
qui ne créera une instance de Supposition
que si la
fonction new
reçoit une valeur entre 1 et 100 :
#![allow(unused)] fn main() { pub struct Supposition { valeur: i32, } impl Supposition { pub fn new(valeur: i32) -> Supposition { if valeur < 1 || valeur > 100 { panic!("Supposition valeur must be between 1 and 100, got {}.", valeur); } Supposition { valeur } } pub fn valeur(&self) -> i32 { self.valeur } } }
D'abord, nous définissons une structure qui s'appelle Supposition
qui a un
champ valeur
qui stocke un i32
. C'est dans ce dernier que le nombre sera
stocké.
Ensuite, nous implémentons une fonction associée new
sur Supposition
qui
crée des instances de Supposition
. La fonction new
est conçue pour recevoir
un paramètre valeur
de type i32
et retourner une Supposition
. Le code dans
le corps de la fonction new
teste valeur
pour s'assurer qu'elle est bien
entre 1 et 100. Si valeur
échoue à ce test, nous faisons appel à panic!
, qui
alertera le développeur qui écrit le code appelant qu'il a un bogue qu'il doit
régler, car créer une Supposition
avec valeur
en dehors de cette plage va
violer le contrat sur lequel s'appuie Supposition::new
. Les conditions dans
lesquelles Supposition::new
va paniquer devraient être expliquées dans la
documentation publique de l'API ; nous verrons les conventions pour indiquer
l'éventualité d'un panic!
dans la documentation de l'API que vous créerez
au chapitre 14. Si valeur
passe le test, nous créons une nouvelle
Supposition
avec son champ valeur
qui prend la valeur du paramètre valeur
et retourne cette Supposition
.
Enfin, nous implémentons une méthode valeur
qui emprunte self
, n'a aucun
autre paramètre, et retourne un i32
. Ce genre de méthode est parfois appelé un
accesseur, car son rôle est d'accéder aux données des champs et de les
retourner. Cette méthode publique est nécessaire car le champ valeur
de la
structure Supposition
est privé. Il est important que le champ valeur
soit
privé pour que le code qui utilise la structure Supposition
ne puisse pas
directement assigner une valeur à valeur
: le code en dehors du module doit
utiliser la fonction Supposition::new
pour créer une instance de
Supposition
, ce qui permet d'empêcher la création d'une Supposition
avec un
champ valeur
qui n'a pas été vérifié par les conditions dans la fonction
Supposition:new
.
Une fonction qui prend en paramètre ou qui retourne des nombres uniquement entre
1 et 100 peut ensuite déclarer dans sa signature qu'elle prend en paramètre ou
qu'elle retourne une Supposition
plutôt qu'un i32
et n'aura pas besoin de
faire de vérifications supplémentaires dans son corps.
Résumé
Les fonctionnalités de gestion d'erreurs de Rust sont conçues pour vous aider à
écrire du code plus résilient. La macro panic!
signale que votre programme
est dans un état qu'il ne peut pas gérer et vous permet de dire au processus de
s'arrêter au lieu d'essayer de continuer avec des valeurs invalides ou
incorrectes. L'énumération Result
utilise le système de types de Rust pour
signaler que des opérations peuvent échouer de telle façon que votre code puisse
rattraper l'erreur. Vous pouvez utiliser Result
pour dire au code qui appelle
votre code qu'il a besoin de gérer le résultat et aussi les potentielles
erreurs. Utiliser panic!
et Result
de manière appropriée rendra votre code
plus fiable face à des problèmes inévitables.
Maintenant que vous avez vu la façon dont la bibliothèque standard tire parti de
la généricité avec les énumérations Option
et Result
, nous allons voir
comment la généricité fonctionne et comment vous pouvez l'utiliser dans votre code.