Chapitre 522 minIntermédiaire
Gérer les erreurs : Option, Result et l'opérateur ?
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- distinguer une panique d'une erreur récupérable ;
- traiter un
Optionet unResultsansunwrap; - propager une erreur avec l'opérateur
?; - écrire son propre type d'erreur avec
DisplayetFrom.
4 exercices corrigés1 schéma
Rust n'a ni exceptions ni null. Une valeur qui peut manquer a le type Option<T>, et une opération qui peut échouer renvoie un Result<T, E> : soit Ok avec le résultat, soit Err avec la cause de l'échec. Le compilateur t'oblige à traiter les deux cas, et l'opérateur ? rend ce traitement aussi court qu'un try/catch. Ce chapitre apprend à lire et à écrire ce code, jusqu'à créer le type d'erreur de l'outil parc.
Deux sortes d'erreurs : panique ou Result
Panique (panic!) | Result<T, E> | |
|---|---|---|
| Pour quoi | Un bug : un cas qui ne devrait jamais arriver | Un échec prévisible : fichier absent, saisie invalide, réseau coupé |
| Effet | Le fil d'exécution s'arrête, avec un message | La fonction renvoie Err(…), l'appelant décide quoi faire |
| Exemples | Indice hors d'un tableau, dépassement d'entier en mode debug, unwrap() sur None | "abc".parse::<u32>(), fs::read_to_string("parc.csv") |
Dans C# ou JavaScript, ces deux situations passent toutes deux par des exceptions, et rien dans la signature d'une fonction ne dit qu'elle peut échouer. En Rust, le type de retour l'annonce : une fonction qui renvoie Result<Poste, ErreurParc> peut échouer, et l'appelant ne peut pas l'ignorer.
Result : Ok ou Err
Result est une enum à deux variantes, comme Option :
enum Result<T, E> {
Ok(T), // succès, avec la valeur
Err(E), // échec, avec l'erreur
}
La méthode parse en renvoie un. Convertir une saisie en nombre peut échouer, et on traite les deux cas avec match :
fn main() {
for saisie in ["89900", "899,00", "-5", ""] {
let resultat: Result<u32, std::num::ParseIntError> = saisie.parse();
match resultat {
Ok(prix) => println!("« {saisie} » → {prix} centimes"),
Err(e) => println!("« {saisie} » refusé : {e}"),
}
}
}
« 89900 » → 89900 centimes
« 899,00 » refusé : invalid digit found in string
« -5 » refusé : invalid digit found in string
« » refusé : cannot parse integer from empty string
Le -5 est refusé parce qu'un u32 ne peut pas être négatif : le type demandé fait partie de la validation.
unwrap et expect : quand les utiliser
unwrap() extrait la valeur d'un Ok (ou d'un Some), et panique sur un Err (ou un None). expect("message") fait la même chose avec ton message :
fn main() {
let saisie = "899,00";
let prix: u32 = saisie.parse().expect("le prix doit être un nombre de centimes");
println!("{prix}");
}
thread 'main' (13771) panicked at src/main.rs:3:36:
le prix doit être un nombre de centimes: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Les méthodes utiles d'Option
Beaucoup de fonctions de la bibliothèque standard renvoient un Option. Chercher un élément, par exemple, peut ne rien trouver :
fn main() {
let numeros = ["PC-0001", "PC-0002", "PC-0007"];
let trouve: Option<&&str> = numeros.iter().find(|n| n.ends_with("07"));
println!("{trouve:?}");
let absent = numeros.iter().find(|n| n.ends_with("99"));
println!("{absent:?}");
// Valeur par défaut, transformation, conversion en Result
let nom: Option<&str> = None;
println!("{}", nom.unwrap_or("personne"));
println!("{:?}", Some("julie").map(|n| n.to_uppercase()));
let r: Result<&str, String> = nom.ok_or(String::from("poste non affecté"));
println!("{r:?}");
// get renvoie une Option au lieu de paniquer
println!("{:?} {:?}", numeros.get(1), numeros.get(10));
}
Some("PC-0007")
None
personne
Some("JULIE")
Err("poste non affecté")
Some("PC-0002") None
| Méthode | Sur Some(v) | Sur None |
|---|---|---|
is_some(), is_none() | true, false | false, true |
unwrap_or(defaut) | v | defaut |
unwrap_or_default() | v | La valeur par défaut du type : 0, chaîne vide… |
map(f) | Some(f(v)) | None |
ok_or(erreur) | Ok(v) | Err(erreur) |
is_some_and(condition) | condition(v) | false |
Result offre les mêmes : unwrap_or, map, map_err (qui transforme l'erreur), ok() (qui le convertit en Option), is_ok(), is_err(). La notation |n| n.ends_with("07") est une fermeture (closure), une petite fonction anonyme, comme n => n.endsWith("07") en JavaScript ; le chapitre 6 les détaille.
L'opérateur ? : propager une erreur
Le plus souvent, une fonction qui rencontre une erreur ne sait pas quoi en faire : c'est à l'appelant de décider. Elle doit donc renvoyer l'erreur. L'opérateur ?, placé après un Result, fait exactement cela : si c'est Ok, il donne la valeur ; si c'est Err, il quitte la fonction en renvoyant l'erreur.
use std::num::ParseIntError;
/// « 89900;2023 » → (89900, 2023).
fn lire_prix_et_annee(ligne: &str) -> Result<(u32, i32), ParseIntError> {
let (prix, annee) = ligne.split_once(';').unwrap_or((ligne, ""));
let prix: u32 = prix.trim().parse()?; // en cas d'erreur, on la renvoie tout de suite
let annee: i32 = annee.trim().parse()?;
Ok((prix, annee))
}
// La même fonction sans ?, pour voir ce qu'il fait à ta place
fn lire_prix_et_annee_long(ligne: &str) -> Result<(u32, i32), ParseIntError> {
let (prix, annee) = ligne.split_once(';').unwrap_or((ligne, ""));
let prix: u32 = match prix.trim().parse() {
Ok(valeur) => valeur,
Err(e) => return Err(e),
};
let annee: i32 = match annee.trim().parse() {
Ok(valeur) => valeur,
Err(e) => return Err(e),
};
Ok((prix, annee))
}
fn main() {
println!("{:?}", lire_prix_et_annee("89900;2023"));
println!("{:?}", lire_prix_et_annee("89900;deux mille"));
println!("{:?}", lire_prix_et_annee_long("89900"));
}
Ok((89900, 2023))
Err(ParseIntError { kind: InvalidDigit })
Err(ParseIntError { kind: Empty })
split_once(';') coupe le texte au premier point-virgule et renvoie un Option des deux morceaux ; s'il n'y en a pas, unwrap_or garde la ligne entière et une année vide, que parse refuse ensuite.
Créer son propre type d'erreur
Une vraie application rencontre plusieurs sortes d'erreurs : une ligne incomplète, un type de poste inconnu, un prix illisible. Le plus clair est de les regrouper dans une enum d'erreur propre à l'application :
use std::fmt;
#[derive(Debug)]
enum ErreurParc {
NombreDeChamps(usize),
TypeInconnu(String),
NombreInvalide(String),
}
impl fmt::Display for ErreurParc {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ErreurParc::NombreDeChamps(n) => write!(f, "4 champs attendus, {n} trouvés"),
ErreurParc::TypeInconnu(t) => write!(f, "type de poste inconnu : « {t} »"),
ErreurParc::NombreInvalide(n) => write!(f, "nombre invalide : « {n} »"),
}
}
}
impl std::error::Error for ErreurParc {}
impl fmt::Displaydéfinit le message lisible de chaque erreur, celui que verra l'utilisateur. Tout type qui implémenteDisplays'affiche avec{}et possède une méthodeto_string().impl std::error::Errordéclare que c'est une erreur au sens de la bibliothèque standard, ce qui permet de la combiner avec les autres. Le bloc est vide : les méthodes ont des versions par défaut.- Ces
impl … forimplémentent des traits, l'équivalent des interfaces, vus au chapitre 7.
On peut alors lire une ligne du fichier CSV en un poste, chaque étape pouvant échouer :
#[derive(Debug, Clone, Copy, PartialEq)]
enum TypePoste {
Fixe,
Portable,
Serveur,
}
#[derive(Debug)]
struct Poste {
numero: String,
type_poste: TypePoste,
prix_ht: u32,
annee_achat: i32,
}
fn lire_type(texte: &str) -> Result<TypePoste, ErreurParc> {
match texte {
"fixe" => Ok(TypePoste::Fixe),
"portable" => Ok(TypePoste::Portable),
"serveur" => Ok(TypePoste::Serveur),
autre => Err(ErreurParc::TypeInconnu(autre.to_string())),
}
}
/// « PC-0001;portable;89900;2023 » → Poste.
fn lire_ligne(ligne: &str) -> Result<Poste, ErreurParc> {
let champs: Vec<&str> = ligne.split(';').collect();
if champs.len() != 4 {
return Err(ErreurParc::NombreDeChamps(champs.len()));
}
Ok(Poste {
numero: champs[0].to_string(),
type_poste: lire_type(champs[1])?,
prix_ht: champs[2]
.parse()
.map_err(|_| ErreurParc::NombreInvalide(champs[2].to_string()))?,
annee_achat: champs[3]
.parse()
.map_err(|_| ErreurParc::NombreInvalide(champs[3].to_string()))?,
})
}
fn main() {
let lignes = [
"PC-0001;portable;89900;2023",
"PC-0002;tablette;45000;2024",
"PC-0003;fixe;649,00;2020",
"PC-0004;fixe",
];
for ligne in lignes {
match lire_ligne(ligne) {
Ok(p) => println!("OK : {} {:?} {} {}", p.numero, p.type_poste, p.prix_ht, p.annee_achat),
Err(e) => println!("Erreur : {e}"),
}
}
}
OK : PC-0001 Portable 89900 2023
Erreur : type de poste inconnu : « tablette »
Erreur : nombre invalide : « 649,00 »
Erreur : 4 champs attendus, 2 trouvés
Deux techniques à retenir :
map_errtransforme l'erreur deparse(unParseIntError, en anglais et sans le texte fautif) en uneErreurParcplus parlante, avant le?.- Le motif
autre =>danslire_typecapture toute autre valeur dans une variable, pour la citer dans le message d'erreur.
split(';') découpe la ligne, et collect() rassemble les morceaux dans un Vec<&str> : le chapitre 6 explique ces deux méthodes.
Convertir automatiquement avec From
Quand une fonction renvoie Result<_, ErreurParc> et qu'elle lit un fichier, l'erreur de lecture est un std::io::Error, pas une ErreurParc. Plutôt qu'un map_err à chaque lecture, on apprend à Rust à convertir l'une en l'autre en implémentant le trait From :
impl From<std::io::Error> for ErreurParc {
fn from(e: std::io::Error) -> Self {
ErreurParc::Fichier(e)
}
}
(Avec une variante Fichier(std::io::Error) ajoutée à l'enum.) Désormais, ? sur un io::Error le convertit tout seul : let contenu = fs::read_to_string(chemin)?; fonctionne dans une fonction qui renvoie Result<_, ErreurParc>. Le chapitre 8 utilise cette conversion dans le module d'erreurs du projet.
main peut renvoyer un Result
main peut, elle aussi, renvoyer un Result, ce qui permet d'y utiliser ? :
use std::error::Error;
use std::fs;
fn compter_postes(chemin: &str) -> Result<usize, std::io::Error> {
let contenu = fs::read_to_string(chemin)?;
Ok(contenu.lines().skip(1).filter(|l| !l.trim().is_empty()).count())
}
fn main() -> Result<(), Box<dyn Error>> {
let nombre = compter_postes("parc-introuvable.csv")?;
println!("{nombre} postes");
Ok(())
}
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Le programme se termine avec le code de sortie 1, que les scripts et les pipelines CI savent détecter. Box<dyn Error> signifie « n'importe quelle erreur » : pratique pour un petit programme. Mais le message affiché est la forme de débogage de l'erreur, peu lisible pour un utilisateur. L'outil parc du chapitre 8 fait mieux : main y affiche le message Display de l'erreur et choisit le code de sortie.
Exercices
Exercice 1 sur 4 Panique ou Result ? Réussi À revoir
Pour chaque situation de l'outil parc, dis si tu utilises une panique (expect) ou un Result, et pourquoi.
- Le fichier
parc.csvn'existe pas. - L'utilisateur tape une année « deux mille vingt ».
- Dans un test, tu lis une ligne que tu as écrite toi-même, forcément valide.
- Le prix total des postes dépasse la capacité d'un
u64.
Voir la réponseMasquer la réponse de l'exercice 1
Result: c'est un cas prévisible (premier lancement, mauvais dossier). Le projet du chapitre 8 en fait même un parc vide.Result: une saisie invalide est normale, il faut un message clair et pas un plantage.unwrapouexpectest acceptable : si la ligne est invalide, c'est le test lui-même qui est faux, et la panique fait échouer le test, ce qui est voulu.- En pratique impossible (plus de 184 millions de milliards d'euros) : une panique serait justifiable. Mais
checked_add, qui renvoie unOption, coûte peu et rend le programme robuste à des données corrompues.
Tu avais trouvé ?
Exercice 2 sur 4 Remplacer unwrap Réussi À revoir
Réécris cette fonction pour qu'elle ne puisse plus paniquer. Elle doit renvoyer Result<u32, String>, avec le message « quantité invalide : X » si la saisie n'est pas un nombre.
fn lire_quantite(saisie: &str) -> u32 {
saisie.trim().parse().unwrap()
}
Voir la réponseMasquer la réponse de l'exercice 2
fn lire_quantite(saisie: &str) -> Result<u32, String> {
saisie
.trim()
.parse()
.map_err(|_| format!("quantité invalide : {saisie}"))
}
fn main() {
println!("{:?}", lire_quantite(" 12 "));
println!("{:?}", lire_quantite("douze"));
}
Ok(12)
Err("quantité invalide : douze")
Le type de retour Result<u32, String> suffit à indiquer à parse qu'il doit produire un u32.
Tu avais trouvé ?
Exercice 3 sur 4 Propager avec ? Réussi À revoir
Écris fn prix_moyen(saisies: &[&str]) -> Result<u32, ParseIntError> qui convertit chaque saisie en u32 avec ? et renvoie la moyenne entière. Que renvoie-t-elle pour ["89900", "64900", "72900"] ? Pour ["89900", "abc"] ? Que se passe-t-il pour une liste vide, et comment l'éviter ?
Voir la réponseMasquer la réponse de l'exercice 3
use std::num::ParseIntError;
fn prix_moyen(saisies: &[&str]) -> Result<u32, ParseIntError> {
let mut total: u64 = 0;
for saisie in saisies {
let prix: u32 = saisie.parse()?;
total += u64::from(prix);
}
Ok((total / saisies.len() as u64) as u32)
}
fn main() {
println!("{:?}", prix_moyen(&["89900", "64900", "72900"]));
println!("{:?}", prix_moyen(&["89900", "abc"]));
}
Ok(75900)
Err(ParseIntError { kind: InvalidDigit })
(89 900 + 64 900 + 72 900) / 3 = 227 700 / 3 = 75 900. Avec "abc", le ? renvoie l'erreur dès la deuxième saisie. Avec une liste vide, total / 0 est une division par zéro : le programme panique (attempt to divide by zero). Pour l'éviter, teste if saisies.is_empty() au début, ou renvoie un Option : Result<Option<u32>, ParseIntError>.
Tu avais trouvé ?
Exercice 4 sur 4 Lire un message d'erreur Réussi À revoir
Le programme de la section « Créer son propre type d'erreur » reçoit la ligne PC-0009;serveur;349000. Quel message affiche-t-il ? Et pour PC-0010;Portable;99900;2025 ? Propose une correction pour le second cas.
Voir la réponseMasquer la réponse de l'exercice 4
PC-0009;serveur;349000n'a que 3 champs : « Erreur : 4 champs attendus, 3 trouvés ».PC-0010;Portable;99900;2025: « Erreur : type de poste inconnu : « Portable » », car la comparaison respecte la casse. Correction : comparer en minuscules,match texte.trim().to_lowercase().as_str() { … }. C'est ce que fait le projet du chapitre 8.as_str()est nécessaire carto_lowercase()renvoie uneString, et les motifs"fixe"… sont des&str.
Tu avais trouvé ?