Projet : compteur de mots en Rust
Dans ce projet, vous allez construire un petit outil, mais un vrai : un compteur de mots. Vous lui donnez un texte, et il vous dit quels mots reviennent le plus souvent. Les auteurs utilisent ce genre d’outil pour repérer les mots qu’ils répètent trop. Les moteurs de recherche se servent de la même idée pour comprendre de quoi parle une page.
Nous allons le construire par petites étapes. Chaque étape est un programme complet qui fonctionne : vous pouvez vous arrêter n’importe quand, faire des essais, puis continuer. En chemin, vous utiliserez les chaînes de caractères, les HashMap, le tri, l’affichage formaté et les arguments de ligne de commande.
À la fin, votre programme affichera quelque chose comme ceci :
MOT FOIS----------------the 6cat 3happy 2Une précision avant de commencer : les textes d’exemple de cette page sont en anglais. Ainsi, les comptages et les tableaux sont exactement les mêmes que dans la version originale du tutoriel. Votre compteur fonctionne tout aussi bien avec du texte en français : to_lowercase et is_alphanumeric comprennent les lettres comme é ou ç.
Étape 1 : Découper le texte en mots
Section intitulée « Étape 1 : Découper le texte en mots »Le premier travail consiste à découper le texte en mots. split_whitespace() coupe une chaîne au niveau des espaces, des tabulations et des retours à la ligne, et ignore les morceaux vides entre eux :
fn main() { let text = "The cat sat on the mat. The dog sat on the log!";
let words: Vec<&str> = text.split_whitespace().collect();
println!("{:?}", words); println!("{} mots", words.len());}Sortie :
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 motsÇa marche, mais regardez de plus près. "The" et "the" sont traités comme deux mots différents, et "mat." a gardé son point. Si nous comptions maintenant, les résultats seraient faux. Corrigeons ça d’abord.
Étape 2 : Normaliser chaque mot
Section intitulée « Étape 2 : Normaliser chaque mot »Normaliser un mot, c’est le mettre sous une forme standard. Nous allons faire deux choses :
- Retirer la ponctuation au début et à la fin, avec
trim_matches. - Le passer en minuscules, avec
to_lowercase.
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. Don't stop -- said the DOG!";
for word in text.split_whitespace() { let clean = normalize(word); if clean.is_empty() { println!("{:>6} -> (ignoré)", word); } else { println!("{:>6} -> {}", word, clean); } }}Sortie :
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (ignoré) said -> said the -> the DOG! -> dogtrim_matches prend une closure (fonction anonyme) qui décide quels caractères retirer. Ici, elle retire tout ce qui n’est pas une lettre ou un chiffre, mais seulement aux extrémités. C’est pour ça que l’apostrophe de don't survit. Un « mot » fait uniquement de ponctuation, comme --, devient une chaîne vide : nous l’ignorons.
Étape 3 : Compter avec une HashMap
Section intitulée « Étape 3 : Compter avec une HashMap »Voici maintenant l’idée principale. Une HashMap associe à chaque mot le nombre de fois où nous l’avons vu. L’API entry rend le comptage très court :
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } }
println!("the : {}", counts["the"]); println!("cat : {}", counts["cat"]); println!("happy : {}", counts["happy"]); println!("{} mots différents", counts.len());}Sortie :
the : 6cat : 3happy : 211 mots différentsLisons lentement *counts.entry(clean).or_insert(0) += 1; :
counts.entry(clean)trouve l’emplacement de ce mot dans la map..or_insert(0)y met un0si le mot est nouveau. Dans tous les cas, elle renvoie une référence mutable vers le compteur.*suit cette référence jusqu’au nombre lui-même, et+= 1lui ajoute un.
Le \ à la fin de la première ligne de texte sert juste à continuer la chaîne sur la ligne suivante. Et counts["the"] cherche directement une clé. Ça provoque une panic si la clé n’existe pas, ce qui ne pose pas de problème ici, puisque nous savons que ces mots sont dans le texte. Pour des clés qui pourraient manquer, utilisez .get().
Pourquoi ne pas avoir affiché toute la map avec une boucle ? Une HashMap n’a pas d’ordre fixe. Si vous la parcourez, l’ordre peut changer à chaque exécution du programme. Pour obtenir un ordre stable et utile, il faut trier.
Étape 4 : Trier par nombre, puis par mot
Section intitulée « Étape 4 : Trier par nombre, puis par mot »Nous déplaçons les paires de la map dans un Vec, puis nous le trions. Nous voulons le plus grand nombre d’abord. Quand deux mots ont le même nombre, nous les voulons dans l’ordre alphabétique, pour que la sortie soit toujours la même :
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } }
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, count) in &pairs { println!("{word} : {count}"); }}Sortie :
the : 6cat : 3happy : 2is : 2on : 2sat : 2dog : 1log : 1mat : 1very : 1yes : 1La ligne du tri est la partie la plus délicate de ce projet, alors décortiquons-la :
sort_bynous donne deux éléments,aetb, et demande « lequel passe en premier ? ».b.1.cmp(&a.1)compare les nombres (.1est la deuxième partie du tuple). Nous avons écritbavantaexprès : ça inverse l’ordre, donc les grands nombres passent en premier..then(a.0.cmp(&b.0))ne sert que quand les nombres sont égaux. Il compare les mots dans l’ordre normal, deaversb, donc les ex æquo sont rangés par ordre alphabétique.
Un détail si vous comptez du texte en français : cet ordre « alphabétique » compare le code de chaque caractère, donc les mots qui commencent par é ou à se retrouvent après le z.
La page Trier et rechercher contient d’autres exemples avec sort_by et ses cousines.
Étape 5 : Afficher un tableau des N premiers mots
Section intitulée « Étape 5 : Afficher un tableau des N premiers mots »Une longue liste est difficile à lire. Affichons seulement les premiers mots, en colonnes bien alignées. Les spécificateurs de format s’occupent de l’alignement à notre place :
{:<10}veut dire « aligner à gauche dans un espace de 10 caractères de large ».{:>5}veut dire « aligner à droite dans un espace de 5 caractères de large ». Les nombres sont généralement plus lisibles alignés à droite.
Les en-têtes MOT (3 lettres) et FOIS (4 lettres) tiennent dans ces largeurs, donc le tableau reste aligné sur la ligne de 16 tirets.
Nous allons aussi ranger le code dans des fonctions, pour que main se lise comme une petite histoire :
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "MOT", "FOIS"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let pairs = sorted_counts(count_words(text)); print_table(&pairs, 5);}Sortie :
MOT FOIS----------------the 6cat 3happy 2is 2on 2.take(top_n) s’arrête après top_n éléments. S’il y a moins de mots que ça, il s’arrête simplement plus tôt : vous n’avez jamais à vous soucier de dépasser la fin.
Remarquez que print_table prend un &[(String, usize)], c’est-à-dire une slice (une tranche). Elle se contente donc d’emprunter la liste, et elle fonctionne avec n’importe quel Vec de paires.
Étape 6 : La version finale, qui lit un fichier
Section intitulée « Étape 6 : La version finale, qui lit un fichier »Un vrai compteur de mots doit lire un fichier. Nous allons prendre le chemin du fichier sur la ligne de commande, avec un deuxième argument optionnel pour le nombre de lignes à afficher. En cas de problème, nous affichons un message clair avec eprintln! (qui écrit sur la sortie d’erreur) et nous quittons avec le code 1.
Créez le projet et un fichier texte pour faire vos essais :
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtMettez ceci dans src/main.rs. Cette version est faite pour votre propre machine, car le Playground ne peut pas lire vos fichiers :
use std::collections::HashMap;use std::env;use std::fs;use std::process;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "MOT", "FOIS"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { let args: Vec<String> = env::args().collect();
// args[0] est le nom du programme, args[1] le chemin du fichier let Some(path) = args.get(1) else { eprintln!("Utilisation : word_counter <fichier> [top_n]"); process::exit(1); };
// le deuxième argument est optionnel ; on prend 10 s'il manque ou n'est pas un nombre let top_n: usize = match args.get(2) { Some(n) => n.parse().unwrap_or(10), None => 10, };
let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) => { eprintln!("Impossible de lire {path} : {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} mots, {} différents\n", total, pairs.len()); print_table(&pairs, top_n);}Lancez-le. N’oubliez pas que les arguments destinés à votre programme se placent après -- :
cargo run -- story.txt 312 mots, 7 différents
MOT FOIS----------------the 4on 2sat 2Et si vous oubliez le nom du fichier, vous obtenez le message d’utilisation au lieu d’un plantage :
cargo runUtilisation : word_counter <fichier> [top_n]let Some(path) = args.get(1) else { ... }; s’appelle un let-else. Ça veut dire « si ce motif correspond, on continue ; sinon, on exécute le bloc else, qui doit arrêter l’exécution (ici, en quittant le programme) ». La page Lire et écrire des fichiers explique read_to_string et ses erreurs plus en détail. Si le fichier n’existe pas, {e} affiche quelque chose comme No such file or directory (os error 2) (« fichier ou dossier introuvable »). Ce message vient du système, c’est pourquoi il est en général en anglais.
Voici le même programme avec le texte directement dans le code, pour que vous puissiez l’exécuter dans le Playground. Seule la fonction main change :
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "MOT", "FOIS"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // on fait comme si ce texte venait d'un fichier let text = "Rust is fast. Rust is safe. Rust is fun! Is it hard? It takes practice, and practice is fun too."; let top_n = 4;
let pairs = sorted_counts(count_words(text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} mots, {} différents\n", total, pairs.len()); print_table(&pairs, top_n);}Sortie :
20 mots, 11 différents
MOT FOIS----------------is 5rust 3fun 2it 2Erreurs courantes
Section intitulée « Erreurs courantes »Oublier le * en comptant. or_insert vous donne une référence vers le nombre, pas le nombre lui-même. Sans *, Rust refuse de lui ajouter quoi que ce soit :
*counts.entry(word).or_insert(0) += 1; // correctcounts.entry(word).or_insert(0) += 1; // erreurerror[E0368]: binary assignment operation `+=` cannot be applied to type `&mut {integer}` --> src/main.rs:6:9 |6 | counts.entry(word).or_insert(0) += 1; | -------------------------------^^^^^ | | | cannot use `+=` on type `&mut {integer}` |help: `+=` can be used on `{integer}` if you dereference the left-hand side |6 | *counts.entry(word).or_insert(0) += 1; | +Le compilateur dit qu’on ne peut pas utiliser += sur une référence &mut, et il vous montre même la correction : ajouter le * pour déréférencer le côté gauche.
Afficher la HashMap directement. println!("{:?}", counts) fonctionne, mais l’ordre est aléatoire et peut changer d’une exécution à l’autre. Triez toujours dans un Vec d’abord quand l’ordre compte.
Trier uniquement par nombre. Si vous triez seulement avec b.1.cmp(&a.1), les mots qui ont le même nombre peuvent apparaître dans n’importe quel ordre. Ajoutez le départage .then(...) pour que la sortie soit toujours la même.
Idées pour aller plus loin
Section intitulée « Idées pour aller plus loin »Vous avez un outil qui fonctionne. Voici quelques façons de vous l’approprier, de la plus facile à la plus difficile :
- Ignorer les mots vides. Des mots comme « the », « a », « is » et « and » (ou « le », « la », « de », « et » en français) sont partout et pas très intéressants. Mettez-les dans un
HashSetet sautez tous les mots qui s’y trouvent. - Afficher des pourcentages. À côté de chaque nombre, affichez la part qu’il représente sur l’ensemble des mots, par exemple
the 6 28.6%. Utilisez{:.1}pour afficher une décimale. - Lire stdin quand aucun fichier n’est donné. Utilisez
std::io::read_to_string(std::io::stdin())pour pouvoir envoyer du texte par un pipe :cat story.txt | cargo run. - Compter plusieurs fichiers. Parcourez chaque chemin de
args[1..]et ajoutez tous leurs mots dans une seule map. - Utiliser
clappour les arguments. Lancezcargo add clap --features deriveet ajoutez de vraies options comme--top 5et--ignore-case, avec en prime un message--helpgratuit. La page Arguments de ligne de commande en donne un exemple. - Écrire des tests. Déplacez les fonctions dans
src/lib.rset ajoutez quelques tests pournormalizeetcount_words.
Récapitulatif
Section intitulée « Récapitulatif »split_whitespace()découpe le texte en mots ;trim_matchesetto_lowercaseles normalisent.*map.entry(key).or_insert(0) += 1est la façon standard de compter des choses avec une HashMap.- Une HashMap n’a pas d’ordre. Rassemblez les paires dans un
Vecet utilisezsort_byavec un critère de départage pour une sortie stable. {:<10}et{:>5}alignent le texte en colonnes ;.take(n)limite le nombre de lignes affichées.- Lisez le chemin du fichier avec
env::args(), gérez les erreurs avec un message clair, et quittez avecprocess::exit(1).
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Que renvoie "Hello!".trim_matches(|c: char| !c.is_alphanumeric()) ?
2. Pourquoi rassemble-t-on la HashMap dans un Vec avant de l'afficher ?
3. Dans pairs.sort_by(|a, b| b.1.cmp(&a.1)), pourquoi b est-il écrit avant a ?
4. Que fait {:>5} dans println! ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Storing Keys with Associated Values in Hash Maps — The Rust Book (en anglais) : contient un exemple de comptage de mots avec
entryetor_insert, très proche de l’étape 3. - An I/O Project: Building a Command Line Program — The Rust Book (en anglais) : construit un petit clone de
grepqui lit des arguments et des fichiers ; les mêmes compétences que l’étape 6, plus en profondeur. HashMap::entry— documentation std (en anglais) : renvoie uneEntry, dont les méthodes incluentor_insert,or_defaultetand_modify.slice::sort_by— documentation std (en anglais) : comment fonctionnent les closures de comparaison, et pourquoi le tri est stable (les éléments égaux gardent leur ordre).- Largeur et alignement — documentation
std::fmt(en anglais) : toutes les options de remplissage et d’alignement, comme{:<10}et{:>6}, utilisées dans les tableaux de cette page.