Discussion du code gestion_erreurs

Concepts

Les concepts abordés dans cet exemple sont:

Documentation

Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :

Discussion

Lors de l'exécution d'un programme, il existe en général une multitude d'endroits où des erreurs peuvent se produire. Il est important de savoir identifier les différents types d'erreurs, ainsi que les outils offerts par le langage Rust permettant de gérer ces erreurs.

Les différents types d'erreurs

Il faut savoir différentier deux principaux types d'erreurs.

Erreurs rattrapables

Dans un premier temps, il y a les erreurs rattrapables ou prévues. En général, ces erreurs surviennent lorsque l'utilisateur entre des données erronées.

Nous avons par exemple :

  • Le chemin d'un fichier fourni par l'utilisateur qui n'existe pas
  • Une entrée invalide, p.ex une lettre à la place d'un nombre
  • De mauvais identifiants pour la connexion à un serveur

Ces erreurs sont attendues, il est donc normal d'anticiper leurs occurences, et par conséquent, de s'y préparer. Elles de ne devraient pas interrompre l'exécution du programme.

Pour détecter ce type d'erreur, nous devons essayer de prévoir tous les scénarios possibles. Si je demande à l'utilisateur d'entrer une valeur comprise entre 1 et 10, je pourrais par exemple obtenir :

  • Une valeur non-numérique
  • Une valeur non comprise dans l'intervalle demandée
  • L'absence de valeur
  • Une erreur impossible à prévoir parce qu'on ne peut pas tout prévoir. Le meilleur moyen de se prémunir de ce genre d'erreur, consiste à utiliser des types adaptés.

Prenons par exemple la fonction :

#![allow(unused)]
fn main() {
fn make_color(red:i32, green:i32, blue:i32, alpha:i32) -> i32 {
    red << 24 | green << 16 | blue << 8 | alpha // Quel est le problème?
}
}

Cette fonction prend en argument une valeur par canal de couleur et construit un entier représentant une couleur rgba 32 bit. Le problème majeur dans cette fonction est le typage de nos arguments. Pour fabriquer une couleur rgba 32 bit, il faut un octet par canal. Le type i32 n'est donc pas approprié, il pourrait être la source d'erreurs.

Pour éviter cela, on pourrait par exemple le remplacer par u8 qui permet de représenter un octet.

#![allow(unused)]
fn main() {
fn make_color(red:u8, green:u8, blue:u8, alpha:u8)->u32 {
    (red as u32) << 24 | (green as u32) << 16 | (blue as u32) << 16 | (alpha as u32)
}
}

De cette manière, on évite les erreurs potentielles à la source. Vous noterez qu'il est nécessaire de caster vos arguments, afin d'avoir une expression cohérente du point de vue des types. Notre code est donc moins susceptible de contenir une erreur, mais en contrepartie, il est plus complexe.

Erreurs irrattrapables

Le deuxième type d'erreurs que l'on peut rencontrer sont les erreurs irrattrapables (les bugs par exemple).

Lorsque de notre programme se trouve dans un état incohérent, il est nécessaire d’interrompre son exécution. De manière générale, les bugs surviennent lorsque le développeur a fait une erreur dans son code. Il existe plusieurs erreurs possibles. Nous avons par exemple des erreurs :

  • algorithmiques (ex: un faute logique dans la conception de l'algorithme, un cas pas couvert)
  • du système (ex: la carte réseau n'est pas accessible)
  • humaines (ex: le développeur a fait une faute d'inattention en écrivant son code)

Il faut à tout prix éviter ces erreurs au maximum. Un programme qui interrompt constamment son exécution, n'est pas un programme robuste et par conséquent, un programme qui n'est pas fiable. La notion de fiabilité dépasse le cadre de ce cours, mais nous pouvons dire pour résumer qu'il existe plusieurs domaines et donc plusieurs niveau d'attente en terme de fiabilité. Il arrive souvent qu'un jeu soit buggé dans ses premières versions. En revanche, il est n'est pas acceptable que le pilote automatique d'un avion crash au en plein milieu d'un vol.

Le type Option

Historiquement, on représentait l'absence de valeur par une valeur dédiée (p.ex : une personne qui n'a pas renseigné son âge, possède un âge de -1, ou alors l'utilisation du pointeur NULL). Bien que cette solution fonctionne, elle peut-être la source de nombreuses erreurs et bugs en tout genre. Un type optionnel est une alternative moderne et plus robuste pour nombreuses erreurs et bugs en tout genre. Les langages modernes gèrent ces cas à l'aide de leur système de typage permettant de meilleures vérifications à la compilation et à l'exécution ce qui les rend plus robustes: on parle de types optionnels.

Il existe un grand nombre de fonctions qui ne retournent pas forcèment un résultat. On peut également penser à des structures. Le caractère optionnel d'une valeur n'est pas forcèment une erreur, mais si cet aspect n'est pas géré correctement, il peut mener à des erreurs.

Nous pouvons par exemple vouloir représenter un utilisateur qui peut s'il le veut fournir sa date de naissance et son adresse email. Prenons la structure suivante :

struct User {
    username: String,
    birth_date: i32,
    email: String,
}

Si on souhaite récupérer l'une de ces deux informations, on pourrait se retrouver dans un cas où l'utilisateur n'a pas souhaité renseigner l'information désirée. C'est là qu'intervient le type Option<T>. Il permet de représenter une valeur optionnelle.

Voici sa déclaration d'après la documentation Rust :

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

C'est tout simplement d'un type énuméré qui contient soit une valeur sous la forme Some(ma_valeur) ou pas de valeur None. Il s'agit de la version générique du type NumberOrNothing vu dans la partie 2.

Nous pouvons donc réecrire notre structure User de cette manière :

struct User {
    username: String,
    birth_date: Option<i32>,
    email: Option<String>,
}

Si nous reprenons notre exemple du minimum d'un tableau, nous pouvons écrire notre fonction de la manière suivante :

pub fn find_min_with_option<T: Minimum>(tab: &[T]) -> Option<T> {
    let mut minimum = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = Minimum::min(minimum, Some(*t));
    }
    minimum
}

Ici on commence par instancier notre minimum à None, puis on compare itérativement notre minimum avec les valeurs du tableau encapsulées dans une option avec Some(*t).

Pour comparer deux options entre elles, nous avons implémenté le trait minimum pour une Option<T>, où T implémente le trait minimum. Ce qui nous donne :

impl<T: Minimum> Minimum for Option<T> {
    fn min(self, rhs: Self) -> Self {
        match self {
            Some(val_l) => Some(match rhs {
                Some(val_r) => val_l.min(val_r),
                None => val_l,
            }),
            None => match rhs {
                Some(val_r) => Some(val_r),
                None => None,
            },
        }
    }
}

On peut voir ici que l'on décompose notre option grâce au pattern matching de Rust.

La macro panic!

Un programme peut être amené à s'arrêter de manière anormale. En C, on utilise la fonction abort. Cette dernière va indiquer au processus parent ayant exécuté le programme qu'une erreur s'est produite et que l'exécution ne peut pas se poursuivre.

En Rust, il est possible de mettre fin au programme d'une manière similaire à ce que l'on retrouve en C avec la fonction suivante :

use std::process;

fn main() {
    process::abort();
}

Néanmoins, Rust étant un langage moderne, il possède la macro panic!. Cette maco ne va pas simplement interrompre l'exécution du programme et signaler une erreur au processus appelant. Par défaut, elle va également remonter la pile des appels de fonctions et libérer la mémoire au fur à mesure.

Pour déclencher une panique du programme, il suffit d'appeler la macro avec un message en argument :

fn main() {
    panic!("!!! Oups something went wrong !!!")
}

Si on exécute ce code, on obtient l'erreur suivante :

   Compiling playground v0.0.1 (/playground)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/playground`
thread 'main' panicked at '!!! Oups something went wrong !!!', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

On peut voir que la console nous indique que le fil d'exécution principal main a paniqué, et il nous affiche le message que nous avons passé en argument à notre macro panic!.

Si nous reprenons notre exemple du minimum d'un tableau, nous pouvons écrire notre fonction de la manière suivante :

pub fn find_min_with_panic<T: Minimum>(tab: &[T]) -> T {
    let mut minimum = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = Minimum::min(minimum, Some(*t));
    }

    // We decide that we cannot compute the minimum of an empty array
    match minimum {
        Some(val) => val,
        None => panic!("The array is empty"),
    }
}

La première chose que nous pouvons noter avec notre fonction est le type de retour. En effet, nous ne retournons pas une option, mais bien un élèment de type T. En effet, si on est certain que notre fonction va retourner un élément, il n'y a pas de raison d'encapsuler ce retour dans une strucutre quelconque.

Si malgré notre certitude, la fonction ne parvenait pas à produire de résultat, nous serions alors dans un cas indertimné, et par conséquent il faut mettre fin à l'exécution du programme. Pour cela, on peut voir dans la dernière expression que si le minimum n'a pas été trouvé None => panic!("The array is empty"),, alors le programme se termine sur une panic.

Le message d'erreur passé en argument est remonté plus haut. Il est en effet possible d'intercepter les panic grâce à la fonction std::panic::catch_unwind ou en rédéfissant manuellement le comportement de la macro panic! à l'aide de la fonction std::panic::set_hook. Néanmoins, l'usage de ces fonctions devrait être limité à des cas très spécifiques que nous ne couvrirons pas dans ce cours d'introduction au Rust.

Le développeur néophyte pourrait être tenté de simplifier (dans le sens du nombre de caractères) son code en utilisant uniquement la macro panic!. Cela va à l'encontre des bonnes pratiques du Rust et aura tendance à rendre votre code peu compréhensible et défaillant. Je vous recommande cet excellent article de la documentation Rust qui explique quand utiliser la macro panic! à bon escient.

En résumé, la macro panic devrait être utilisé à des fins de debugging ou uniquement lorsque le programme rentre dans un état indeterminé, c'est à dire un erreur irrattrapable.

Le type Result

Le Rust offre une solution plus élégante que d'interrompre l'exécution du programme pour gérer les erreurs. Il s'agit comme pour Option<T> d'un type énuméré. Voici sa définition :

#![allow(unused)]
fn main() {
enum Result<T, E> {
   Ok(T),
   Err(E),
}
}

Le type Result<T,E> peut contenir soit une valeur attendue en cas de réussite sous la forme d'un Ok(ma_valeur), ou valeur permettant de renseigner l'erreur sur forme de Err(mon_erreur). C'est un type plus complet qu'une simple Option<T>, car en cas d'absence de valeur, nous pouvons indiquer la cause de l'erreur.

Prenons par exemple la fonction suivante qui permet de télécharger une page WEB :

fn get_html(uri: String) -> String

Un problème pourrait se produire lors du téléchargement. On pourrait donc écrire :

fn get_html(uri: String) -> Option<String>

Si notre fonction nous retourne None, on se rend compte très vite que l'on a aucune information sur la raison de l'absence de données (pas de connexion, mauvaise url, accès interdit, etc...).

C'est là qu'intervient le type Result<T,E>, en cas d'absence de résultat de la fonction, nous allons pouvoir comprendre la source de l'erreur et réagir réagir en conséquence. Nous pourrions donc écrire :

enum DownloadError {
  BadURL,
  Forbidden,
  ...
}
fn get_html(uri: String) -> Result<String, DownloadError>

Reprenons maintenant notre exemple du minimum d'un tableau. La première chose que nous pouvons faire est de définir type représentant les erreurs que nous pourrions rencontrer en cherchant le minimum :

#[derive(PartialEq)]
pub enum FindMinError {
    EmptyList,
    UnsupportedError(String),
}

Ici nous envisageons deux scénarios pouvant provoquer une erreur :

  • Le minimum d'une liste vide
  • Une éventuelle erreur que nous n'aurions pas encore prévu. Cette erreur est accompagnée d'un message décrivant l'erreur.

Une fois nos erreurs définies, nous pouvons passer à l'implémentation de notre fonction de recherche du minimum :

pub fn find_min_with_result<T: Minimum>(tab: &[T]) -> Result<T, FindMinError> {
    let mut minimum = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = Minimum::min(minimum, Some(*t));
    }

    match minimum {
        Some(val) => Ok(val),
        None => Err(EmptyList),
    }
}

Cette fonction n'est pas très différente des précédentes. On remarque à la fin que pour former un Result, si nous trouvons un minimum, nous l'encapsulons dans un Ok(...), sinon nous retournons une erreur avec Err(...). Ici la seule erreur que nous retournons est la liste vide.

Pour traiter ce résultat, nous pouvons faire du pattern matching :

    let min = find_min_with_result(&tab_empty);
    match min {
        Ok(val) => print!("The minimum value is {}", val),
        Err(EmptyList) => eprintln!("The array is empty"),
        Err(UnsupportedError(msg)) => panic!("Unsupported error : {}", msg),
    }

Ici nous avons trois cas :

  • le minimum a été trouvé, on l'affiche.
  • la liste était vide, on affiche un message d'erreur.
  • le minimum n'a pas pu être trouvé à cause d'une erreur non gérée, le programme va donc se terminer sur une panic contenant le message d'erreur retourné par la fonction.

Il est commun de rencontrer un usage pouvant mener facilement à des erreurs de Result. Prenons par exemple Result<T, String> ou Result<T, i32>. En règle général, ces types concrets permettent de retourner un message ou un code en cas d'erreur. Bien que cela ne pose pas de problème du point de vue purement fonctionel, il rend aveugle le compilateur. En effet, en définissant les erreurs attendues avec un type énuméré, le compilateur peut s'assurer que toutes les erreurs ont été prises en compte lors d'une décomposition de l'erreur.

Prenons l'exemple suivant :

fn foo() -> Result<i32, String>{
  // do stuff
  match val {
    1 => Ok(42),
    2 => Err("Oupsi"),
    3 => Err("G phai une faute"),
    _ => Err("Aie aie aie")
  }
}

Imaginons que j'aie fait une faute dans mon message d'erreur. Mon message d'erreur est le seul moyen de différencier les différentes erreurs possibles. En corrigeant ma faute, je dois maintenant mettre à jour tout mon code, ainsi que le code de tous ceux qui utilisent mon code. Je peux également me tromper sur la casse de mon message d'erreur la ponctuation etc... Le compilateur ne verra pas l'erreur et je peux passer des heures à chercher pourquoi mon code ne marche pas.

Il en va de même avec les codes d'erreurs numériques, il suffit que je veuille changer un seul code pour devoir à nouveau mettre tout à jour.

Il est donc fortement recommandé d'éviter cet usage du type Result.

Néanmoins, l'usage de type énuméré pour la gestion des erreurs peut-être source de redondance. On se retrouve avec des types qui représentent les mêmes erreurs avec des noms différents dans chaque crate. Pour éviter cela, il est préférable de commencer par chercher si son erreur existe avant de créer une nouvelle erreur. Cela dépasse le cadre de ce cours, mais sachez qu'il existe deux crates populaires qui peuvent vous aider pour la gestion d'erreur :

L'opérateur ?

Le language Rust offre un sucre syntaxique, afin de simplifier la gestion des options et des erreurs imbriquées. L'opérateur ? permet de récupérer la valeur contenue ou faire remonter l'erreur ou l'absence de valeur. On s'en sert principalement pour les Option et les Result. Pour plus de détails sur l'interface Try qui permet d'utiliser l'opérateur ? sur un type quelconque, je vous recommande la documentation.

Prenons un exemple directement tiré de notre code :

pub fn find_min_amongst_arrays_by_hand<T: Minimum>(
    lhs: &[T],
    rhs: &[T],
) -> Result<T, FindMinError> {
    let min_result = find_min_with_result(lhs);
    let min_l = if let Ok(x) = min_result {
        x
    } else {
        // Since tmp is not Ok, we return the error to the caller
        return min_result;
    };

    let min_result = find_min_with_result(rhs);
    let min_r = if let Ok(x) = min_result {
        x
    } else {
        // Since tmp is not Ok, we return the error to the caller
        return min_result;
    };

    Ok(min_l.min(min_r))
}

Cette fonction prends deux tableaux en argument et va chercher quelle est la valeur minimum globale. Si on veut réutiliser le code que nous avons déjà écrit, nous avons une fonction qui retourne un Result et qui nous donne la valeur minimale d'un tableau. Il nous suffit donc de chercher la valeur minimale dans le premier tableau, puis dans le deuxième, et enfin retourner la plus petite des deux valeurs.

Seulement, ce n'est pas aussi simple puisque l'on obtient un Result pour chaque fonction, il faut systèmatiquement verifier qu'aucune erreur s'est produite. Ici, si une erreur est survenue, nous n'avons rien d'autre à faire que de retourner l'erreur telle quelle.

C'est en réalité un cas de figure que l'on retrouve souvent. On peut voir dans notre code ci-dessus, le code est répetitif et rends le code la fonction moins lisible.

Avec l'opérateur ? on peut simplement remplacer le test ainsi :

pub fn find_min_amongst_arrays_qm_op<T: Minimum>(lhs: &[T], rhs: &[T]) -> Result<T, FindMinError> {
    // The question mark operator will unpack the value if the function returns [Result::Ok]
    // or end the function and return the [Result:Err] to the caller.
    let min_l = find_min_with_result(lhs)?;
    let min_r = find_min_with_result(rhs)?;

    Ok(min_l.min(min_r))
}

Ces deux fonctions font strictement la même chose. L'opérateur agit comme un sucre syntaxique qui permet d'allèger l'écriture du code et ainsi augmenter sa lisibilité. En clair, si le résultat est Ok(val), l'expression retourne val, sinon la fonction se termine ici et retourne le résultat Err(error) contenu dans le résultat.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

La gestion des erreurs

$ rustlings run errors1
$ rustlings run errors2
$ rustlings run errors3
$ rustlings run errors4
$ rustlings run errors5
$ rustlings run errors6

Les options

$ rustlings run options1
$ rustlings run options2
$ rustlings run options3