Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 Option et un Result sans unwrap ;
  • propager une erreur avec l'opérateur ? ;
  • écrire son propre type d'erreur avec Display et From.

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 quoiUn bug : un cas qui ne devrait jamais arriverUn échec prévisible : fichier absent, saisie invalide, réseau coupé
EffetLe fil d'exécution s'arrête, avec un messageLa fonction renvoie Err(…), l'appelant décide quoi faire
ExemplesIndice 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 :

rust
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 :

rust
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}"),
        }
    }
}
text
« 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 :

rust
fn main() {
    let saisie = "899,00";
    let prix: u32 = saisie.parse().expect("le prix doit être un nombre de centimes");
    println!("{prix}");
}
text
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 :

rust
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));
}
text
Some("PC-0007")
None
personne
Some("JULIE")
Err("poste non affecté")
Some("PC-0002") None
MéthodeSur Some(v)Sur None
is_some(), is_none()true, falsefalse, true
unwrap_or(defaut)vdefaut
unwrap_or_default()vLa 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.

rust
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"));
}
text
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.

appelsl'erreur remontemain()executer(cli)charger(chemin)lire_parc(contenu)lire_ligne(ligne)"649,00".parse()→ Err?renvoyée?Erraffiche l'erreur,quitte : code 1map_err : le nombredevient ErreurParcmap_err : ajoutele numéro de lignelit le fichierligne par lignechamp par champ
L'opérateur ? renvoie l'erreur à l'appelant, étage par étage, jusqu'à la fonction qui sait la traiter

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 :

rust
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::Display définit le message lisible de chaque erreur, celui que verra l'utilisateur. Tout type qui implémente Display s'affiche avec {} et possède une méthode to_string().
  • impl std::error::Error dé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 … for implé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 :

rust
#[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}"),
        }
    }
}
text
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_err transforme l'erreur de parse (un ParseIntError, en anglais et sans le texte fautif) en une ErreurParc plus parlante, avant le ?.
  • Le motif autre => dans lire_type capture 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 :

rust
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 ? :

rust
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(())
}
text
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.

  1. Le fichier parc.csv n'existe pas.
  2. L'utilisateur tape une année « deux mille vingt ».
  3. Dans un test, tu lis une ligne que tu as écrite toi-même, forcément valide.
  4. Le prix total des postes dépasse la capacité d'un u64.
Voir la réponseMasquer la réponse de l'exercice 1
  1. Result : c'est un cas prévisible (premier lancement, mauvais dossier). Le projet du chapitre 8 en fait même un parc vide.
  2. Result : une saisie invalide est normale, il faut un message clair et pas un plantage.
  3. unwrap ou expect est 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.
  4. En pratique impossible (plus de 184 millions de milliards d'euros) : une panique serait justifiable. Mais checked_add, qui renvoie un Option, 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.

rust
fn lire_quantite(saisie: &str) -> u32 {
    saisie.trim().parse().unwrap()
}
Voir la réponseMasquer la réponse de l'exercice 2
rust
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"));
}
text
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
rust
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"]));
}
text
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;349000 n'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 car to_lowercase() renvoie une String, et les motifs "fixe"… sont des &str.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Vec, String, HashMap et itérateursRevoir le sommaire du cours