Développement Web

Structs, enums et pattern matching en Rust

13 دقائق للقراءة

📍 Guide principal de la série : Apprendre Rust de zéro : le guide complet pour débuter. Ce tutoriel fait partie de la série « Rust de zéro ». Pour la vue d’ensemble, lisez d’abord le guide principal.

Donner une forme à vos données

Jusqu’ici, un produit de stock-cli était éparpillé : un nom par-ci, un prix par-là, une quantité ailleurs. Ça ne tient pas à l’échelle. Pour modéliser le métier d’Ousmane, il faut un type qui regroupe ces champs sous un nom unique et leur attache un comportement. C’est le rôle des struct. Et pour représenter des choix exclusifs — la catégorie d’un produit, la présence ou l’absence d’une valeur —, Rust offre les enum et leur compagnon indispensable : le pattern matching avec match.

Ce trio (struct, enum, match) est ce qui rend le code Rust à la fois sûr et expressif. Le compilateur vous oblige à traiter tous les cas possibles, ce qui élimine une catégorie entière de bugs : l’oubli d’un cas. Dans ce tutoriel, vous transformez stock-cli en un vrai modèle de données : un type Produit avec ses méthodes, une catégorie en enum, et une recherche sûre grâce à Option.

🎯 Ce que vous allez apprendre

  • Définir une struct, l’instancier et lui attacher des méthodes avec un bloc impl.
  • Dériver Debug pour afficher une structure pendant le développement.
  • Modéliser des choix exclusifs avec un enum et traiter tous les cas avec match.
  • Manipuler l’absence de valeur avec Option et la raccourci if let.

🛠️ Ce que vous allez construire

Le type central de stock-cli : une struct Produit (nom, catégorie, prix, quantité) avec des méthodes valeur() et est_disponible(), un enum Categorie, et une fonction de recherche qui renvoie un Option<&Produit> au lieu de planter quand le produit n’existe pas.

Prérequis

  • Maîtriser l’ownership et le borrowing (références &), car les méthodes les utilisent.
  • Test express : si vous savez ce que fait &self vous prend de l’avance ; sinon, ce tutoriel l’introduit.
  • ⏱️ Temps estimé : ~40 minutes.

Étape 1 — Définir et instancier une struct

Une struct regroupe des champs nommés sous un type. Contrairement au tuple, chaque champ porte un nom explicite, ce qui rend le code auto-documenté. Définissons le produit de l’atelier :

struct Produit {
    nom: String,
    prix: u32,        // en FCFA
    quantite: u32,
}

fn main() {
    let ecran = Produit {
        nom: String::from("Écran iPhone 11"),
        prix: 4500,
        quantite: 8,
    };
    println!("{} : {} FCFA, {} en stock", ecran.nom, ecran.prix, ecran.quantite);
}

On accède aux champs avec la notation pointée (ecran.nom). Pour modifier un champ, l’instance entière doit être déclarée mut (let mut ecran = ...) — Rust ne permet pas de rendre un seul champ mutable. C’est cohérent avec le principe d’immuabilité par défaut vu précédemment.

Étape 2 — Attacher des méthodes avec impl

Une structure sans comportement n’est qu’un sac de données. Le bloc impl lui attache des méthodes. La première, presque toujours présente, est une fonction associée new qui sert de constructeur. Les méthodes qui agissent sur une instance prennent &self (emprunt en lecture) en premier paramètre :

impl Produit {
    // fonction associée : un constructeur (pas de self)
    fn new(nom: &str, prix: u32, quantite: u32) -> Produit {
        Produit { nom: nom.to_string(), prix, quantite }
    }

    // méthode : emprunte l'instance en lecture
    fn valeur(&self) -> u32 {
        self.prix * self.quantite
    }

    fn est_disponible(&self) -> bool {
        self.quantite > 0
    }
}

Trois choses à noter. new n’a pas de self : c’est une fonction associée, appelée via Produit::new(...). valeur et est_disponible prennent &self : elles empruntent l’instance sans la consommer, donc on peut les appeler autant qu’on veut. Et dans le constructeur, prix et quantite utilisent le raccourci de champ (le nom du paramètre est celui du champ, on n’écrit pas prix: prix). Voici l’usage :

fn main() {
    let ecran = Produit::new("Écran iPhone 11", 4500, 8);
    println!("Valeur du stock : {} FCFA", ecran.valeur());
    println!("Disponible : {}", ecran.est_disponible());
}

Point d’étapeecran.valeur() doit afficher 36000 (4500 × 8) et est_disponible() doit afficher true. Si le compilateur dit no method named valeur, vérifiez que le bloc impl Produit entoure bien les méthodes.

Étape 3 — Afficher une struct avec Debug

Pendant le développement, on veut souvent inspecter une structure entière sans écrire un println! champ par champ. Le trait Debug, qu’on dérive automatiquement, le permet via le format {:?} :

#[derive(Debug)]
struct Produit {
    nom: String,
    prix: u32,
    quantite: u32,
}

fn main() {
    let ecran = Produit::new("Écran iPhone 11", 4500, 8);
    println!("{ecran:?}");   // affichage compact
    println!("{ecran:#?}");  // affichage indenté, plus lisible
}

L’annotation #[derive(Debug)] au-dessus de la structure demande au compilateur de générer le code d’affichage. Le format {:?} donne une ligne compacte, {:#?} une version indentée idéale pour déboguer. C’est l’outil n°1 pour comprendre l’état de vos données quand quelque chose cloche.

Étape 4 — Modéliser des choix exclusifs avec un enum

Un produit appartient à une seule catégorie à la fois. Représenter ça avec des booléens (est_ecran, est_batterie…) serait fragile : rien n’empêcherait deux booléens vrais en même temps. L’enum exprime exactement « une valeur parmi un ensemble fini » :

#[derive(Debug)]
enum Categorie {
    Ecran,
    Batterie,
    Accessoire,
}

Une variable de type Categorie vaut forcément l’une des trois variantes, jamais deux, jamais aucune. On peut l’ajouter comme champ de Produit. Les enums de Rust vont plus loin que ceux de la plupart des langages : une variante peut transporter des données (par exemple Categorie::Promo(u32) pour un pourcentage de remise), mais restons simples pour l’instant. L’intérêt immédiat : forcer le traitement de tous les cas avec match.

Étape 5 — Le pattern matching exhaustif avec match

Le match compare une valeur à une série de motifs et exécute la branche correspondante. Sa force : le compilateur exige que tous les cas soient couverts. Oubliez une variante, et le code ne compile pas. Affichons un délai de réapprovisionnement selon la catégorie :

fn delai_reappro(cat: &Categorie) -> &'static str {
    match cat {
        Categorie::Ecran => "3 à 5 jours",
        Categorie::Batterie => "1 semaine",
        Categorie::Accessoire => "disponible en gros à Dakar",
    }
}

Chaque branche utilise la flèche =>. Comme match est une expression, sa valeur est renvoyée directement. Si vous ajoutiez une quatrième variante à l’enum sans l’ajouter ici, le compilateur refuserait : non-exhaustive patterns. C’est une sécurité énorme dans un vrai projet — impossible d’oublier de gérer un nouveau cas. Si vous voulez un cas « par défaut » regroupant le reste, utilisez le motif universel _ :

    match cat {
        Categorie::Ecran => "priorité haute",
        _ => "priorité normale",   // tout le reste
    }

Point d’étape — Appelez delai_reappro sur chaque variante. Retirez temporairement une branche (sans _) : le compilateur doit afficher non-exhaustive patterns. Ce refus est la garantie d’exhaustivité — remettez la branche.

Étape 6 — Gérer l’absence avec Option

Que renvoie une recherche quand le produit n’existe pas ? Dans beaucoup de langages, on renvoie null — source de la fameuse « erreur du milliard de dollars » (le null pointer). Rust n’a pas de null. À la place, le type Option<T> encode explicitement « une valeur, ou rien » avec deux variantes : Some(valeur) ou None. Écrivons une recherche dans une liste de produits :

fn trouver<'a>(produits: &'a [Produit], nom: &str) -> Option<&'a Produit> {
    for p in produits {
        if p.nom == nom {
            return Some(p);
        }
    }
    None
}

La fonction renvoie Some(&produit) si elle trouve, None sinon. Le code appelant doit traiter les deux cas — le compilateur l’y oblige —, ce qui rend impossible l’oubli du cas « introuvable ». On déballe le résultat avec match :

    match trouver(&inventaire, "Écran iPhone 11") {
        Some(p) => println!("Trouvé : {} FCFA", p.prix),
        None => println!("Produit absent du stock"),
    }

Quand seul le cas Some vous intéresse, le raccourci if let évite la lourdeur du match complet :

    if let Some(p) = trouver(&inventaire, "Batterie Samsung A10") {
        println!("Disponible à {} FCFA", p.prix);
    }

Point d’étape — Cherchez un produit présent puis un absent : vous devez voir « Trouvé… » dans un cas, « Produit absent… » dans l’autre. Si le compilateur réclame de gérer None, c’est exactement le but : Option vous force à prévoir l’absence.

Étape 7 — Réunir struct et enum dans un seul modèle

Jusqu’ici, Produit et Categorie vivaient séparément. Réunissons-les : un produit a une catégorie. On ajoute le champ à la structure, puis une méthode qui s’appuie sur le match pour en déduire une décision métier. C’est le moment où struct, enum et pattern matching se combinent — la forme typique d’un modèle de données Rust dans un vrai projet.

#[derive(Debug)]
struct Produit {
    nom: String,
    categorie: Categorie,
    prix: u32,
    quantite: u32,
}

impl Produit {
    // priorité de réassort déduite de la catégorie
    fn priorite(&self) -> &'static str {
        match self.categorie {
            Categorie::Ecran => "haute",
            Categorie::Batterie => "moyenne",
            Categorie::Accessoire => "basse",
        }
    }
}

La méthode priorite lit self.categorie et renvoie une priorité de réapprovisionnement. Remarquez l’enchaînement naturel : la struct porte les données, l’enum encode un attribut fini, et le match traduit cet attribut en décision. Quand Ousmane ajoutera une nouvelle catégorie — disons Categorie::Coque pour les coques de protection —, le compilateur signalera aussitôt ce match comme incomplet, vous forçant à décider la priorité du nouveau type avant même de lancer le programme. C’est cette boucle entre le compilateur et le développeur qui rend un modèle Rust difficile à laisser dans un état incohérent : le code reflète toujours toutes les règles métier en vigueur.

Pour construire un tel produit, on passe la variante d’enum directement au constructeur, exactement comme on passe un nombre ou une chaîne. Chaque produit transporte ainsi sa catégorie de façon sûre, sans chaîne de caractères libre susceptible de fautes de frappe, et toute la logique de réassort en découle automatiquement. C’est ce modèle Produit enrichi que les prochains tutoriels manipuleront pour gérer une liste complète et l’exporter.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
non-exhaustive patterns Une variante d’enum non traitée dans match Ajouter la branche manquante ou un motif _
no method named ... Méthode hors du bloc impl Placer la méthode dans impl Produit { ... }
cannot move out of borrowed content Sortir une valeur d’une référence Emprunter le champ (&p.nom) ou cloner
borrow of moved value sur un champ String Champ déplacé hors de la struct Utiliser &self et emprunter le champ
Oubli de #[derive(Debug)] {:?} sur un type sans Debug Ajouter #[derive(Debug)] au-dessus du type

🌍 Adaptation au contexte ouest-africain

Le type Option change la donne pour un logiciel de gestion utilisé en conditions réelles, où les données sont souvent incomplètes : un produit pas encore référencé, un client sans numéro enregistré, un prix non renseigné. Là où un script PHP ou Python renverrait null et planterait trois écrans plus loin, Rust force à décider, dès l’écriture, ce qui se passe quand la donnée manque. Pour une caisse d’atelier ou une boutique qui ne peut pas se permettre un plantage en plein service, c’est une fiabilité qui se ressent.

De même, modéliser les catégories en enum plutôt qu’en chaînes libres ("ecran", "Ecran", "écran"…) évite les fautes de frappe qui polluent les inventaires saisis à la main. Le compilateur n’accepte que les variantes définies : pas de catégorie fantôme due à une majuscule oubliée.

✅ Récapitulatif

Vous avez donné une vraie forme à stock-cli. Vous savez définir une struct, l’instancier, lui attacher des méthodes via impl (constructeur new, méthodes &self), et l’inspecter avec #[derive(Debug)]. Vous modélisez les choix exclusifs avec un enum et traitez tous les cas avec un match exhaustif. Surtout, vous gérez l’absence de valeur proprement avec Option et if let — sans jamais croiser un null. La suite naturelle : que faire quand une opération peut échouer ? C’est le sujet de la gestion d’erreurs avec Result.

🧾 Aide-mémoire

Élément Rôle
struct T { champ: Type } Regroupe des champs nommés
impl T { fn m(&self) {} } Attache des méthodes
T::new(...) Fonction associée (constructeur)
#[derive(Debug)] + {:?} Afficher une structure
enum E { A, B } Choix exclusif parmi des variantes
match x { ... } Traitement exhaustif des cas
Option<T> : Some/None Valeur présente ou absente
if let Some(v) = ... Raccourci pour un seul cas

💪 À vous de jouer

Ajoutez à Produit une méthode etiquette(&self) -> String qui renvoie une chaîne du type "Écran iPhone 11 — 4500 FCFA (8 en stock)". Utilisez le formatage avec format!.

Voir une solution
impl Produit {
    fn etiquette(&self) -> String {
        format!("{} — {} FCFA ({} en stock)", self.nom, self.prix, self.quantite)
    }
}

fn main() {
    let ecran = Produit::new("Écran iPhone 11", 4500, 8);
    println!("{}", ecran.etiquette());
}

La macro format! fonctionne comme println! mais renvoie une String au lieu de l’afficher. On emprunte &self et on lit les champs pour composer l’étiquette.

Tutoriels frères

Pour aller plus loin

FAQ

Q : Quelle différence entre une fonction associée et une méthode ?
R : Une méthode prend &self (ou self/&mut self) et agit sur une instance : ecran.valeur(). Une fonction associée n’a pas de self et s’appelle sur le type : Produit::new(...). new est par convention le constructeur.

Q : Pourquoi match exige-t-il tous les cas ?
R : Pour garantir qu’aucune situation n’est oubliée. Quand vous ajoutez une variante à un enum, le compilateur vous signale tous les match à mettre à jour. C’est une aide au refactoring, pas une contrainte gratuite.

Q : Option remplace-t-il vraiment null ?
R : Oui, et en mieux. Comme le type indique explicitement qu’une valeur peut être absente, le compilateur force à traiter le cas None. On n’a plus de plantage surprise dû à une valeur nulle non vérifiée.

Q : Peut-on stocker des données dans une variante d’enum ?
R : Oui. Une variante peut transporter des champs, par exemple Categorie::Promo(u32). C’est ce qui rend les enums Rust si puissants ; Option et Result sont eux-mêmes des enums de ce genre.

مشاركة