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

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. Au fur et à mesure que notre programme grossit, 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. Il est largement préférable 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 chemin_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 même message d’erreur pour tout, ce qui ne donne aucune information à l’utilisateur !

Quatrièmement, nous utilisons expect pour gérer une erreur, 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, de nombreux développeurs Rust trouvent utile de séparer les tâches d’un programme binaire lorsque main commence à grossir. Ce processus se décompose selon les étapes suivantes :

  • Diviser votre programme dans un fichier main.rs et un fichier 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 reste petite, elle peut rester dans la fonction main.
  • Lorsque la logique d’interprétation de la ligne de commande commence à devenir compliquée, il faut la déplacer de la fonction main vers d’autres fonctions ou types.

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 méthode 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 la déplaçant en-dehors de la fonction main. Le seul code qui restera dans la fonction mainsera 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. L’encart 12-5 montre le nouveau début de la fonction main qui appelle une nouvelle fonction interpreter_config, que nous allons définir dans src/main.rs.

Filename: src/main.rs
use std::env;
use std::fs;

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

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

    // -- partie masquée ici --

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

    let contenu = fs::read_to_string(chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

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

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

    (recherche, chemin_fichier)
}
Listing 12-5: Extracting a parse_config function from 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 chemin_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 chemin_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 ; à la place, nous mettrons les deux valeurs dans une seule structure et donnerons 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.

Filename: 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.chemin_fichier);

    let contenu = fs::read_to_string(config.chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

    // -- partie masquée ici --

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

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

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

    Config { recherche, chemin_fichier }
}
Listing 12-6: Refactoring parse_config to return an instance of a Config struct

Nous avons ajouté une structure Config qui a deux champs recherche et chemin_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.

Il y a plusieurs manières de gérer les données String, 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 contreparties 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 de son 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 chemin_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 chemin_fichier pour qu’il utilise maintenant les champs de la structure Config à la place.

Maintenant, notre code indique clairement que recherche et chemin_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 chemin_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 à chemin_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.

Filename: 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.chemin_fichier);

    let contenu = fs::read_to_string(config.chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

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

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

// -- partie masquée ici --

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

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

        Config { recherche, chemin_fichier }
    }
}
Listing 12-7: Changing parse_config into 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` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
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’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.

Filename: 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.chemin_fichier);

    let contenu = fs::read_to_string(config.chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

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

struct Config {
    recherche: String,
    chemin_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 chemin_fichier = args[2].clone();

        Config { recherche, chemin_fichier }
    }
}
Listing 12-8: Adding a check for the number of arguments

Ce code est similaire à la fonction Supposition::new que nous avons écrite 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 condition serait true, 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` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
il n'y a pas assez d'arguments
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. À 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 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. Nous allons aussi changer le nom de la fonction de new à build car de nombreux programmeurs s’attendent à ce que les fonctions new n’échouent jamais. Lorsque Config::build communiquera avec 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 montre les changements que nous devons apporter à la valeur de retour de la fonction que nous appelons maintenant Config::build, ainsi qu’au 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 main, ce que nous allons faire dans le prochain encart.

Filename: 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.chemin_fichier);

    let contenu = fs::read_to_string(config.chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

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

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

impl Config {
    fn build(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 chemin_fichier = args[2].clone();

        Ok(Config { recherche, chemin_fichier })
    }
}
Listing 12-9: Returning a Result from Config::build

Notre fonction build retourne un Result contenant une instance de Config dans le cas d’un succès et un littéral de chaîne de caractères dans le cas d’une erreur. Nos valeurs d’erreur seront toujours des litté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 : 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::build permet à la fonction main de gérer la valeur Result retournée par la fonction build et de terminer plus proprement le processus dans le cas d’une erreur.

Appeler Config::build 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::build, 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, à la place, 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.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

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

    let config = Config::build(&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.chemin_fichier);

    let contenu = fs::read_to_string(config.chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

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

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

impl Config {
    fn build(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 chemin_fichier = args[2].clone();

        Ok(Config { recherche, chemin_fichier })
    }
}
Listing 12-10: Exiting with an error code if building a Config fails

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, exempte 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` profile [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é, la fonction 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 les petites améliorations progressives pour extraire une fonction run.

Filename: 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::build(&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.chemin_fichier);

    run(config);
}

fn run(config: Config) {
    let contenu = fs::read_to_string(config.chemin_fichier)
        .expect("Aurait dû pouvoir lire le fichier");

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

// -- partie masquée ici --

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

impl Config {
    fn build(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 chemin_fichier = args[2].clone();

        Ok(Config { recherche, chemin_fichier })
    }
}
Listing 12-11: Extracting a run function containing the rest of the program logic

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 depuis 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::build 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 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.

Filename: 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::build(&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.chemin_fichier);

    run(config);
}

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

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

    Ok(())
}

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

impl Config {
    fn build(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 chemin_fichier = args[2].clone();

        Ok(Config { recherche, chemin_fichier })
    }
}
Listing 12-12: Changing the run function to return 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 18. 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 unavertissement :

$ 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: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [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::build 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::build(&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.chemin_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.chemin_fichier)?;

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

    Ok(())
}

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

impl Config {
    fn build(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 chemin_fichier = args[2].clone();

        Ok(Config { recherche, chemin_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::build 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, qui 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. Ainsi, nous pourrons le tester et avoir un fichier src/main.rs qui héberge moins de fonctionnalités.

Définissons le code chargé de la recherche de texte dans src/lib.rs plutôt que dans src/main.rs, ce qui nous permettra (ainsi qu’à toute autre personne utilisant notre bibliothèque minigrep) d’appeler la fonction de recherche depuis davantage de contextes que notre binaire minigrep.

Commençons par définir la signature de la fonction rechercher dans src/lib.rs, comme indiqué dans l’encart 12-13, avec un corps qui appelle la macro unimplemented!. Nous expliquerons cette signature plus en détail lorsque nous rédigerons l’implémentation.

Filename: src/lib.rs
pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: Defining the search function in src/lib.rs

Nous avons utilisé le mot-clé pub dans la définition de fonction pour indiquer que rechercher fait partie de l’API publique de notre crate de bibliothèque. Nous avons maintenant une crate de bibliothèque que nous pouvons utiliser et que nous pouvons tester !

Maintenant, nous devons importer le code défini dans src/lib.rs dans la portée de la crate binaire dans src/main.rs et l’appeller, comme montré dans l’encart 12-14.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// -- partie masquée ici --
use minigrep::rechercher;

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

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

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

// -- partie masquée ici --


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

impl Config {
    fn build(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 chemin_fichier = args[2].clone();

        Ok(Config { recherche, chemin_fichier })
    }
}

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

    for ligne in rechercher(&config.recherche, &contenu) {
        println!("{ligne}");
    }

    Ok(())
}
Listing 12-14: Using the minigrep library crate’s search function in src/main.rs

Nous avons ajouté une ligne use minigrep::Config pour apporter la fonction rechercher de la crate de bibliothèque dans la portée de la crate binaire. Ensuite, dans la fonction run, au lieu d’afficher le contenu du fichier, nous appelons la fonction rechercher en lui passant la valeur config.recherche et contenu comme arguments. Puis, run utilise une boucle for pour afficher chaque ligne retournée depuis rechercher qui correspond à la recherche. C’est aussi l’occasion d’enlever les appels à println! de la fonction main qui affichaient la recherche et le chemin du fichier, de sorte que notre programme n’affiche que les résultats de la recherche (si aucune erreur n’est survenue).

Notez que la fonction de recherche va collecter tous les résultats dans un vecteur qu’elle renvoie avant qu’aucun affichage n’arrive. Cette implémentation pourrait s’avérer lente à afficher les résultats quand elle traite de gros fichiers, parce que les résultats ne sont pas affichés au fur et à mesure qu’ils sont trouvés ; nous discuterons d’un moyen envisageable pour remédier à ceci en utilisant les itérateurs dans le chapitre 13.

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. À 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 !