Remanier le code pour améliorer sa modularité et la gestion des erreurs

Pour améliorer notre programme, nous allons résoudre quatre problèmes liés à la structure du programme et à la façon dont il gère de potentielles erreurs.

Premièrement, notre fonction main assure deux tâches : elle interprète les arguments et elle lit des fichiers. Pour une fonction aussi petite, ce n'est pas un problème majeur. Cependant, si nous continuons à faire grossir notre programme dans le main, le nombre des différentes tâches qu'assure la fonction main va continuer à s'agrandir. Plus une fonction assure des tâches différentes, plus cela devient difficile de la comprendre, de la tester, et d'y faire des changements sans casser ses autres constituants. Cela est mieux de séparer les fonctionnalités afin que chaque fonction n'assure qu'une seule tâche.

Cette problématique est aussi liée au deuxième problème : bien que recherche et nom_fichier soient des variables de configuration de notre programme, les variables telles que contenu sont utilisées pour appuyer la logique du programme. Plus main est grand, plus nous aurons des variables à importer dans la portée ; plus nous avons des variables dans notre portée, plus il sera difficile de se souvenir à quoi elles servent. Il est préférable de regrouper les variables de configuration dans une structure pour clarifier leur usage.

Le troisième problème est que nous avons utilisé expect pour afficher un message d'erreur lorsque la lecture du fichier échoue, mais le message affiche uniquement Quelque chose s'est mal passé lors de la lecture du fichier. Lire un fichier peut échouer pour de nombreuses raisons : par exemple, le fichier peut ne pas exister, ou parce que nous n'avons pas le droit de l'ouvrir. Pour le moment, quelle que soit la raison, nous affichons le message d'erreur Quelque chose s'est mal passé lors de la lecture du fichier, ce qui ne donne aucune information à l'utilisateur !

Quatrièmement, nous utilisons expect à répétition pour gérer les différentes erreurs, et si l'utilisateur lance notre programme sans renseigner d'arguments, il va avoir une erreur index out of bounds provenant de Rust, qui n'explique pas clairement le problème. Il serait plus judicieux que tout le code de gestion des erreurs se trouve au même endroit afin que les futurs mainteneurs n'aient qu'un seul endroit à consulter dans le code si la logique de gestion des erreurs doit être modifiée. Avoir tout le code de gestion des erreurs dans un seul endroit va aussi garantir que nous affichons des messages qui ont du sens pour les utilisateurs.

Corrigeons ces quatre problèmes en remaniant notre projet.

Séparation des tâches des projets de binaires

Le problème de l'organisation de la répartition des tâches multiples dans la fonction main est commun à de nombreux projets binaires. En conséquence, la communauté Rust a développé une procédure à utiliser comme ligne conductrice pour partager les tâches d'un programme binaire lorsque main commence à grossir. Le processus se décompose selon les étapes suivantes :

  • Diviser votre programme dans un main.rs et un lib.rs et déplacer la logique de votre programme dans lib.rs.
  • Tant que votre logique d'interprétation de la ligne de commande est peu volumineuse, elle peut rester dans le main.rs
  • Lorsque la logique d'interprétation de la ligne de commande commence à devenir compliquée, il faut la déplacer du main.rs vers le lib.rs.

Les fonctionnalités qui restent dans la fonction main après cette procédure seront les suivantes :

  • Appeler la logique d'interprétation de ligne de commande avec les valeurs des arguments
  • Régler toutes les autres configurations
  • Appeler une fonction run de lib.rs
  • Gérer l'erreur si run retourne une erreur

Cette structure permet de séparer les responsabilités : main.rs se charge de lancer le programme, et lib.rs renferme toute la logique des tâches à accomplir. Comme vous ne pouvez pas directement tester la fonction main, cette structure vous permet de tester toute la logique de votre programme en les déplaçant dans des fonctions dans lib.rs. Le seul code qui restera dans le main.rs sera suffisamment petit pour s'assurer qu'il soit correct en le lisant. Lançons-nous dans le remaniement de notre programme en suivant cette procédure.

Extraction de l'interpréteur des arguments

Nous allons déplacer la fonctionnalité de l'interprétation des arguments dans une fonction que main va appeler afin de préparer le déplacement de la logique de l'interpréteur dans src/lib.rs. L'encart 12-5 montre le nouveau début du main qui appelle une nouvelle fonction interpreter_config, que nous allons définir dans src/main.rs pour le moment.

Fichier : src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (recherche, nom_fichier) = interpreter_config(&args);

    // -- partie masquée ici --

    println!("On recherche : {}", recherche);
    println!("Dans le fichier : {}", nom_fichier);

    let contenu = fs::read_to_string(nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    println!("Dans le texte :\n{}", contenu);
}

fn interpreter_config(args: &[String]) -> (&str, &str) {
    let recherche = &args[1];
    let nom_fichier = &args[2];

    (recherche, nom_fichier)
}

Encart 12-5 : Extraction d'une fonction interpreter_config à partir de main

Nous continuons à récupérer les arguments de la ligne de commande dans un vecteur, mais au lieu d'assigner la valeur de l'argument d'indice 1 à la variable recherche et la valeur de l'argument d'indice 2 à la variable nom_fichier dans la fonction main, nous passons le vecteur entier à la fonction interpreter_config. La fonction interpreter_config renferme la logique qui détermine quel argument va dans quelle variable et renvoie les valeurs au main. Nous continuons à créer les variables recherche et nom_fichier dans le main, mais main n'a plus la responsabilité de déterminer quelles sont les variables qui correspondent aux arguments de la ligne de commande.

Ce remaniement peut sembler excessif pour notre petit programme, mais nous remanions de manière incrémentale par de petites étapes. Après avoir fait ces changements, lancez à nouveau le programme pour vérifier que l'envoi des arguments fonctionne toujours. C'est une bonne chose de vérifier souvent lorsque vous avancez, pour vous aider à mieux identifier les causes de problèmes lorsqu'ils apparaissent.

Grouper les valeurs de configuration

Nous pouvons appliquer une nouvelle petite étape pour améliorer la fonction interpreter_config. Pour le moment, nous retournons un tuple, mais ensuite nous divisons immédiatement ce tuple à nouveau en plusieurs éléments. C'est un signe que nous n'avons peut-être pas la bonne approche.

Un autre signe qui indique qu'il y a encore de la place pour de l'amélioration est la partie config de interpreter_config qui sous-entend que les deux valeurs que nous retournons sont liées et font partie d'une même valeur de configuration. Or, à ce stade, nous ne tenons pas compte de cela dans la structure des données que nous utilisons si ce n'est en regroupant les deux valeurs dans un tuple ; nous pourrions mettre les deux valeurs dans une seule structure et donner un nom significatif à chacun des champs de la structure. Faire ainsi permet de faciliter la compréhension du code par les futurs développeurs de ce code pour mettre en évidence le lien entre les deux valeurs et leurs rôles respectifs.

L'encart 12-6 montre les améliorations apportées à la fonction interpreter_config.

Fichier : src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = interpreter_config(&args);

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    let contenu = fs::read_to_string(config.nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    // -- partie masquée ici --

    println!("Dans le texte :\n{}", contenu);
}

struct Config {
    recherche: String,
    nom_fichier: String,
}

fn interpreter_config(args: &[String]) -> Config {
    let recherche = args[1].clone();
    let nom_fichier = args[2].clone();

    Config { recherche, nom_fichier }
}

Encart 12-6 : Remaniement de interpreter_config pour retourner une instance de la structure Config

Nous avons ajouté une structure Config qui a deux champs recherche et nom_fichier. La signature de interpreter_config indique maintenant qu'elle retourne une valeur Config. Dans le corps de interpreter_config, où nous retournions une slice de chaînes de caractères qui pointaient sur des valeurs String présentes dans args, nous définissons maintenant la structure Config pour contenir des valeurs String qu'elle possède. La variable args du main est la propriétaire des valeurs des arguments et permet uniquement à la fonction interpreter_config de les emprunter, ce qui signifie que nous violons les règles d'emprunt de Rust si Config essaye de prendre possession des valeurs provenant de args.

Nous pourrions gérer les données String de plusieurs manières, mais la façon la plus facile, bien que non optimisée, est d'appeler la méthode clone sur les valeurs. Cela va produire une copie complète des données pour que l'instance de Config puisse se les approprier, ce qui va prendre plus de temps et de mémoire que de stocker une référence vers les données de la chaîne de caractères. Cependant le clonage des données rend votre code très simple car nous n'avons pas à gérer les durées de vie des références ; dans ces circonstances, sacrifier un peu de performances pour gagner en simplicité est un compromis qui en vaut la peine.

Les contre-parties de l'utilisation de clone

Il y a une tendance chez les Rustacés de s'interdire l'utilisation de clone pour régler les problèmes d'appartenance à cause du coût à l'exécution. Dans le chapitre 13, vous allez apprendre à utiliser des méthodes plus efficaces dans ce genre de situation. Mais pour le moment, ce n'est pas un problème de copier quelques chaînes de caractères pour continuer à progresser car vous allez le faire une seule fois et les chaînes de caractères nom_fichier et recherche sont très courtes. Il est plus important d'avoir un programme fonctionnel qui n'est pas très optimisé plutôt que d'essayer d'optimiser à outrance le code dès sa première écriture. Plus vous deviendrez expérimenté en Rust, plus il sera facile de commencer par la solution la plus performante, mais pour le moment, il est parfaitement acceptable de faire appel à clone.

Nous avons actualisé main pour qu'il utilise l'instance de Config retournée par interpreter_config dans une variable config, et nous avons rafraîchi le code qui utilisait les variables séparées recherche et nom_fichier pour qu'il utilise maintenant les champs de la structure Config à la place.

Maintenant, notre code indique clairement que recherche et nom_fichier sont reliés et que leur but est de configurer le fonctionnement du programme. N'importe quel code qui utilise ces valeurs sait comment les retrouver dans les champs de l'instance config grâce à leurs noms donnés à cet effet.

Créer un constructeur pour Config

Pour l'instant, nous avons extrait la logique en charge d'interpréter les arguments de la ligne de commande à partir du main et nous l'avons placé dans la fonction interpreter_config. Cela nous a aidé à découvrir que les valeurs recherche et nom_fichier étaient liées et que ce lien devait être retranscrit dans notre code. Nous avons ensuite créé une structure Config afin de donner un nom au rôle apparenté à recherche et à nom_fichier, et pour pouvoir retourner les noms des valeurs sous la forme de noms de champs à partir de la fonction interpreter_config.

Maintenant que le but de la fonction interpreter_config est de créer une instance de Config, nous pouvons transformer interpreter_config d'une simple fonction à une fonction new qui est associée à la structure Config. Ce changement rendra le code plus familier. Habituellement, nous créons des instances de types de la bibliothèque standard, comme String, en appelant String::new. Si on change le interpreter_config en une fonction new associée à Config, nous pourrons créer de la même façon des instances de Config en appelant Config::new. L'encart 12-7 nous montre les changements que nous devons faire pour cela.

Fichier : src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    let contenu = fs::read_to_string(config.nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    println!("Dans le texte :\n{}", contenu);

    // -- partie masquée ici --
}

// -- partie masquée ici --

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Config { recherche, nom_fichier }
    }
}

Encart 12-7 : Transformer interpreter_config en Config::new

Nous avons actualisé le main où nous appelions interpreter_config pour appeler à la place le Config::new. Nous avons changé le nom de interpreter_config par new et nous l'avons déplacé dans un bloc impl, ce qui relie la fonction new à Config. Essayez à nouveau de compiler ce code pour vous assurer qu'il fonctionne.

Corriger la gestion des erreurs

Maintenant, nous allons nous pencher sur la correction de la gestion des erreurs. Rappellez-vous que la tentative d'accéder aux valeurs dans le vecteur args aux indices 1 ou 2 va faire paniquer le programme si le vecteur contient moins de trois éléments. Essayez de lancer le programme sans aucun argument ; cela donnera quelque chose comme ceci :

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

La ligne index out of bounds: the len is 1 but the index is 1 est un message d'erreur destiné aux développeurs. Il n'aidera pas nos utilisateurs finaux à comprendre ce qu'il s'est passé et ce qu'ils devraient faire à la place. Corrigeons cela dès maintenant.

Améliorer le message d'erreur

Dans l'encart 12-8, nous ajoutons une vérification dans la fonction new, qui va vérifier que le slice est suffisamment grand avant d'accéder aux indices 1 et 2. Si le slice n'est pas suffisamment grand, le programme va paniquer et afficher un meilleur message d'erreur que le message index out of bounds.

Fichier : src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    let contenu = fs::read_to_string(config.nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    println!("Dans le texte :\n{}", contenu);
}

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    // -- partie masquée ici --
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("il n'y a pas assez d'arguments");
        }
        // -- partie masquée ici --

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Config { recherche, nom_fichier }
    }
}

Encart 12-8 : Ajout d'une vérification du nombre d'arguments

Ce code est similaire à la fonction Supposition::new que nous avons écrit dans l'encart 9-13, dans laquelle nous appelions panic! lorsque l'argument valeur était hors de l'intervalle des valeurs valides. Plutôt que de vérifier un intervalle de valeurs dans le cas présent, nous vérifions que la taille de args est au moins de 3 et que le reste de la fonction puisse fonctionner en s'appuyant sur l'affirmation que cette condition a bien été remplie. Si args avait moins de trois éléments, cette fonction serait vraie, et nous appellerions alors la macro panic! pour mettre fin au programme immédiatement.

Avec ces quelques lignes de code en plus dans new, lançons le programme sans aucun argument à nouveau pour voir à quoi ressemble désormais l'erreur :

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'il n'y a pas assez d'arguments', src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Cette sortie est meilleure : nous avons maintenant un message d'erreur compréhensible. Cependant, nous avons aussi des informations superflues que nous ne souhaitons pas afficher à nos utilisateurs. Peut-être que la technique que nous avons utilisée dans l'encart 9-13 n'est pas la plus appropriée dans ce cas : un appel à panic! est plus approprié pour un problème de développement qu'un problème d'utilisation, comme nous l'avons appris au chapitre 9. A la place, nous pourrions utiliser une autre technique que vous avez apprise au chapitre 9 — retourner un Result qui indique si c'est un succès ou une erreur.

Retourner un Result à partir de new plutôt que d'appeler panic!

Nous pouvons à la place retourner une valeur Result qui contiendra une instance de Config dans le cas d'un succès et va décrire le problème dans le cas d'une erreur. Lorsque Config::new communiquera avec le main, nous pourrons utiliser le type de Result pour signaler où il y a un problème. Ensuite, nous pourrons changer le main pour convertir une variante de Err dans une erreur plus pratique pour nos utilisateurs sans avoir le texte à propos de thread 'main' et de RUST_BACKTRACE qui sont provoqués par l'appel à panic!.

L'encart 12-9 nous montre les changements que nous devons apporter à la valeur de retour de Config::new et le corps de la fonction pour pouvoir retourner un Result. Notez que cela ne va pas se compiler tant que nous ne corrigeons pas aussi le main, ce que nous allons faire dans le prochain encart.

Fichier : src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    let contenu = fs::read_to_string(config.nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    println!("Dans le texte :\n{}", contenu);
}

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

Encart 12-9 : Retourner un Result à partir de Config::new

Notre fonction new retourne désormais un Result contenant une instance de Config dans le cas d'un succès et une &'static str dans le cas d'une erreur. Nos valeurs d'erreur seront toujours des litéraux de chaîne de caractères qui ont la durée de vie 'static.

Nous avons fait deux changements dans le corps de notre fonction new : plutôt que d'avoir à appeler panic! lorsque l'utilisateur n'envoie pas assez d'arguments, nous retournons maintenant une valeur Err, et nous avons intégré la valeur de retour Config dans un Ok. Ces modifications rendent la fonction conforme à son nouveau type de signature.

Retourner une valeur Err à partir de Config::new permet à la fonction main de gérer la valeur Result retournée par la fonction new et de terminer plus proprement le processus dans le cas d'une erreur.

Appeler Config::new et gérer les erreurs

Pour gérer les cas d'erreurs et afficher un message correct pour l'utilisateur, nous devons mettre à jour main pour gérer le Result retourné par Config::new, comme dans l'encart 12-10. Nous allons aussi prendre la décision de quitter l'outil en ligne de commande avec un code d'erreur différent de zéro avec panic! et nous allons l'implémenter manuellement. Un statut de sortie différent de zéro est une convention pour signaler au processus qui a appelé notre programme que le programme s'est terminé dans un état d'erreur.

Fichier : src/main.rs

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    // -- partie masquée ici --

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    let contenu = fs::read_to_string(config.nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    println!("Dans le texte :\n{}", contenu);
}

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

Encart 12-10 : Quitter avec un code d'erreur si la création d'une nouvelle Config échoue.

Dans cet encart, nous avons utilisé une méthode que nous n'avons pas encore détaillée pour l'instant : unwrap_or_else, qui est définie sur Result<T, E> par la bibliothèque standard. L'utilisation de unwrap_or_else nous permet de définir une gestion des erreurs personnalisée, exempt de panic!. Si le Result est une valeur Ok, le comportement de cette méthode est similaire à unwrap : elle retourne la valeur à l'intérieur du Ok. Cependant, si la valeur est une valeur Err, cette méthode appelle le code dans la fermeture, qui est une fonction anonyme que nous définissons et passons en argument de unwrap_or_else. Nous verrons les fermetures plus en détail dans le chapitre 13. Pour l'instant, vous avez juste à savoir que le unwrap_or_else va passer la valeur interne du Err (qui dans ce cas est la chaîne de caractères statique "pas assez d'arguments" que nous avons ajoutée dans l'encart 12-9) à notre fermeture dans l'argument err qui est présent entre deux barres verticales. Le code dans la fermeture peut ensuite utiliser la valeur err lorsqu'il est exécuté.

Nous avons ajouté une nouvelle ligne use pour importer process dans la portée à partir de la bibliothèque standard. Le code dans la fermeture qui sera exécuté dans le cas d'une erreur fait uniquement deux lignes : nous affichons la valeur de err et nous appelons ensuite process::exit. La fonction process::exit va stopper le programme immédiatement et retourner le nombre qui lui a été donné en paramètre comme code de statut de sortie. C'est semblable à la gestion basée sur panic! que nous avons utilisée à l'encart 12-8, mais nous n'avons plus tout le texte en plus. Essayons cela :

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problème rencontré lors de l'interprétation des arguments : il n'y a pas assez d'arguments

Très bien ! Cette sortie est bien plus compréhensible pour nos utilisateurs.

Extraction de la logique du main

Maintenant que nous avons fini le remaniement de l'interprétation de la configuration, occupons-nous de la logique du programme. Comme nous l'avons dit dans “Séparation des tâches des projets de binaires”, nous allons extraire une fonction run qui va contenir toute la logique qui est actuellement dans la fonction main qui n'est pas liée au réglage de la configuration ou la gestion des erreurs. Lorsque nous aurons terminé, main sera plus concise et facile à vérifier en l'inspectant, et nous pourrons écrire des tests pour toutes les autres logiques.

L'encart 12-11 montre la fonction run extraite. Pour le moment, nous faisons des petites améliorations progressives pour extraire les fonctions. Nous continuons à définir la fonction dans src/main.rs.

Fichier : src/main.rs

use std::env;
use std::fs;
use std::process;

fn main() {
    // -- partie masquée ici --

    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    run(config);
}

fn run(config: Config) {
    let contenu = fs::read_to_string(config.nom_fichier)
        .expect("Quelque chose s'est mal passé lors de la lecture du fichier");

    println!("Dans le texte :\n{}", contenu);
}

// -- partie masquée ici --

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

Encart 12-11 : Extraction d'une fonction run qui contient le reste de la logique du programme

La fonction run contient maintenant toute la logique qui restait dans le main, en commençant par la lecture du fichier. La fonction run prend l'instance de Config en argument.

Retourner des erreurs avec la fonction run

Avec le restant de la logique du programme maintenant séparée dans la fonction run, nous pouvons améliorer la gestion des erreurs, comme nous l'avons fait avec Config::new dans l'encart 12-9. Plutôt que de permettre au programme de paniquer en appelant expect, la fonction run va retourner un Result<T, E> lorsque quelque chose se passe mal. Cela va nous permettre de consolider davantage la logique de gestion des erreurs dans le main pour qu'elle soit plus conviviale pour l'utilisateur. L'encart 12-12 montre les changements que nous devons appliquer à la signature et au corps du run.

Fichier : src/main.rs

use std::env;
use std::fs;
use std::process;
use std::error::Error;

// -- partie masquée ici --


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    println!("Dans le texte :\n{}", contenu);

    Ok(())
}

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

Encart 12-12 : Changer la fonction run pour retourner un Result

Nous avons fait trois changements significatifs ici. Premièrement, nous avons changé le type de retour de la fonction run en Result<(), Box<dyn Error>>. Cette fonction renvoyait précédemment le type unité, (), que nous gardons comme valeur de retour dans le cas de Ok.

En ce qui concerne le type d'erreur, nous avons utilisé l'objet trait Box<dyn Error> (et nous avons importé std::error::Error dans la portée avec une instruction use en haut). Nous allons voir les objets trait dans le chapitre 17. Pour l'instant, retenez juste que Box<dyn Error> signifie que la fonction va retourner un type qui implémente le trait Error, mais que nous n'avons pas à spécifier quel sera précisément le type de la valeur de retour. Cela nous donne la flexibilité de retourner des valeurs d'erreurs qui peuvent être de différents types dans différents cas d'erreurs. Le mot-clé dyn est un raccourci pour “dynamique”.

Deuxièmement, nous avons enlevé l'appel à expect pour privilégier l'opérateur ?, que nous avons vu dans le chapitre 9. Au lieu de faire un panic! sur une erreur, ? va retourner la valeur d'erreur de la fonction courante vers le code qui l'a appelé pour qu'il la gère.

Troisièmement, la fonction run retourne maintenant une valeur Ok dans les cas de succès. Nous avons déclaré dans la signature que le type de succès de la fonction run était (), ce qui signifie que nous avons enveloppé la valeur de type unité dans la valeur Ok. Cette syntaxe Ok(()) peut sembler un peu étrange au départ, mais utiliser () de cette manière est la façon idéale d'indiquer que nous appelons run uniquement pour ses effets de bord ; elle ne retourne pas de valeur dont nous pourrions avoir besoin.

Lorsque vous exécutez ce code, il va se compiler mais il va afficher un avertissement :

$ cargo run the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
On recherche : the
Dans le fichier : poem.txt
Dans le texte :
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust nous informe que notre code ignore la valeur Result et que cette valeur Result pourrait indiquer qu'une erreur s'est passée. Mais nous ne vérifions pas pour savoir si oui ou non il y a eu une erreur, et le compilateur nous rappelle que nous devrions avoir du code de gestion des erreurs ici ! Corrigeons dès à présent ce problème.

Gérer les erreurs retournées par run dans main

Nous allons vérifier les erreurs et les gérer en utilisant une technique similaire à celle que nous avons utilisée avec Config::new dans l'encart 12-10, mais avec une légère différence :

Fichier : src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // -- partie masquée ici --

    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    if let Err(e) = run(config) {
        println!("Erreur applicative : {}", e);

        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    println!("Dans le texte :\n{}", contenu);

    Ok(())
}

struct Config {
    recherche: String,
    nom_fichier: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

Nous utilisons if let plutôt que unwrap_or_else pour vérifier si run retourne un valeur Err et appeler process::exit(1) le cas échéant. La fonction run ne retourne pas de valeur sur laquelle nous aurions besoin d'utiliser unwrap comme avec le Config::new qui retournait une instance de Config. Comme run retourne () dans le cas d'un succès, nous nous préoccupons uniquement de détecter les erreurs, donc nous n'avons pas besoin de unwrap_or_else pour retourner la valeur extraite car elle sera toujours ().

Les corps du if let et de la fonction unwrap_or_else sont identiques dans les deux cas : nous affichons l'erreur et nous quittons.

Déplacer le code dans une crate de bibliothèque

Notre projet minigrep se présente plutôt bien pour le moment ! Maintenant, nous allons diviser notre fichier src/main.rs et déplacer du code dans le fichier src/lib.rs pour que nous puissions le tester et avoir un fichier src/main.rs qui héberge moins de fonctionnalités.

Déplaçons tout le code qui ne fait pas partie de la fonction main dans le src/main.rs vers le src/lib.rs :

  • La définition de la fonction run
  • Les instructions use correspondantes
  • La définition de Config
  • La définition de la fonction Config::new

Le contenu du src/lib.rs devrait contenir les signatures de l'encart 12-13 (nous avons enlevé les corps des fonctions pour des raisons de brièveté). Notez que cela ne va pas se compiler jusqu'à ce que nous modifions le src/main.rs dans l'encart 12-14.

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        // -- partie masquée ici --
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // -- partie masquée ici --
    let contenu = fs::read_to_string(config.nom_fichier)?;

    println!("Dans le texte :\n{}", contenu);

    Ok(())
}

Encart 12-13 : Déplacement de Config et de run dans src/lib.rs

Nous avons fait un usage généreux du mot-clé pub : sur Config, sur ses champs et sur la méthode new et enfin sur la fonction run. Nous avons maintenant une crate de bibliothèque qui a une API publique que nous pouvons tester !

Maintenant nous devons importer le code que nous avons déplacé dans src/lib.rs dans la portée de la crate binaire dans src/main.rs, comme dans l'encart 12-14.

Fichier : src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // -- partie masquée ici --
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    println!("On recherche : {}", config.recherche);
    println!("Dans le fichier : {}", config.nom_fichier);

    if let Err(e) = minigrep::run(config) {
        // -- partie masquée ici --
        println!("Erreur applicative : {}", e);

        process::exit(1);
    }
}

Encart 12-14 : Utilisation de la crate de bibliothèque minigrep dans src/main.rs

Nous avons ajouté une ligne use minigrep::Config pour importer le type Config de la crate de bibliothèque dans la portée de la crate binaire, et nous avons avons préfixé la fonction run avec le nom de notre crate. Maintenant, toutes les fonctionnalités devraient être connectées et devraient fonctionner. Lancez le programme avec cargo run pour vous assurer que tout fonctionne correctement.

Ouah ! C'était pas mal de travail, mais nous nous sommes organisés pour nous assurer le succès à venir. Maintenant il est bien plus facile de gérer les erreurs, et nous avons rendu le code plus modulaire. A partir de maintenant, l'essentiel de notre travail sera effectué dans src/lib.rs.

Profitons de cette nouvelle modularité en accomplissant quelque chose qui aurait été difficile à faire avec l'ancien code, mais qui est facile avec ce nouveau code : nous allons écrire des tests !