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)
}
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 }
}
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èresnom_fichier
etrecherche
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 }
}
}
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 }
}
}
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 })
}
}
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 })
}
}
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 })
}
}
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 })
}
}
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(())
}
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);
}
}
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 !