📍 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 blocimpl. - Dériver
Debugpour afficher une structure pendant le développement. - Modéliser des choix exclusifs avec un
enumet traiter tous les cas avecmatch. - Manipuler l’absence de valeur avec
Optionet la raccourciif 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
&selfvous 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’étape —
ecran.valeur()doit afficher 36000 (4500 × 8) etest_disponible()doit affichertrue. Si le compilateur ditno method named valeur, vérifiez que le blocimpl Produitentoure 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_reapprosur chaque variante. Retirez temporairement une branche (sans_) : le compilateur doit affichernon-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 :Optionvous 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
- Comprendre l’ownership et le borrowing — indispensable pour bien manier
&self. - Gérer les erreurs : Result, Option et l’opérateur ? — la suite directe sur la robustesse.
Pour aller plus loin
- 🔝 Retour au guide principal : Apprendre Rust de zéro : le guide complet
- Chapitres « Structs » et « Enums and Pattern Matching » du livre : doc.rust-lang.org/book
- Tutoriel suivant suggéré : Gestion d’erreurs avec Result
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.