Aller au contenu

Gestion des erreurs en Rust

La gestion des erreurs est un concept capital en Rust.

Tout d’abord on va distinguer 2 types d’erreur en Rust :

  • Les erreurs non récupérable
  • Les erreurs récupérable

C’est quoi la différence entre les 2 ? Et bien c’est assez simple.

Tout d’abord c’est au dev de choisir si son erreur est récupérable ou pas et on va voir comment choisir entre les 2.

Les Erreurs non récupérable

Imaginons qu’on ait un programme qui va écrire des informations dans un fichier.

Si on doit absolument écrire dans CE fichier, sans quoi le programme ne pourrait pas continuer, alors si le fichier n’existe pas on va créer une erreur non récupérable qui va arréter l’execution du programme.

fn main(){
//Imaginons que le fichier est introuvable...
panic!("Le Fichier n'existe pas!");
}

Le mot clé panic! va faire crasher le programme et afficher la string qu’on lui a donné au moment du crash.

Voici le message d’erreur qu’on obtient : thread 'main' panicked at src/main.rs:2:5... Le Fichier n'existe pas!

On a une info très interessante, c’est l’endroit ou a eu lieu le panic!.

⬇️ Le numéro de la ligne

src/main.rs:2:5 ⬆️ Le numéro du charactère

Donc ici on voit que l’erreur a eu lieu à la 2ème ligne, au 5ème charactère.

Ca nous permet de débugger plus facilement notre programme.

Les Erreurs récupérable.

A contrario certaines erreurs peuvent être moins grave et ne pas empêcher le programme de continuer.

Imaginons maintenant qu’on veuille créer un programme qui soit une calculatrice, comme tu le sais certaines opérations sont interdites, par exemple diviser par 0.

Quand tu divises par 0 sur une calculatrice, ta calculatrice ne plante pas complètement en s’arretant, elle va simplement t’afficher un message d’erreur t’indiquant qu’on ne peut pas diviser par 0.

C’est ce qu’on va pouvoir faire avec les erreurs recouvrable.

Le type Result

Pour gérer les erreurs recouvrable nous allons voir un nouveau type, nommé le type Result

Ce type est un enum, voici à quoi il ressemble :

Result<T,E>{
OK(T),
Err(E)
}

Qu’est ce que c’est que ce T et ce E ?

Et bien ce sont des types génériques qu’on a pas encore vu, tout ça c’est pour dire que T sera de n’importe quel type qu’on veut et pareil pour E.

Concrètement la variante OK contiendra une valeur de type T qui sera la valeur en cas de succès.

La variante Err contiendra une valeur de type E qui sera la valeur en cas d’erreur.

Donc une valeur de type Result, sera soit la variante Ok avec une valeur de n’importe quel type en cas de succès, soit la variante Err avec une valeur de n’importe quel type en cas d’erreur.

Le type Result ressemble énormément au type option, on va voir maintenant tout ça avec un exemple de calculatrice.

Voilà un programme de calculatrice sans aucune gestion d’erreur.

fn div(x:usize, y:usize)-> f64{
x/y
}
fn main(){
let a = 44;
let b = 0;
let result = div(a,b); // Erreur car division par 0
}

Maintenant voyons comment gérer l’erreur de la division par 0.

La première chose à faire c’est de modifier le type de retour de la fonction div, car lorsqu’on divise par 0 on ne peut pas retourner un nombre mais une erreur.

On va donc retourner un type Result, qui va contenir soit un f64 si le calcul a réussi soit une String qui sera le message d’erreur dans le cas ou le calcul échoue.

Ensuite dans la fonction div, on va faire en sorte que si le diviseur est 0, on retourne une erreur sinon on retourne le résultat.

fn div(x:usize, y:usize)-> Result<usize, String>{
if y==0{
return Err(String::from("Interdit de diviser par 0"))
}
Ok(x/y)
}
fn main(){
let a = 44;
let b = 0;
let result = div(a,b);// Err("Interdit de diviser par 0")
println!("{:?}", result);// :? pour afficher un type complexe
}

Maintenant notre programme va retourner une erreur quand on divise par 0 ou le résultat dans tous les autres cas.

Comme toujours avec les enums, il va falloir unwrap la valeur contenu dans la variante si on veut travailler avec.

Extraire la valeur avec unwrap()

Pour cela on utilise la méthode unwrap() comme on l’a vu dans l’article sur les enums, cependant il y a une grosse différence avec les autres enums…

Si la valeur à unwrap() est la variante Err(), alors Rust va automatiquement appeller panic! pour nous. Pourquoi ? Tout simplement car quand on veut unwrap() on part du principe que la valeur est une valeur de réussite, sinon ça n’a pas de sens de unwrap().

fn div(x:usize, y:usize)-> Result<usize, String>{
if y==0{
return Err(String::from("Interdit de diviser par 0"))
}
Ok(x/y)
}
fn main(){
let a = 44;
let b = 4;
let result = div(a,b); // Ok(44)
let number = result.unwrap() // 44
}

Grâce à unwrap on a maintenant accès à la valeur du calcul et pas à la variante.

Maintenant voyons cee qui se passe lorsqu’on divise par 0 et qu’on utilise unwrap.

fn div(x:usize, y:usize)-> Result<usize, String>{
if y==0{
return Err(String::from("Interdit de diviser par 0"))
}
Ok(x/y)
}
fn main(){
let a = 44;
let b = 0;
let result = div(a,b); // Err("Interdit de diviser par 0")
let number = result.unwrap() // panic
}

Utiliser des enums comme Erreur

Un pattern qui est très pratique est de regrouper les différentes erreurs possible dans un enum.

Pour reprendre notre exemple de calculatrice, il existe plusieurs erreurs possible pas seulement la division par 0.

Comme calculer la racine carré d’un nombre négatif par exemple !

On peut donc créer un enum MathError qui va avoir pour variante DivisionByZero et NegativeSquareRoot. ça permet de clarifier le code et de regrouper les différentes erreurs de calcul au même endroit.

enum MathError{
DivisionByZero,
NegativeSquareRoot
}
fn div(x:usize, y:usize)-> Result<f64, MathError::DivisionByZero>{
if y==0{
return Err(MathError::DivisionByZero)
}
Ok(x/y)
}
fn sqrt(x: f64) -> Result<f64, MathError::NegativeSquareRoot> {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn main(){
let x = 12.0;
let y = 0.0;
let div_result = div(x, y); // Err(MathError::DivisionByZero)
let a = -4.0;
let sqrt_a = sqart(a); // Err(MathError::NegativeSquareRoot)
}

Tu vois qu’on peut maintenant gérer les erreurs intélligement et de façon claire.

Le seul petit truc un peu moche ici, c’est qu’on est obligé d’écrire comme type de retour le long Result<f64, MathError::NegativeSquareRoot>.

On peut corriger ça assez simplement grâce au mot clé type qui va nous permettre de donner un nom à un type particulier.

On va ainsi pouvoir faire type MathResult = Result<f64, MathError>; et utiliser le type MathError comme type de retour des fonctions div et sqrt.

enum MathError{
DivisionByZero,
NegativeSquareRoot
}
type MathResult = Result<f64, MathError>;
fn div(x:usize, y:usize)-> MathResult{
if y==0{
return Err(MathError::DivisionByZero)
}
Ok(x/y)
}
fn sqrt(x: f64) -> MathResult{
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn main(){
let x = 12.0;
let y = 0.0;
let div_result = div(x, y); // Err(MathError::DivisionByZero)
let a = -4.0;
let sqrt_a = sqart(a); // Err(MathError::NegativeSquareRoot)
}

Et voilà le travail, un code très propre et concis !

La seule différence avec le code de tout à l’heure c’est que maintenant div pourrait techniquement retourner une erreur de type NegativeSquareRoot à cause de sa signature, puisqu’elle peut retourner un erreur de type MathError et que MathError comporte les variantes NegativeSquareRoot.

Cependant ça ne pose pas problème puisque notre fonction div ne retourne jamais cette erreur.

L’opérateur ?

L’opérateur ? s’utilise uniquement dans des fonctions qui retournent un type Result.

Voilà ce qu’il fait :

  • Si la valeur est la variante Ok(), la valeur à l’intérieur est unwrap
  • Si la valeur est la variante Err(), l’erreur est retournée depuis la fonction ou on se trouve, comme si on avait utilisé return.

Ca nous permet très facilement de lancer une opération, récupérer la valeur si l’opération a fonctionnée ou de retourner directement une erreur si l’opération a échouée.

Reprenons notre exemple de calculatrice, imaginons qu’on crée une fonction qui va:

  • Diviser 2 nombres
  • Calculer la racine carré du résultat de la division précédente
enum MathError{
DivisionByZero,
NegativeSquareRoot
}
type MathResult = Result<f64, MathError>;
fn div(x:usize, y:usize)-> MathResult{
if y==0{
return Err(MathError::DivisionByZero)
}
Ok(x/y)
}
fn sqrt(x: f64) -> MathResult{
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn operation(x:f64, y:f64) -> MathResult{
// Si div échoue, Math::DivisionByZero est retourné
let div_result = div(x,y)?
// Si sqrt échoue, Math::NegativeSquareRoot est retourné
let sqrt_result = sqrt(div_result)?
// Si rien échoue on retourne sqrt_result
sqrt_result
}
fn main(){
let x = 12.0;
let y = 0.0;
let result = operation(x, y); // Err(MathError::DivisionByZero)
}

Tu vois que dans la fonction operation, on a, avec très peu de lignes, implémenté une logique puissante de gestion des erreurs.

Tout ça sans aucun if !

Concrètement quand on fait div(x,y) avec y différent de 0, le résultat est de type Ok(f64), le fait d’utiliser l’opérateur ? va extraire la valeur contenu dans le Ok et ainsi div(x,y)? sera de type f64.