Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

panic! ou ne pas panic!

Alors, comment décider quand vous devriez appeler panic! et quand vous devriez renvoyer Result ? Lorsque le code panique, il n’y à aucun moyen de récupérer. Vous pourriez appeler panic! pour n’importe quelle situation d’erreur, qu’il y ait un moyen possible de récupérer ou non, mais dans ce cas vous prenez la décision qu’une situation est irrécupérable au nom du code appelant. Lorsque vous choisissez de renvoyer une valeur Result, vous donnez des options au code appelant. Le code appelant pourrait choisir de tenter de récupérer d’une manière appropriée à sa situation, ou il pourrait décider qu’une valeur Err dans ce cas est irrécupérable, et ainsi appeler panic! pour transformer votre erreur récupérable en erreur irrécupérable. Par conséquent, renvoyer Result est un bon choix par défaut lorsque vous définissez une fonction qui pourrait échouer.

Dans des situations telles que les exemples, le code de prototypage et les tests, il est plus approprié d’écrire du code qui panique plutôt que de renvoyer un Result. Explorons pourquoi, puis discutons des situations dans lesquelles le compilateur ne peut pas déterminer que l’échec est impossible, mais vous en tant qu’humain le pouvez. Le chapitre se conclura par quelques directives générales sur la façon de décider s’il faut paniquer dans du code de bibliothèque.

Exemples, code de prototypage et tests

Lorsque vous écrivez un exemple pour illustrer un concept, inclure également du code robuste de gestion des erreurs peut rendre l’exemple moins clair. Dans les exemples, il est entendu qu’un appel à une méthode comme unwrap qui pourrait paniquer est destiné à servir de substitut pour la manière dont vous voudriez que votre application gère les erreurs, ce qui peut différer selon ce que fait le reste de votre code.

De la même manière, les méthodes unwrap et expect sont très pratiques lorsque vous faites du prototypage et que vous n’êtes pas encore prêt à décider comment gérer les erreurs. Elles laissent des marqueurs clairs dans votre code pour le moment où vous serez prêt à rendre votre programme plus robuste.

Si un appel de méthode échoue dans un test, vous voudriez que le test entier échoue, même si cette méthode n’est pas la fonctionnalité testée. Comme panic! est la façon dont un test est marqué comme échoué, appeler unwrap ou expect est exactement ce qui devrait se passer.

Quand vous en savez plus que le compilateur

Il serait également approprié d’appeler expect lorsque vous avez une autre logique qui garantit que le Result aura une valeur Ok, mais que cette logique n’est pas quelque chose que le compilateur comprend. Vous aurez toujours une valeur Result que vous devez gérer : quelle que soit l’opération que vous appelez, elle a toujours la possibilité d’échouer en général, même si c’est logiquement impossible dans votre situation particulière. Si vous pouvez garantir en inspectant manuellement le code que vous n’aurez jamais de variante Err, il est tout à fait acceptable d’appeler expect et de documenter la raison pour laquelle vous pensez ne jamais avoir de variante Err dans le texte de l’argument. Voici un exemple : rust {{#rustdoc_include ../listings/ch09-error-handling/no-listing-08-unwrap-that-cant-fail/src/main.rs:here}}

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

Nous créons une instance d’IpAddr en analysant une chaîne de caractères codée en dur. Nous pouvons voir que 127.0.0.1 est une adresse IP valide, il est donc acceptable d’utiliser expect ici. Cependant, avoir une chaîne valide codée en dur ne change pas le type de retour de la méthode parse : nous obtenons toujours une valeur Result, et le compilateur nous obligera toujours à gérer le Result comme si la variante Err était une possibilité, car le compilateur n’est pas assez intelligent pour voir que cette chaîne est toujours une adresse IP valide. Si la chaîne de l’adresse IP provenait d’un utilisateur plutôt que d’être codée en dur dans le programme et avait donc effectivement une possibilité d’échec, nous voudrions certainement gérer le Result de manière plus robuste. Mentionner l’hypothèse que cette adresse IP est codée en dur nous incitera à remplacer expect par un meilleur code de gestion des erreurs si, à l’avenir, nous devons obtenir l’adresse IP depuis une autre source.

Directives pour la gestion des erreurs

Il est conseillé de faire paniquer votre code lorsqu’il est possible que votre code se retrouve dans un état incohérent. Dans ce contexte, un état incohérent (bad state) survient lorsqu’une hypothèse, une garantie, un contrat ou un invariant a été violé, par exemple lorsque des valeurs invalides, contradictoires ou manquantes sont passées à votre code – plus une où plusieurs des conditions suivantes :

  • L’état incohérent est quelque chose d’inattendu, par opposition à quelque chose qui se produira probablement de temps en temps, comme un utilisateur saisissant des données dans le mauvais format.
  • Votre code après ce point doit pouvoir compter sur le fait de ne pas être dans cet état incohérent, plutôt que de 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 travaillerons sur un exemple de ce que nous voulons dire dans [« Encoder les états et les comportements comme des types »][encoding] au chapitre 18.

Si quelqu’un appelle votre code et passe des valeurs qui n’ont pas de sens, il est préférable de renvoyer une erreur si vous le pouvez afin que l’utilisateur de la bibliothèque puisse décider ce qu’il veut faire dans ce cas. Cependant, dans les cas où continuer pourrait être dangereux ou nuisible, le meilleur choix pourrait être d’appeler panic! et d’alerter la personne utilisant votre bibliothèque du bogue dans son code afin qu’elle puisse le corriger pendant le développement. De même, panic! est souvent approprié si vous appelez du code externe qui échappe à votre contrôle et qui renvoie un état invalide que vous n’avez aucun moyen de corriger.

Cependant, lorsque l’échec est attendu, il est plus approprié de renvoyer un Result que de faire un appel à panic!. Les exemples incluent un analyseur recevant des données malformées ou une requête HTTP renvoyant un statut indiquant que vous avez atteint une limite de débit. Dans ces cas, renvoyer un Result indique que l’échec est une possibilité attendue que le code appelant doit décider comment gérer.

Lorsque votre code effectue une opération qui pourrait mettre un utilisateur en danger si elle est appelée avec des valeurs invalides, votre code devrait vérifier que les valeurs sont valides en premier et paniquer si les valeurs ne sont pas valides. C’est principalement pour des raisons de sécurité : tenter d’opérer sur des données invalides peut exposer votre code à des vulnérabilités. C’est la raison principale pour laquelle la bibliothèque standard appellera panic! si vous tentez un accès mémoire hors limites : 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é courant. Les fonctions ont souvent des contrats : leur comportement n’est garanti que si les entrées satisfont des exigences particulières. Paniquer lorsque le contrat est violé est logique car une violation de contrat indique toujours un bogue du côté de l’appelant, et ce n’est pas un type d’erreur que vous voulez que le code appelant doive explicitement gérer. En fait, il n’y a pas de moyen raisonnable pour le code appelant de récupérer ; ce sont les programmeurs appelants qui doivent corriger le code. Les contrats d’une fonction, en particulier lorsqu’une violation provoquera un panic, devraient être expliqués dans la documentation de l’API de la fonction.

Cependant, avoir de nombreuses vérifications d’erreurs dans toutes vos fonctions serait verbeux et agaçant. Heureusement, vous pouvez utiliser le système de types de Rust (et donc la vérification de types effectuée par le compilateur) pour faire beaucoup de ces vérifications à votre place. Si votre fonction à un type particulier comme paramètre, vous pouvez poursuivre la logique de votre code en sachant que le compilateur a déjà garanti que vous avez une valeur valide. Par exemple, si vous avez un type plutôt qu’une Option, votre programme s’attend à avoir quelque chose plutôt que rien. Votre code n’a alors pas besoin de gérer deux cas pour les variantes Some et None : il n’aura qu’un seul cas pour avoir définitivement une valeur. Du code essayant de passer rien à votre fonction ne compilera même pas, donc votre fonction n’a pas besoin de vérifier ce cas à l’exécution. Un autre exemple est l’utilisation d’un type d’entier non signé comme u32, qui garantit que le paramètre n’est jamais négatif.

Types personnalisés pour la validation

Poussons l’idée d’utiliser le système de types de Rust pour garantir que nous avons une valeur valide un cran plus loin et examinons la création d’un type personnalisé pour la validation. Rappelez-vous le jeu de devinettes du chapitre 2 dans lequel notre code demandait à l’utilisateur de deviner un nombre entre 1 et 100. Nous n’avons jamais validé que la supposition de l’utilisateur se trouvait entre ces nombres avant de la comparer avec notre nombre secret ; nous avons seulement validé que la supposition était positive. Dans ce cas, les conséquences n’étaient pas très graves : notre affichage de « Trop grand » ou « Trop petit » serait toujours correct. Mais ce serait une amélioration utile de guider l’utilisateur vers des suppositions valides et d’avoir un comportement différent lorsque l’utilisateur devine un nombre hors de la plage par rapport à quand l’utilisateur tape, par exemple, des lettres à la place.

Une façon de faire cela serait d’analyser la supposition comme un i32 au lieu d’un u32 uniquement pour permettre des nombres potentiellement négatifs, puis d’ajouter une vérification que le nombre est dans la plage, comme ceci :

Filename: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

L’expression if vérifie si notre valeur est hors de la plage, informe l’utilisateur du problème et appelle continue pour démarrer la prochaine itération de la boucle et demander une autre supposition. Après l’expression if, nous pouvons poursuivre avec les comparaisons entre guess et le nombre secret en sachant que guess est entre 1 et 100.

Cependant, ce n’est pas une solution idéale : s’il était absolument critique que le programme n’opère que sur des valeurs entre 1 et 100, et qu’il avait de nombreuses fonctions avec cette exigence, avoir une vérification comme celle-ci dans chaque fonction serait fastidieux (et pourrait impacter les performances).

À la place, nous pouvons créer un nouveau type dans un module dédié et placer les validations dans une fonction qui crée une instance du type plutôt que de répéter les validations partout. De cette façon, les fonctions peuvent utiliser le nouveau type dans leurs signatures en toute sécurité et utiliser avec confiance les valeurs qu’elles reçoivent. L’encart 9-13 montre une façon de définir un type Guess qui ne créera une instance de Guess que si la fonction new reçoit une valeur entre 1 et 100.

Filename: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: A Guess type that will only continue with values between 1 and 100

Notez que ce code dans src/guessing_game.rs dépend de l’ajout d’une déclaration de module mod guessing_game; dans src/lib.rs que nous n’avons pas montrée ici. Dans le fichier de ce nouveau module, nous définissons une structure nommée Guess qui à un champ nommé value contenant un i32. C’est là que le nombre sera stocké.

Ensuite, nous implémentons une fonction associée nommée new sur Guess qui crée des instances de valeurs Guess. La fonction new est définie avec un paramètre nommé value de type i32 et renvoie un Guess. Le code dans le corps de la fonction new teste value pour s’assurer qu’il est entre 1 et 100. Si value ne passe pas ce test, nous faisons un appel à panic!, qui alertera le programmeur écrivant le code appelant qu’il à un bogue qu’il doit corriger, car créer un Guess avec une value en dehors de cette plage violerait le contrat sur lequel Guess::new s’appuie. Les conditions dans lesquelles Guess::new pourrait paniquer devraient être discutées dans sa documentation d’API publique ; nous couvrirons les conventions de documentation indiquant la possibilité d’un panic! dans la documentation d’API que vous créerez au chapitre 14. Si value passe le test, nous créons un nouveau Guess avec son champ value défini sur le paramètre value et renvoyons le Guess.

Ensuite, nous implémentons une méthode nommée value qui emprunté self, n’à aucun autre paramètre et renvoie un i32. Ce type de méthode est parfois appelé un getter car son but est d’obtenir des données de ses champs et de les renvoyer. Cette méthode publique est nécessaire car le champ value de la structure Guess est privé. Il est important que le champ value soit privé afin que le code utilisant la structure Guess ne soit pas autorisé à définir value directement : le code en dehors du module guessing_game doit utiliser la fonction Guess::new pour créer une instance de Guess, garantissant ainsi qu’il n’y à aucun moyen pour un Guess d’avoir une value qui n’a pas été vérifiée par les conditions de la fonction Guess::new.

Une fonction qui à un paramètre ou ne renvoie que des nombres entre 1 et 100 pourrait alors déclarer dans sa signature qu’elle prend ou renvoie un Guess plutôt qu’un i32 et n’aurait pas besoin de faire de vérifications supplémentaires dans son corps.

Résumé

Les fonctionnalités de gestion des erreurs de Rust sont conçues pour vous aider à écrire du code plus robuste. 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’enum Result utilise le système de types de Rust pour indiquer que des opérations pourraient échouer d’une manière dont votre code pourrait récupérer. Vous pouvez utiliser Result pour indiquer au code qui appelle votre code qu’il doit également gérer le succès ou l’échec potentiel. Utiliser panic! et Result dans les situations appropriées rendra votre code plus fiable face aux problèmes inévitables.

Maintenant que vous avez vu des manières utiles dont la bibliothèque standard utilise les génériques avec les enums Option et Result, nous parlerons de la façon dont les génériques fonctionnent et comment vous pouvez les utiliser dans votre code.