Développement Web

Collections, traits et génériques : un outil CLI Rust complet

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.

Assembler toutes les briques en un outil réel

Vous avez construit, tutoriel après tutoriel, les pièces de stock-cli : un type Produit, des méthodes, la gestion d’erreurs. Il manque deux choses pour en faire un vrai outil : une collection qui contient plusieurs produits et grandit au fil de l’eau, et la capacité de parcourir cette collection pour calculer, filtrer, agréger. C’est le rôle de Vec, des itérateurs et de HashMap. On y ajoutera deux concepts qui rendent le code réutilisable : les génériques et les traits.

Ce dernier tutoriel est le point d’orgue de la série : on assemble tout pour obtenir un outil en ligne de commande complet qui charge un inventaire, calcule sa valeur totale, liste les produits en stock faible, et compte les produits par état (en stock ou en rupture). À la fin, vous aurez un programme Rust qui ressemble à ce qu’on écrit en vrai, pas à un exercice jouet.

🎯 Ce que vous allez apprendre

  • Stocker une liste dynamique avec Vec et la parcourir avec des itérateurs (map, filter, sum).
  • Agréger des données avec HashMap et son API entry.
  • Écrire une fonction générique <T> et définir un comportement partagé avec un trait.
  • Assembler un outil CLI complet qui lit ses arguments et affiche un rapport d’inventaire.

🛠️ Ce que vous allez construire

La version finale de stock-cli : un Vec<Produit> en mémoire, une valeur totale du stock calculée par itérateur, la liste des produits sous le seuil d’alerte, un comptage par état via HashMap, et un trait Resume qui donne à chaque produit une ligne d’affichage soignée.

Prérequis

  • Avoir suivi les tutoriels sur les structs/enums et la gestion d’erreurs.
  • Test express : si vous savez définir une struct Produit avec une méthode, vous êtes prêt à l’assembler ici.
  • ⏱️ Temps estimé : ~50 minutes — c’est le plus complet, on construit l’outil entier.

Étape 1 — Vec : une liste qui grandit

Le tableau de taille fixe ne suffit plus : un inventaire s’agrandit. Vec<T> (vecteur) est un tableau dynamique, alloué sur le tas, qui peut accueillir un nombre variable d’éléments du même type. On le crée vide puis on y ajoute avec push, ou directement avec la macro vec! :

let mut inventaire: Vec<Produit> = Vec::new();
inventaire.push(Produit::new("Écran iPhone 11", 4500, 8));
inventaire.push(Produit::new("Batterie A10", 2000, 2));
inventaire.push(Produit::new("Chargeur USB-C", 1500, 0));

println!("{} produits référencés", inventaire.len());

La variable doit être mut pour qu’on puisse y ajouter des éléments. Vec gère seul son agrandissement : quand il manque de place, il réalloue une zone plus grande, de façon transparente. On accède à un élément par index (inventaire[0]), mais en pratique on parcourt rarement par index : on utilise les itérateurs, bien plus sûrs et expressifs.

Étape 2 — Les itérateurs : calculer sans boucle manuelle

Un itérateur produit les éléments d’une collection un par un, et offre une cascade de méthodes pour les transformer. Plutôt qu’une boucle for avec un accumulateur, on enchaîne des opérations lisibles. Calculons la valeur totale de l’inventaire :

let total: u32 = inventaire.iter()
    .map(|p| p.valeur())   // chaque produit -> sa valeur (prix * quantité)
    .sum();                // additionne le tout

println!("Valeur totale du stock : {total} FCFA");

iter() crée un itérateur qui emprunte chaque produit. map applique une fonction à chacun — ici la méthode valeur() via une closure |p| p.valeur() (une fonction anonyme dont p est le paramètre). sum consomme l’itérateur et additionne. Le tout en trois lignes déclaratives, sans variable mutable ni risque d’erreur d’index. Les itérateurs sont aussi paresseux : rien ne se calcule tant qu’une méthode finale (comme sum ou collect) ne déclenche le parcours.

Filtrer fonctionne pareil. Listons les produits sous le seuil d’alerte (moins de 3 unités), en collectant les noms dans un nouveau Vec :

let a_reapprovisionner: Vec<&str> = inventaire.iter()
    .filter(|p| p.quantite < 3)       // garde ceux en stock faible
    .map(|p| p.nom.as_str())          // récupère le nom
    .collect();                       // rassemble dans un Vec

println!("À réapprovisionner : {a_reapprovisionner:?}");

filter ne garde que les éléments pour lesquels la closure renvoie true. collect rassemble le résultat dans une collection — ici un Vec<&str>, déduit par l’annotation de type. Ce style « itérateur » est idiomatique en Rust : plus court, plus sûr et souvent aussi rapide qu’une boucle écrite à la main.

Point d’étape — Vous devez voir la valeur totale (par exemple 40000 FCFA) et la liste ["Batterie A10", "Chargeur USB-C"]. Si collect se plaint d’un type ambigu, ajoutez l’annotation Vec<&str> sur la variable : collect a besoin de savoir quelle collection produire.

Étape 3 — HashMap : agréger par clé

Pour compter les produits par état (en stock ou en rupture), on a besoin d’associer une clé (l’état) à une valeur (un compteur). C’est le rôle de HashMap<K, V>, une table de hachage. Son API entry rend le comptage élégant : elle insère une valeur par défaut si la clé est absente, puis renvoie une référence modifiable.

use std::collections::HashMap;

let mut compte: HashMap<&str, u32> = HashMap::new();
for p in &inventaire {
    let etat = if p.quantite == 0 { "rupture" } else { "en stock" };
    *compte.entry(etat).or_insert(0) += 1;
}

for (etat, n) in &compte {
    println!("{etat} : {n} produit(s)");
}

La ligne clé est *compte.entry(etat).or_insert(0) += 1. entry cherche la clé ; or_insert(0) insère 0 si elle est absente et renvoie une référence mutable vers la valeur ; le * déréférence pour incrémenter. En une ligne, on a le « compter ou créer » que d’autres langages écrivent en trois. On importe HashMap avec use std::collections::HashMap; — il n’est pas dans le prélude automatique, contrairement à Vec.

Étape 4 — Génériques : écrire une fois, réutiliser partout

Imaginez une fonction qui renvoie le premier élément d’une liste, quel que soit son type. L’écrire pour Vec<Produit> puis la réécrire pour Vec<u32> serait absurde. Les génériques permettent d’écrire la logique une seule fois, valable pour tout type T :

fn premier<T>(liste: &[T]) -> Option<&T> {
    liste.first()   // renvoie Some(&premier) ou None si vide
}

fn main() {
    let nombres = vec![10u32, 20, 30];
    let noms = vec!["Écran", "Batterie"];
    println!("{:?}", premier(&nombres));  // Some(10)
    println!("{:?}", premier(&noms));     // Some("Écran")
}

Le <T> après le nom de la fonction déclare un type générique. premier fonctionne pour un slice de n’importe quel type, sans duplication. C’est le même mécanisme qui sous-tend Vec<T>, Option<T> et HashMap<K, V> : ces types sont génériques, et c’est pourquoi ils marchent avec vos propres structures sans rien réécrire.

Étape 5 — Les traits : définir un comportement partagé

Un trait décrit un comportement qu’un type peut implémenter — l’équivalent d’une interface. Définissons un trait Resume qui impose une méthode resume(), puis implémentons-le pour Produit :

trait Resume {
    fn resume(&self) -> String;
}

impl Resume for Produit {
    fn resume(&self) -> String {
        format!("{} ({} en stock)", self.nom, self.quantite)
    }
}

fn main() {
    let p = Produit::new("Écran iPhone 11", 4500, 8);
    println!("{}", p.resume());  // Écran iPhone 11 (8 en stock)
}

Le bloc trait déclare le contrat (la signature de resume), le bloc impl Resume for Produit le remplit. N’importe quel type peut implémenter Resume à sa façon, et on peut écrire des fonctions génériques qui acceptent « tout type qui implémente Resume ». C’est ainsi que Rust fait du polymorphisme sans héritage. D’ailleurs, les annotations #[derive(Debug)] que vous utilisez depuis le début ne sont que des implémentations automatiques de traits standard.

Point d’étapep.resume() doit afficher « Écran iPhone 11 (8 en stock) ». Si le compilateur dit method not found, vérifiez que le bloc impl Resume for Produit est bien présent et que le trait est dans la portée.

Étape 6 — Assembler l’outil CLI complet

Réunissons tout. Un outil en ligne de commande lit ses arguments via std::env::args, qui renvoie un itérateur des arguments passés au programme. On accepte un seuil d’alerte optionnel, et on affiche un rapport complet :

use std::env;

fn main() {
    // 1er argument après le nom du programme = seuil d'alerte (défaut 3)
    let seuil: u32 = env::args().nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(3);

    let inventaire = vec![
        Produit::new("Écran iPhone 11", 4500, 8),
        Produit::new("Batterie A10", 2000, 2),
        Produit::new("Chargeur USB-C", 1500, 0),
    ];

    let total: u32 = inventaire.iter().map(|p| p.valeur()).sum();
    println!("=== Rapport stock-cli ===");
    println!("Valeur totale : {total} FCFA");
    println!("Seuil d'alerte : {seuil}");

    println!("-- À réapprovisionner --");
    for p in inventaire.iter().filter(|p| p.quantite < seuil) {
        println!("  {}", p.resume());
    }
}

env::args().nth(1) récupère le premier argument réel (l’index 0 est le nom du programme). and_then tente de le convertir en nombre, unwrap_or(3) retombe sur 3 si l’argument manque ou est invalide — une chaîne d’Option qui ne plante jamais. On lance avec cargo run -- 5 (le -- sépare les arguments de Cargo de ceux du programme) pour fixer le seuil à 5. L’outil affiche la valeur totale puis, grâce à filter et au trait resume(), la liste soignée des produits à réapprovisionner.

Point d’étapecargo run affiche le rapport avec le seuil 3 ; cargo run -- 5 élargit la liste d’alerte. Si rien ne s’affiche sous « À réapprovisionner », c’est que tous vos stocks dépassent le seuil — baissez-le pour vérifier.

Une remarque pour relier ce tutoriel au précédent : dans cette version, l’inventaire est codé en dur dans le main. La suite logique, une fois ce socle maîtrisé, consiste à le charger depuis un fichier texte avec std::fs::read_to_string et à propager l’erreur avec l’opérateur ? — exactement la technique de gestion d’erreurs vue auparavant. Vous parseriez chaque ligne (par exemple nom;prix;quantite) en la découpant avec split(';'), puis en construisant un Produit par ligne, le tout rassemblé dans le Vec avec un collect. C’est l’enchaînement naturel de toutes les briques de la série : les collections tiennent les données, les itérateurs les transforment, et Result sécurise la lecture. À ce stade, stock-cli n’est plus un exercice mais un véritable petit logiciel, prêt à être étendu selon les besoins réels d’un atelier ou d’une boutique.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
cannot borrow `inventaire` as mutable push sur un Vec non mut Déclarer let mut inventaire
type annotations needed sur collect Type de collection cible inconnu Annoter : let v: Vec<_> = ....collect();
cannot find type HashMap Import manquant Ajouter use std::collections::HashMap;
method `resume` not found Trait non implémenté ou hors portée Vérifier impl Resume for Produit
L’argument CLI est ignoré Oubli du -- avec cargo run Lancer cargo run -- 5

🌍 Adaptation au contexte ouest-africain

Un outil comme stock-cli n’a rien d’un exercice abstrait : c’est exactement le genre de logiciel utile à un atelier de Pikine, une boutique de Bamako ou un dépôt de Cotonou. Compilé en --release, il devient un binaire unique de quelques méga-octets, sans dépendance à installer, qui démarre instantanément et tourne sur n’importe quelle machine — y compris un vieux portable ou un Raspberry Pi alimenté en cas de coupure. Pas de runtime à déployer, pas de base de données lourde : un fichier exécutable et un fichier texte d’inventaire suffisent.

C’est un atout commercial concret pour un développeur freelance de la sous-région : livrer un outil de gestion qui « juste marche » sur le matériel existant du client, sans frais d’hébergement mensuel ni connexion permanente. La sobriété de Rust — un seul binaire, mémoire prévisible, démarrage immédiat — colle aux réalités du terrain, là où des solutions gourmandes en serveur seraient coûteuses ou peu fiables.

✅ Récapitulatif

Vous avez assemblé un outil complet. Vous savez stocker une liste dynamique avec Vec, la parcourir et l’agréger avec les itérateurs (map, filter, sum, collect), compter par clé avec HashMap et son API entry, écrire une fonction générique <T>, et définir un comportement partagé avec un trait. Le tout converge dans un stock-cli qui lit ses arguments et produit un vrai rapport d’inventaire. Vous tenez là les fondations du Rust quotidien — il ne vous reste qu’à les approfondir sur vos propres projets. Le guide principal de la série vous indique les prochaines directions.

🧾 Aide-mémoire

Élément Rôle
Vec::new() / vec![...] Liste dynamique
.push(x) Ajoute un élément
.iter().map(...).sum() Transforme puis agrège
.filter(...).collect() Filtre puis rassemble
HashMap + .entry(k).or_insert(v) Compter / agréger par clé
fn f<T>(...) Fonction générique
trait T { ... } + impl T for X Comportement partagé
env::args().nth(1) Lire un argument CLI

💪 À vous de jouer

Ajoutez au rapport une ligne qui affiche le produit le plus cher de l’inventaire. Indice : l’itérateur possède une méthode max_by_key qui renvoie un Option de l’élément maximisant une clé.

Voir une solution
if let Some(p) = inventaire.iter().max_by_key(|p| p.prix) {
    println!("Produit le plus cher : {} ({} FCFA)", p.nom, p.prix);
}

max_by_key parcourt l’itérateur et renvoie Some(&produit) ayant le plus grand prix, ou None si l’inventaire est vide. On déballe avec if let pour gérer proprement le cas vide.

Tutoriels frères

Pour aller plus loin

FAQ

Q : Quand utiliser un Vec plutôt qu’un tableau [T; N] ?
R : Le tableau a une taille fixe connue à la compilation. Le Vec grandit et rétrécit à l’exécution. Pour un inventaire qui évolue, c’est toujours Vec. Le tableau convient aux ensembles figés (jours de la semaine, par exemple).

Q : Les itérateurs sont-ils plus lents qu’une boucle for ?
R : Non, en général. Le compilateur les optimise au point qu’ils produisent souvent le même code machine qu’une boucle écrite à la main — c’est ce qu’on appelle des abstractions « à coût zéro ». Préférez le style itérateur pour la lisibilité.

Q : Quelle différence entre génériques et traits ?
R : Les génériques (<T>) permettent d’écrire du code valable pour plusieurs types. Les traits définissent un comportement qu’un type implémente. On les combine souvent : une fonction générique peut exiger que T implémente un trait donné.

Q : Comment lire les arguments d’un programme plus complexe ?
R : std::env::args suffit pour des cas simples. Dès que les options se multiplient (drapeaux, sous-commandes), la crate clap de l’écosystème gère l’analyse des arguments proprement. C’est l’étape naturelle quand stock-cli grandit.

مشاركة