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

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

Rust niveau 3 — Structures, énumérations et match

Modéliser ses données pour que les états invalides soient impossibles : niveau 3 du parcours Rust, avec exercices corrigés et QCM.

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

1. Les structures

Une structure regroupe plusieurs valeurs nommées qui forment un tout.

#[derive(Debug)]
struct Poste {
    nom: String,
    salle: u16,
    ram_go: u32,
    actif: bool,
}

fn main() {
    let p = Poste {
        nom: String::from("PC-207-01"),
        salle: 207,
        ram_go: 16,
        actif: true,
    };
    println!("{} est en salle {}", p.nom, p.salle);
    println!("{:?}", p);        // grâce à #[derive(Debug)]
    println!("{:#?}", p);       // version indentée
}
  • Les noms de structures s’écrivent en CamelCase, les champs en snake_case.
  • #[derive(Debug)] demande au compilateur de générer l’affichage de débogage {:?}.
  • Pour modifier un champ, l’instance entière doit être mut : let mut p = …; p.actif = false;.
struct Poste { nom: String, salle: u16, ram_go: u32, actif: bool }

fn nouveau_poste(nom: String, salle: u16) -> Poste {
    Poste { nom, salle, ram_go: 8, actif: true }   // raccourci : nom au lieu de nom: nom
}

fn main() {
    let p1 = nouveau_poste(String::from("PC-207-01"), 207);
    let p2 = Poste {
        nom: String::from("PC-207-02"),
        ..p1                                        // les autres champs viennent de p1
    };
    println!("{} {} {}", p2.nom, p2.salle, p2.ram_go);
}

Structures-tuples

struct Couleur(u8, u8, u8);
struct Metres(f64);

fn main() {
    let rouge = Couleur(255, 0, 0);
    let distance = Metres(42.5);
    println!("{} {}", rouge.0, distance.0);
}

Une structure-tuple à un seul champ (Metres) empêche de confondre des valeurs de même type mais de sens différent : on ne peut pas passer des mètres à une fonction qui attend des secondes. C’est le motif newtype, très utilisé.

2. Les méthodes

Un bloc impl associe des fonctions à une structure.

#[derive(Debug)]
struct Rectangle {
    largeur: f64,
    hauteur: f64,
}

impl Rectangle {
    // fonction associée (pas de self) : sert de constructeur
    fn new(largeur: f64, hauteur: f64) -> Rectangle {
        Rectangle { largeur, hauteur }
    }

    fn carre(cote: f64) -> Self {           // Self = le type en cours
        Self { largeur: cote, hauteur: cote }
    }

    // méthode qui lit
    fn aire(&self) -> f64 {
        self.largeur * self.hauteur
    }

    // méthode qui modifie
    fn agrandir(&mut self, facteur: f64) {
        self.largeur *= facteur;
        self.hauteur *= facteur;
    }

    // méthode qui consomme l'instance
    fn en_texte(self) -> String {
        format!("{} x {}", self.largeur, self.hauteur)
    }
}

fn main() {
    let mut r = Rectangle::new(3.0, 2.0);
    println!("aire : {}", r.aire());
    r.agrandir(2.0);
    println!("{:?}", r);
    let c = Rectangle::carre(5.0);
    println!("{}", c.en_texte());
    // println!("{:?}", c);  ← erreur : c a été consommé par en_texte
}
Premier paramètreSensAppel
&selfLa méthode lit l’instancer.aire()
&mut selfLa méthode modifie l’instancer.agrandir(2.0) (r doit être mut)
selfLa méthode consomme l’instance, qui n’existe plus aprèsc.en_texte()
aucunFonction associée, souvent un constructeurRectangle::new(3.0, 2.0)

On retrouve exactement les règles du niveau 2 : &self est un emprunt en lecture, &mut self un emprunt en écriture, self un déplacement.

3. Les énumérations

Une énumération représente une valeur qui est une variante parmi plusieurs.

#[derive(Debug)]
enum Protocole {
    Http,
    Https,
    Ssh,
}

fn port_par_defaut(p: &Protocole) -> u16 {
    match p {
        Protocole::Http => 80,
        Protocole::Https => 443,
        Protocole::Ssh => 22,
    }
}

fn main() {
    let p = Protocole::Ssh;
    println!("{:?} utilise le port {}", p, port_par_defaut(&p));
}

La vraie force des énumérations Rust : chaque variante peut porter ses propres données.

enum Evenement {
    Connexion { utilisateur: String, ip: String },
    Deconnexion { utilisateur: String },
    EchecAuthentification { ip: String, tentatives: u32 },
    Redemarrage,
}

impl Evenement {
    fn gravite(&self) -> &str {
        match self {
            Evenement::EchecAuthentification { tentatives, .. } if *tentatives >= 5 => "CRITIQUE",
            Evenement::EchecAuthentification { .. } => "ALERTE",
            Evenement::Redemarrage => "INFO",
            _ => "INFO",
        }
    }
}

fn main() {
    let journal = [
        Evenement::Connexion { utilisateur: String::from("lina"), ip: String::from("10.0.0.12") },
        Evenement::EchecAuthentification { ip: String::from("203.0.113.9"), tentatives: 7 },
        Evenement::Deconnexion { utilisateur: String::from("lina") },
        Evenement::Redemarrage,
    ];
    for e in &journal {
        let texte = match e {
            Evenement::Connexion { utilisateur, ip } => format!("{utilisateur} connecté depuis {ip}"),
            Evenement::Deconnexion { utilisateur } => format!("{utilisateur} déconnecté"),
            Evenement::EchecAuthentification { ip, tentatives } => format!("{tentatives} échecs depuis {ip}"),
            Evenement::Redemarrage => String::from("redémarrage du serveur"),
        };
        println!("[{}] {}", e.gravite(), texte);
    }
}

4. Option : l’absence de valeur sans null

Beaucoup de langages ont une valeur spéciale null qui peut remplacer n’importe quel objet. Son inventeur, Tony Hoare, l’a lui-même qualifiée « d’erreur à un milliard de dollars » : oublier de tester null provoque d’innombrables plantages. Rust n’a pas de null. L’absence possible d’une valeur est dite dans le type :

enum Option<T> {    // déjà définie dans la bibliothèque standard
    Some(T),        // il y a une valeur
    None,           // il n'y en a pas
}
fn diviser(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

fn trouver(noms: &[&str], cherche: &str) -> Option<usize> {
    for (i, n) in noms.iter().enumerate() {
        if *n == cherche {
            return Some(i);
        }
    }
    None
}

fn main() {
    match diviser(10.0, 4.0) {
        Some(r) => println!("résultat : {r}"),
        None => println!("division par zéro"),
    }

    let classe = ["Lina", "Sam", "Noé"];
    if let Some(pos) = trouver(&classe, "Sam") {
        println!("Sam est en position {pos}");
    }

    let pos = trouver(&classe, "Zoé").unwrap_or(999);
    println!("{pos}");
}

Une Option<f64> n’est pas un f64 : on ne peut pas faire de calcul avec avant d’avoir traité le cas None. Le compilateur rend l’oubli impossible.

MéthodeEffet
match / if letTraiter explicitement les deux cas
.unwrap_or(v)La valeur, ou v par défaut
.unwrap_or_default()La valeur, ou la valeur par défaut du type (0, texte vide…)
.is_some() / .is_none()Tester la présence
.map(|x| …)Transformer la valeur si elle existe
.unwrap() / .expect("…")La valeur, ou arrêt du programme si None

5. Tout ce que match sait faire

fn decrire(n: i32) -> String {
    match n {
        0 => String::from("zéro"),
        1 | 2 | 3 => String::from("petit"),                // plusieurs valeurs
        4..=9 => String::from("moyen"),                     // intervalle
        x if x < 0 => format!("négatif ({x})"),             // garde
        grand @ 10..=99 => format!("grand ({grand})"),      // liaison + intervalle
        _ => String::from("très grand"),
    }
}

struct Point { x: i32, y: i32 }

fn position(p: &Point) -> &str {
    match p {
        Point { x: 0, y: 0 } => "origine",
        Point { x: 0, .. } => "sur l'axe vertical",
        Point { y: 0, .. } => "sur l'axe horizontal",
        _ => "ailleurs",
    }
}

fn main() {
    for n in [0, 2, 7, -4, 42, 500] {
        println!("{n} : {}", decrire(n));
    }
    println!("{}", position(&Point { x: 0, y: 5 }));
}

if let, let … else, while let

fn port(texte: &str) -> Option<u16> {
    texte.parse().ok()
}

fn afficher_port(texte: &str) {
    // let … else : on extrait la valeur, ou on sort de la fonction
    let Some(p) = port(texte) else {
        println!("« {texte} » n'est pas un port valide");
        return;
    };
    println!("port {p}");
}

fn main() {
    // if let : un seul cas nous intéresse
    if let Some(p) = port("8080") {
        println!("serveur sur {p}");
    }

    afficher_port("443");
    afficher_port("99999");

    // while let : on répète tant que le motif correspond
    let mut pile = vec![1, 2, 3];
    while let Some(sommet) = pile.pop() {
        println!("dépilé : {sommet}");
    }
}

6. Rendre les états invalides impossibles

Un principe de conception très puissant en Rust : si une donnée ne peut pas être invalide, on n’a jamais besoin de la vérifier. On le met en œuvre en ne permettant de créer une valeur que par un constructeur qui contrôle.

#[derive(Debug)]
pub struct Port(u16);

impl Port {
    pub fn new(valeur: u16) -> Option<Port> {
        if valeur == 0 { None } else { Some(Port(valeur)) }
    }
    pub fn valeur(&self) -> u16 {
        self.0
    }
    pub fn est_privilegie(&self) -> bool {
        self.0 < 1024
    }
}

fn ouvrir_serveur(port: &Port) {
    // aucune vérification nécessaire : un Port est toujours valide
    println!("écoute sur le port {}", port.valeur());
}

fn main() {
    match Port::new(8080) {
        Some(p) => ouvrir_serveur(&p),
        None => println!("port refusé"),
    }
    println!("{:?}", Port::new(0));
}

Dès que ce type sera placé dans un module (niveau 5), le champ privé empêchera d’écrire Port(0) directement : la seule porte d’entrée sera Port::new, qui contrôle. Le même principe s’applique à une adresse électronique, un numéro de salle, un identifiant d’utilisateur.

7. Mini-projet : le cycle de vie d’un ticket d’incident

8. Bilan

Exercices corrigés

Les corrigés privilégient les écritures idiomatiques de Rust. Si ta version fonctionne avec plus de if et moins de match, compare les deux : le match est souvent plus court, et surtout, le compilateur vérifie qu’il est complet.

Exercice 3.1 — Une structure complète

Voir le corrigé
#[derive(Debug)]
struct Eleve {
    nom: String,
    classe: String,
    notes: [f64; 3],
}

impl Eleve {
    fn new(nom: &str, classe: &str, notes: [f64; 3]) -> Self {
        Self { nom: nom.to_string(), classe: classe.to_string(), notes }
    }

    fn moyenne(&self) -> f64 {
        (self.notes[0] + self.notes[1] + self.notes[2]) / 3.0
    }

    fn est_admis(&self) -> bool {
        self.moyenne() >= 10.0
    }

    fn corriger_note(&mut self, indice: usize, note: f64) -> bool {
        if indice >= self.notes.len() || !(0.0..=20.0).contains(&note) {
            return false;
        }
        self.notes[indice] = note;
        true
    }
}

fn main() {
    let mut a = Eleve::new("Lina", "SIO1", [12.0, 8.5, 9.0]);
    let b = Eleve::new("Sam", "SIO1", [15.0, 14.0, 17.5]);

    for e in [&a, &b] {
        println!("{} ({}) : {:.2} admis = {}", e.nom, e.classe, e.moyenne(), e.est_admis());
    }

    println!("correction valide : {}", a.corriger_note(1, 14.0));
    println!("note 25 : {}", a.corriger_note(0, 25.0));
    println!("indice 5 : {}", a.corriger_note(5, 12.0));
    println!("{} : {:.2} admis = {}", a.nom, a.moyenne(), a.est_admis());
}

Explication

Le constructeur prend des &str et crée lui-même les String : l’appel reste simple. corriger_note vérifie avant de modifier, et le bool renvoyé permet à l’appelant de savoir si sa demande a été prise en compte. Le tableau [&a, &b] contient des références : on parcourt sans déplacer les élèves.

Pour aller plus loin

Au niveau 4, corriger_note renverra plutôt un Result, capable d’expliquer pourquoi la correction a été refusée.

Exercice 3.2 — Énumérations et Option

Voir le corrigé
use std::f64::consts::PI;

#[derive(Debug)]
enum Forme {
    Cercle { rayon: f64 },
    Rectangle { largeur: f64, hauteur: f64 },
    Triangle { base: f64, hauteur: f64 },
}

impl Forme {
    fn aire(&self) -> f64 {
        match self {
            Forme::Cercle { rayon } => PI * rayon * rayon,
            Forme::Rectangle { largeur, hauteur } => largeur * hauteur,
            Forme::Triangle { base, hauteur } => base * hauteur / 2.0,
        }
    }
}

fn plus_grande(formes: &[Forme]) -> Option<&Forme> {
    let mut meilleure: Option<&Forme> = None;
    for f in formes {
        match meilleure {
            Some(m) if m.aire() >= f.aire() => {}
            _ => meilleure = Some(f),
        }
    }
    meilleure
}

fn jour_suivant(jour: &str) -> Option<&str> {
    let jours = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
    for (i, j) in jours.iter().enumerate() {
        if *j == jour {
            return Some(jours[(i + 1) % 7]);
        }
    }
    None
}

fn main() {
    let formes = [
        Forme::Cercle { rayon: 1.5 },
        Forme::Rectangle { largeur: 3.0, hauteur: 2.0 },
        Forme::Triangle { base: 4.0, hauteur: 5.0 },
        Forme::Cercle { rayon: 0.5 },
    ];
    let mut total = 0.0;
    for f in &formes {
        println!("{:?} : {:.2}", f, f.aire());
        total += f.aire();
    }
    println!("Aire totale : {total:.2}");
    println!("La plus grande : {:?}", plus_grande(&formes));
    println!("Liste vide : {:?}", plus_grande(&[]));

    let Some(demain) = jour_suivant("dimanche") else {
        println!("jour inconnu");
        return;
    };
    println!("Après dimanche vient {demain}");
}

Explication

Le match de aire est exhaustif : ajouter une variante Hexagone sans compléter la méthode provoquerait une erreur de compilation. C’est un filet de sécurité précieux quand un programme évolue. plus_grande renvoie une référence vers une forme de la liste, sans la copier.

Mini-projet

Voir le corrigé
#[derive(Debug)]
enum Etat {
    Ouvert,
    EnCours { technicien: String },
    Resolu { technicien: String, solution: String },
    Ferme,
}

#[derive(Debug)]
struct Ticket {
    numero: u32,
    titre: String,
    salle: u16,
    etat: Etat,
}

impl Ticket {
    fn new(numero: u32, titre: &str, salle: u16) -> Self {
        Ticket { numero, titre: titre.to_string(), salle, etat: Etat::Ouvert }
    }

    fn prendre_en_charge(self, technicien: &str) -> Option<Ticket> {
        match self.etat {
            Etat::Ouvert => Some(Ticket { etat: Etat::EnCours { technicien: technicien.to_string() }, ..self }),
            _ => None,
        }
    }

    fn resoudre(self, solution: &str) -> Option<Ticket> {
        match self.etat {
            Etat::EnCours { technicien } => Some(Ticket {
                etat: Etat::Resolu { technicien, solution: solution.to_string() },
                ..self
            }),
            _ => None,
        }
    }

    fn fermer(self) -> Option<Ticket> {
        match self.etat {
            Etat::Resolu { .. } => Some(Ticket { etat: Etat::Ferme, ..self }),
            _ => None,
        }
    }

    fn resume(&self) -> String {
        let etat = match &self.etat {
            Etat::Ouvert => String::from("ouvert, en attente"),
            Etat::EnCours { technicien } => format!("pris en charge par {technicien}"),
            Etat::Resolu { technicien, solution } => format!("résolu par {technicien} : {solution}"),
            Etat::Ferme => String::from("fermé"),
        };
        format!("#{} [salle {}] {} — {}", self.numero, self.salle, self.titre, etat)
    }
}

fn main() {
    let t = Ticket::new(42, "Vidéoprojecteur sans signal", 207);
    println!("{}", t.resume());

    let t = t.prendre_en_charge("Magali").expect("transition autorisée");
    println!("{}", t.resume());
    let t = t.resoudre("câble HDMI remplacé").expect("transition autorisée");
    println!("{}", t.resume());
    let t = t.fermer().expect("transition autorisée");
    println!("{}", t.resume());

    let autre = Ticket::new(43, "Imprimante bloquée", 112);
    match autre.resoudre("?") {
        Some(_) => println!("anomalie : transition acceptée"),
        None => println!("refusé : on ne résout pas un ticket qui n'est pas en cours"),
    }
}

Explication

Les méthodes de transition prennent self : l’ancien ticket est consommé, il ne peut plus être utilisé par erreur. La syntaxe ..self recopie les champs inchangés. Les données portées par l’état (le technicien) sont transmises d’un état à l’autre par le match, ce qui garantit qu’un ticket résolu a toujours un technicien.

Pour aller plus loin

Une variante très utilisée de ce motif consiste à faire de chaque état un type différent (TicketOuvert, TicketEnCours…) : une transition interdite devient alors une erreur de compilation.

🧠 Testez vos connaissances

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

1. Quelle signature donner à une méthode qui modifie l’instance sans la consommer ?

💬 &self lit, &mut self modifie, self consomme. On retrouve les règles d’emprunt du niveau 2.

2. Que représente Option<u32> ?

💬 L’absence est inscrite dans le type : impossible de calculer avec avant d’avoir traité None.

3. Pourquoi match sur une énumération est-il précieux quand le programme évolue ?

💬 L’exhaustivité transforme un oubli en erreur de compilation.

4. Quand unwrap() est-il dangereux ?

💬 Sur une saisie ou un message réseau, un unwrap() peut devenir un déni de service. On traite le cas None.

5. Quel est l’intérêt d’un constructeur Port::new qui refuse la valeur 0 ?

💬 La validation se fait une fois, au seul point d’entrée. C’est le principe « rendre les états invalides impossibles ».

© 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