Tutoriels gratuits et ouverts à toutes et à tous · conçus au Lycée Chevrollier — AngersÀ propos

Série « Parcours Rust » · partie 5 / 8

Rust niveau 4 — Erreurs et collections

Traiter les échecs avec Result, stocker des données avec Vec, String et HashMap : niveau 4 du parcours Rust, avec exercices corrigés et QCM.

Développement✎ B. Jousseaume📅 17 octobre 2026⏱ 6 h📶 Intermédiaire

1. Deux familles d’erreurs

panic!Result<T, E>
NatureBug, situation impossibleÉchec prévisible : fichier absent, saisie invalide, réseau coupé
EffetArrêt du programme (du fil d’exécution)Une valeur que l’appelant doit traiter
ExemplesIndice hors limites, unwrap() sur None"abc".parse::<i32>(), File::open(…)
enum Result<T, E> {   // déjà définie dans la bibliothèque standard
    Ok(T),            // succès, avec la valeur produite
    Err(E),           // échec, avec la description de l'erreur
}
fn main() {
    let essais = ["42", "-7", "abc", ""];
    for texte in essais {
        match texte.parse::<i32>() {
            Ok(n) => println!("« {texte} » → {n}"),
            Err(e) => println!("« {texte} » → erreur : {e}"),
        }
    }
}

Comme Option, un Result oblige l’appelant à regarder ce qui s’est passé : impossible d’utiliser la valeur sans avoir traité l’erreur. C’est la grande différence avec les exceptions, qui peuvent être oubliées.

2. Renvoyer et propager des erreurs

fn lire_age(texte: &str) -> Result<u8, String> {
    let age: u8 = match texte.trim().parse() {
        Ok(a) => a,
        Err(_) => return Err(format!("« {texte} » n'est pas un nombre entre 0 et 255")),
    };
    if !(15..=99).contains(&age) {
        return Err(format!("âge {age} hors de la plage 15 à 99"));
    }
    Ok(age)
}

fn main() {
    for t in ["19", "7", "abc", "300"] {
        match lire_age(t) {
            Ok(a) => println!("âge accepté : {a}"),
            Err(msg) => println!("refusé : {msg}"),
        }
    }
}

L’opérateur ?

Écrire un match à chaque appel devient vite lourd. L’opérateur ? fait la même chose en un caractère : si c’est Ok, prends la valeur ; si c’est Err, renvoie immédiatement l’erreur à l’appelant.

use std::num::ParseIntError;

fn somme(a: &str, b: &str) -> Result<i32, ParseIntError> {
    let x: i32 = a.trim().parse()?;
    let y: i32 = b.trim().parse()?;
    Ok(x + y)
}

fn main() {
    println!("{:?}", somme("12", "30"));    // Ok(42)
    println!("{:?}", somme("12", "trente"));// Err(ParseIntError { kind: InvalidDigit })
}

? ne s’utilise que dans une fonction qui renvoie elle-même un Result (ou un Option, pour les Option). Même main peut renvoyer un Result :

use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let n: i32 = "42".parse()?;
    println!("{n}");
    Ok(())
}

Box<dyn Error> signifie « n’importe quel type d’erreur » : pratique dans main et dans les petits programmes.

ÉcritureQuand l’utiliser
match sur le résultatPour traiter chaque cas différemment
?Pour laisser l’appelant décider
.unwrap_or(v) / .unwrap_or_else(|e| …)Pour une valeur de repli
.map_err(|e| …)Pour transformer l’erreur en une autre, plus parlante
.ok()Pour convertir en Option en ignorant la cause
.expect("…")Seulement si l’échec est un bug : arrête le programme

3. Ses propres types d’erreur

Un texte d’erreur suffit pour un exemple. Dans une vraie application, l’appelant a besoin de distinguer les erreurs pour réagir différemment : on les décrit par une énumération.

use std::fmt;

#[derive(Debug, PartialEq)]
enum ErreurNote {
    Vide,
    PasUnNombre(String),
    HorsBornes(f64),
}

impl fmt::Display for ErreurNote {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ErreurNote::Vide => write!(f, "note manquante"),
            ErreurNote::PasUnNombre(t) => write!(f, "« {t} » n'est pas une note"),
            ErreurNote::HorsBornes(n) => write!(f, "{n} n'est pas entre 0 et 20"),
        }
    }
}

impl std::error::Error for ErreurNote {}

fn lire_note(texte: &str) -> Result<f64, ErreurNote> {
    let t = texte.trim().replace(',', ".");        // accepte 12,5 comme 12.5
    if t.is_empty() {
        return Err(ErreurNote::Vide);
    }
    let n: f64 = t.parse().map_err(|_| ErreurNote::PasUnNombre(texte.to_string()))?;
    if !(0.0..=20.0).contains(&n) {
        return Err(ErreurNote::HorsBornes(n));
    }
    Ok(n)
}

fn main() {
    for t in ["14", "12,5", "", "vingt", "21"] {
        match lire_note(t) {
            Ok(n) => println!("{n}"),
            Err(e) => println!("erreur : {e}"),
        }
    }
    assert_eq!(lire_note("25"), Err(ErreurNote::HorsBornes(25.0)));
}
  • Implémenter Display fournit le message destiné à l’utilisateur ({}).
  • Implémenter std::error::Error rend le type compatible avec Box<dyn Error> et avec l’écosystème.
  • Les traits, dont Display est un exemple, sont l’objet du niveau 5 : pour l’instant, recopie ce modèle.

4. Les vecteurs : Vec<T>

Un vecteur est une liste de valeurs du même type, qui peut grandir.

fn main() {
    let mut notes: Vec<f64> = Vec::new();
    notes.push(12.0);
    notes.push(15.5);
    notes.push(9.0);
    let autres = vec![17.0, 11.0];          // macro de création
    notes.extend(&autres);

    println!("{} notes : {:?}", notes.len(), notes);
    println!("première : {}", notes[0]);    // arrêt si l'indice n'existe pas
    println!("dixième : {:?}", notes.get(9)); // None : accès sûr

    notes.sort_by(|a, b| a.partial_cmp(b).unwrap());
    println!("triées : {:?}", notes);
    notes.retain(|n| *n >= 10.0);           // garde seulement ce qui vérifie la condition
    println!("≥ 10 : {:?}", notes);

    if let Some(derniere) = notes.pop() {    // retire et renvoie le dernier élément
        println!("retirée : {derniere}");
    }
    for n in &notes {                        // parcours sans déplacer le vecteur
        print!("{n} ");
    }
    println!();
    for n in notes.iter_mut() {              // parcours avec modification
        *n += 1.0;
    }
    println!("bonus : {:?}", notes);
}
MéthodeEffet
push(x) / pop()Ajouter à la fin / retirer le dernier (renvoie Option)
v[i] / v.get(i)Lire (arrêt si hors limites) / lire en Option
len(), is_empty()Taille, test de vide
contains(&x)Présence d’une valeur
sort(), sort_by(…), dedup()Trier, retirer les doublons consécutifs
retain(|x| …)Ne garder que les éléments qui vérifient une condition
insert(i, x), remove(i)Insérer ou retirer à une position

5. Les textes, en détail

fn main() {
    let mut s = String::from("Lycée");
    s.push_str(" Chevrollier");
    let complet = format!("{s} - Angers");          // format! ne déplace rien
    let concat = s.clone() + " (49)";               // + prend possession du premier texte

    println!("{complet} / {concat}");
    println!("octets : {}, caractères : {}", s.len(), s.chars().count());
    println!("{}", s.to_uppercase());
    println!("{:?}", s.find("Chev"));                // Some(7) : position en octets
    println!("{}", s.replace("Lycée", "LP"));
    println!("{}", "  espaces  ".trim());
    println!("{:?}", "a;b;;c".split(';').collect::<Vec<&str>>());
    println!("{}", s.starts_with("Ly"));

    for (i, c) in "été".char_indices() {
        println!("octet {i} : {c}");
    }
}

6. Les tables : HashMap<K, V>

Une table de hachage associe des clés uniques à des valeurs.

use std::collections::HashMap;

fn main() {
    let mut stock: HashMap<String, u32> = HashMap::new();
    stock.insert(String::from("clavier"), 12);
    stock.insert(String::from("souris"), 30);
    stock.insert(String::from("clavier"), 15);        // remplace l'ancienne valeur

    match stock.get("écran") {
        Some(q) => println!("écrans : {q}"),
        None => println!("aucun écran référencé"),
    }

    // entry : insérer si absent, puis modifier
    *stock.entry(String::from("écran")).or_insert(0) += 4;
    *stock.entry(String::from("souris")).or_insert(0) -= 5;

    // une HashMap n'a pas d'ordre : on trie pour afficher
    let mut articles: Vec<_> = stock.iter().collect();
    articles.sort();
    for (article, quantite) in articles {
        println!("{article:<10} {quantite:>4}");
    }
    println!("{} références", stock.len());
}

Le motif *table.entry(clé).or_insert(0) += 1 est le moyen de compter des occurrences : mots d’un texte, adresses IP d’un journal, notes par élève.

7. Lire et écrire des fichiers

use std::fs;
use std::io::{self, BufRead, BufReader, Write};

fn main() -> io::Result<()> {
    // écrire tout un fichier
    fs::write("notes.txt", "Lina;12\nSam;15\nNoé;9\n")?;

    // lire tout un fichier (petits fichiers)
    let contenu = fs::read_to_string("notes.txt")?;
    println!("{} lignes", contenu.lines().count());

    // lire ligne par ligne (gros fichiers : la mémoire reste faible)
    let fichier = fs::File::open("notes.txt")?;
    for (numero, ligne) in BufReader::new(fichier).lines().enumerate() {
        let ligne = ligne?;
        println!("{:>3} | {ligne}", numero + 1);
    }

    // ajouter à la fin
    let mut f = fs::OpenOptions::new().append(true).open("notes.txt")?;
    writeln!(f, "Zoé;17")?;
    Ok(())
}

Un fichier peut ne pas exister, être protégé, ou disparaître pendant la lecture : chaque opération renvoie un Result, propagé ici par ?.

8. Mini-projet : le bulletin de notes

9. Bilan

Exercices corrigés

La question à se poser devant chaque ligne de ces corrigés : que se passe-t-il si la donnée est fausse ? Un bon traitement d’erreurs se reconnaît à ce qu’aucune entrée, même absurde, ne fait planter le programme.

Exercice 4.1 — Valider une adresse IPv4

Voir le corrigé
use std::fmt;

#[derive(Debug, PartialEq)]
enum ErreurIp {
    MauvaisNombreDeParties(usize),
    OctetInvalide(String),
}

impl fmt::Display for ErreurIp {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ErreurIp::MauvaisNombreDeParties(n) => write!(f, "4 nombres attendus, {n} trouvé(s)"),
            ErreurIp::OctetInvalide(t) => write!(f, "« {t} » n'est pas un nombre de 0 à 255"),
        }
    }
}

fn lire_ipv4(texte: &str) -> Result<[u8; 4], ErreurIp> {
    let parties: Vec<&str> = texte.trim().split('.').collect();
    if parties.len() != 4 {
        return Err(ErreurIp::MauvaisNombreDeParties(parties.len()));
    }
    let mut octets = [0u8; 4];
    for (i, p) in parties.iter().enumerate() {
        octets[i] = p.parse().map_err(|_| ErreurIp::OctetInvalide(p.to_string()))?;
    }
    Ok(octets)
}

fn main() {
    for t in ["192.168.1.10", "10.0.0.1", "192.168.1", "192.168.1.300", "192.168.a.1", "", "1.2.3.4.5", "01.2.3.4"] {
        match lire_ipv4(t) {
            Ok(ip) => println!("{t:<16} → {:?}", ip),
            Err(e) => println!("{t:<16} → erreur : {e}"),
        }
    }
    assert_eq!(lire_ipv4("192.168.1"), Err(ErreurIp::MauvaisNombreDeParties(3)));
}

Explication

Le type u8 fait la moitié du travail : parse::<u8>() refuse d’office les négatifs, les nombres supérieurs à 255 et le texte. La chaîne vide produit une seule partie vide, donc l’erreur MauvaisNombreDeParties(1).

Pour aller plus loin

La bibliothèque standard fournit std::net::Ipv4Addr, qui fait exactement ce travail : "192.168.1.10".parse::<Ipv4Addr>(). Dans un vrai projet, on l’utilise plutôt que d’écrire son propre analyseur.

Exercice 4.2 — Statistiques

Voir le corrigé
fn statistiques(valeurs: &[f64]) -> Option<(f64, f64, f64, f64)> {
    if valeurs.is_empty() {
        return None;
    }
    let mut tri = valeurs.to_vec();
    tri.sort_by(|a, b| a.total_cmp(b));

    let min = tri[0];
    let max = tri[tri.len() - 1];
    let moyenne = tri.iter().sum::<f64>() / tri.len() as f64;
    let milieu = tri.len() / 2;
    let mediane = if tri.len() % 2 == 0 {
        (tri[milieu - 1] + tri[milieu]) / 2.0
    } else {
        tri[milieu]
    };
    Some((min, max, moyenne, mediane))
}

fn main() {
    println!("{:?}", statistiques(&[12.0, 15.5, 9.0, 17.0]));
    println!("{:?}", statistiques(&[12.0, 15.5, 9.0]));
    println!("{:?}", statistiques(&[1.0, 2.0, 3.0, 4.0]));
    println!("{:?}", statistiques(&[]));
    if let Some((min, max, moy, med)) = statistiques(&[8.0, 14.0, 11.0]) {
        println!("min {min} max {max} moyenne {moy:.2} médiane {med}");
    }
}

Explication

Le test de liste vide en tout début de fonction garantit que tri[0] existe ensuite. total_cmp trie des f64 sans unwrap(). tri.len() as f64 est une conversion sans risque, d’entier vers décimal.

Exercice 4.3 — Fréquence des mots

Voir le corrigé
use std::collections::HashMap;

fn frequences(texte: &str) -> HashMap<String, usize> {
    let mut compte = HashMap::new();
    for mot in texte.split_whitespace() {
        let propre: String = mot
            .chars()
            .filter(|c| !".,;:!?()\"'".contains(*c))
            .collect::<String>()
            .to_lowercase();
        if !propre.is_empty() {
            *compte.entry(propre).or_insert(0) += 1;
        }
    }
    compte
}

fn main() {
    let texte = "Rust est rapide. Rust est sûr ! Le compilateur de Rust est exigeant, \
                 mais le compilateur est utile : il est un allié.";
    let compte = frequences(texte);

    let mut classement: Vec<(&String, &usize)> = compte.iter().collect();
    classement.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));

    for (mot, n) in classement.iter().take(5) {
        println!("{mot:<12} {n}");
    }
}

Explication

Le tri combine deux critères : b.1.cmp(a.1) classe par nombre décroissant (b avant a), puis .then(a.0.cmp(b.0)) départage par ordre alphabétique. take(5) ne garde que les cinq premiers. Le filtre de ponctuation est un itérateur : le niveau 5 les présente en détail.

Mini-projet

Voir le corrigé
use std::collections::HashMap;
use std::fmt;

const EXPORT: &str = "Nom;Matière;Note
Lina;Maths;14
Lina;Réseau;16,5
Sam;Maths;9
Sam;Réseau;abc
Noé;Maths;12
Noé;Réseau;
Zoé;Maths;21
Lina;Anglais;11
ligne sans séparateur
Sam;Anglais;13";

#[derive(Debug)]
enum ErreurNote {
    Vide,
    PasUnNombre(String),
    HorsBornes(f64),
}

impl fmt::Display for ErreurNote {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ErreurNote::Vide => write!(f, "note manquante"),
            ErreurNote::PasUnNombre(t) => write!(f, "« {t} » n'est pas une note"),
            ErreurNote::HorsBornes(n) => write!(f, "{n} n'est pas entre 0 et 20"),
        }
    }
}

fn lire_note(texte: &str) -> Result<f64, ErreurNote> {
    let t = texte.trim().replace(',', ".");
    if t.is_empty() {
        return Err(ErreurNote::Vide);
    }
    let n: f64 = t.parse().map_err(|_| ErreurNote::PasUnNombre(texte.to_string()))?;
    if !(0.0..=20.0).contains(&n) {
        return Err(ErreurNote::HorsBornes(n));
    }
    Ok(n)
}

struct Ligne {
    nom: String,
    note: f64,
}

fn analyser_ligne(ligne: &str) -> Result<Ligne, String> {
    let champs: Vec<&str> = ligne.split(';').collect();
    let [nom, _matiere, note] = champs[..] else {
        return Err(format!("3 champs attendus, {} trouvé(s)", champs.len()));
    };
    if nom.trim().is_empty() {
        return Err(String::from("nom manquant"));
    }
    let note = lire_note(note).map_err(|e| e.to_string())?;
    Ok(Ligne { nom: nom.trim().to_string(), note })
}

fn main() {
    let mut par_eleve: HashMap<String, Vec<f64>> = HashMap::new();
    let mut rejets: Vec<String> = Vec::new();

    for (i, ligne) in EXPORT.lines().enumerate().skip(1) {
        match analyser_ligne(ligne) {
            Ok(l) => par_eleve.entry(l.nom).or_default().push(l.note),
            Err(motif) => rejets.push(format!("ligne {} : {motif}", i + 1)),
        }
    }

    let mut noms: Vec<&String> = par_eleve.keys().collect();
    noms.sort();
    println!("{:<8} {:>6} {:>8}", "Élève", "Notes", "Moyenne");
    for nom in noms {
        let notes = &par_eleve[nom];
        let moyenne = notes.iter().sum::<f64>() / notes.len() as f64;
        println!("{:<8} {:>6} {:>8.2}", nom, notes.len(), moyenne);
    }

    println!("\n{} ligne(s) rejetée(s) :", rejets.len());
    for r in &rejets {
        println!("  {r}");
    }
}

Explication

Le traitement est découpé en couches : lire_note valide une valeur, analyser_ligne valide une ligne entière, main agrège. Chaque couche renvoie un Result et la boucle principale ne fait que trier les succès et les échecs. let [nom, _matiere, note] = champs[..] else vérifie en une ligne qu’il y a exactement trois champs. or_default() crée un vecteur vide pour un élève encore inconnu.

Pour aller plus loin

Ce motif « valider chaque ligne, garder les rejets, produire un rapport » est exactement celui des imports de comptes utilisateurs, de fichiers de commandes ou de relevés bancaires.

🧠 Testez vos connaissances

5 questions. Répondez puis cliquez sur « Corriger ». Rien n’est enregistré : c’est de l’auto-évaluation.

1. Que fait l’opérateur ? sur un Result ?

💬 Il propage l’erreur à l’appelant. La fonction doit elle-même renvoyer un Result compatible.

2. Quand utiliser panic! plutôt que Result ?

💬 Les échecs prévisibles (fichier, saisie, réseau) se traitent avec Result.

3. Quelle différence entre v[10] et v.get(10) sur un vecteur de 3 éléments ?

💬 get est l’accès sûr, à utiliser dès que l’indice vient de l’extérieur.

4. Comment compter des occurrences dans une HashMap ?

💬 entry crée la clé si elle est absente, puis on incrémente la valeur.

5. Un utilisateur demande le rapport ../../etc/passwd. Quelle faille menace ?

💬 On ne construit jamais un chemin avec une saisie sans refuser les .. et séparateurs, ou vérifier le chemin canonique.

© 2026 Bruno Jousseaume. Ce cours est publié sous licence CC BY-NC-SA 4.0 : vous pouvez le partager et l’adapter en citant l’auteur et un lien vers cette page, sans usage commercial, et en partageant vos adaptations sous la même licence.

Pour aller plus loin

Python niveau 1 — Premiers pas

Afficher, calculer, mémoriser dans des variables et dialoguer avec l’utilisateur : niveau 1 du parcours Python, avec exercices corrigés et QCM.

Débutant⏱ 4 à 5 h · 17 octobre 2026