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
    }
}
}

Encart 9-13 : un type Supposition qui ne va continuer que si la valeur est entre 1 et 100

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.