Progetto: contatore di parole in Rust
In questo progetto costruisci uno strumento piccolo ma vero: un contatore di parole. Gli dai un testo, e lui ti dice quali parole compaiono più spesso. Chi scrive usa strumenti così per scoprire le parole che ripete troppo. I motori di ricerca usano la stessa idea per capire di cosa parla una pagina.
Lo costruiamo a piccoli passi. Ogni passo è un programma completo che funziona, quindi puoi fermarti quando vuoi, giocarci un po’ e poi continuare. Lungo la strada userai stringhe, HashMap, ordinamento, output formattato e argomenti da riga di comando.
Alla fine il tuo programma stamperà qualcosa del genere:
PAROLA VOLTE----------------the 6cat 3happy 2I testi di esempio restano in inglese: l’ordine alfabetico e il passaggio alle minuscole danno risultati più prevedibili senza lettere accentate, e così l’output resta identico a quello che ottieni tu.
Passo 1: dividi il testo in parole
Sezione intitolata “Passo 1: dividi il testo in parole”Il primo lavoro è tagliare il testo in parole. split_whitespace() divide una stringa su spazi, tabulazioni e a capo, e salta i pezzi vuoti tra uno e l’altro:
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!("{} parole", words.len());}Output:
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 paroleFunziona, ma guarda bene. "The" e "the" vengono trattate come parole diverse, e "mat." ha ancora il suo punto. Se contassimo adesso, i risultati sarebbero sbagliati. Sistemiamo prima questo.
Passo 2: normalizza ogni parola
Sezione intitolata “Passo 2: normalizza ogni parola”Normalizzare una parola significa metterla in un’unica forma standard. Faremo due cose:
- Togliere la punteggiatura all’inizio e alla fine, con
trim_matches. - Renderla minuscola, con
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} -> (saltata)", word); } else { println!("{:>6} -> {}", word, clean); } }}Output:
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (saltata) said -> said the -> the DOG! -> dogtrim_matches prende una closure che decide quali caratteri togliere. Qui toglie tutto ciò che non è una lettera o una cifra, ma solo alle estremità. Ecco perché l’apostrofo in don't sopravvive (lo stesso varrebbe per un dell'orto italiano). Una «parola» fatta solo di punteggiatura, come --, diventa una stringa vuota, quindi la saltiamo.
Passo 3: conta con una HashMap
Sezione intitolata “Passo 3: conta con una HashMap”Ora l’idea principale. Una HashMap associa a ogni parola quante volte l’abbiamo vista. L’API entry rende il conteggio cortissimo:
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!("{} parole diverse", counts.len());}Output:
the: 6cat: 3happy: 211 parole diverseLeggiamo con calma *counts.entry(clean).or_insert(0) += 1;:
counts.entry(clean)trova il posto di questa parola nella mappa..or_insert(0)ci mette uno0se la parola è nuova. In ogni caso restituisce un riferimento mutabile al conteggio.*segue quel riferimento fino al numero vero e proprio, e+= 1aggiunge uno.
La \ alla fine della prima riga del testo serve solo a continuare la stringa sulla riga successiva. E counts["the"] cerca direttamente una chiave. Va in panic se la chiave manca, il che qui va bene perché sappiamo che quelle parole sono nel testo. Per le chiavi che potrebbero mancare, usa .get().
Perché non abbiamo stampato tutta la mappa con un ciclo? Una HashMap non ha un ordine fisso. Se ci giri sopra con un ciclo, l’ordine può cambiare a ogni esecuzione del programma. Per avere un ordine stabile e utile, dobbiamo ordinare.
Passo 4: ordina per conteggio, poi per parola
Sezione intitolata “Passo 4: ordina per conteggio, poi per parola”Spostiamo le coppie della mappa in un Vec, poi lo ordiniamo. Vogliamo prima il conteggio più alto. Quando due parole hanno lo stesso conteggio, le vogliamo in ordine alfabetico, così l’output è sempre lo stesso:
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}"); }}Output:
the: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1La riga dell’ordinamento è la parte più delicata del progetto, quindi smontiamola:
sort_byci dà due elementi,aeb, e chiede «quale viene prima?».b.1.cmp(&a.1)confronta i conteggi (.1è la seconda parte della tupla). Abbiamo scrittobprima diaapposta: così l’ordine si inverte, e i conteggi grandi vengono per primi..then(a.0.cmp(&b.0))si usa solo quando i conteggi sono uguali. Confronta le parole nel normale ordine daaab, quindi i pari merito sono in ordine alfabetico.
Un dettaglio se conti testo in italiano: questo ordine «alfabetico» confronta il codice di ogni carattere, quindi le parole che iniziano con una lettera accentata come è finiscono dopo la z.
La pagina Ordinamento e ricerca ha altri esempi di sort_by e simili.
Passo 5: stampa una tabella con le prime N parole
Sezione intitolata “Passo 5: stampa una tabella con le prime N parole”Una lista lunga è difficile da leggere. Mostriamo solo le prime parole, in colonne ordinate. Gli specificatori di formato si occupano dell’allineamento:
{:<10}significa «allinea a sinistra in uno spazio largo 10 caratteri».{:>5}significa «allinea a destra in uno spazio largo 5 caratteri». I numeri di solito stanno meglio allineati a destra.
Le intestazioni PAROLA (6 lettere) e VOLTE (5 lettere) stanno in quelle larghezze, quindi la tabella resta allineata con la riga di 16 trattini. Spostiamo anche il codice in funzioni, così main si legge come un breve racconto:
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}", "PAROLA", "VOLTE"); 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);}Output:
PAROLA VOLTE----------------the 6cat 3happy 2is 2on 2.take(top_n) si ferma dopo top_n elementi. Se le parole sono meno di così, si ferma semplicemente prima, quindi non devi mai preoccuparti di andare oltre la fine.
Nota che print_table prende &[(String, usize)], una slice. Significa che prende solo in prestito la lista, e funziona con qualsiasi Vec di coppie.
Passo 6: la versione finale, che legge un file
Sezione intitolata “Passo 6: la versione finale, che legge un file”Un vero contatore di parole dovrebbe leggere un file. Prendiamo il percorso del file dalla riga di comando, più un secondo argomento facoltativo per il numero di righe da mostrare. Se qualcosa va storto, stampiamo un messaggio gentile con eprintln! (che scrive sull’output degli errori) e usciamo con il codice 1.
Crea il progetto e un file di testo per le prove:
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtMetti questo in src/main.rs. Questa versione è per il tuo computer, perché il Playground non può leggere i tuoi file:
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}", "PAROLA", "VOLTE"); 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] è il nome del programma, args[1] è il percorso del file let Some(path) = args.get(1) else { eprintln!("Uso: word_counter <file> [top_n]"); process::exit(1); };
// il secondo argomento è facoltativo; usa 10 se manca o non è un numero 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!("Impossibile leggere {path}: {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} parole, {} diverse\n", total, pairs.len()); print_table(&pairs, top_n);}Eseguilo. Ricorda che gli argomenti per il tuo programma vanno dopo --:
cargo run -- story.txt 312 parole, 7 diverse
PAROLA VOLTE----------------the 4on 2sat 2E se ti dimentichi il nome del file, ottieni il messaggio d’uso invece di un crash:
cargo runUso: word_counter <file> [top_n]let Some(path) = args.get(1) else { ... }; si chiama let-else. Significa «se questo pattern corrisponde, vai avanti; altrimenti esegui il blocco else, che deve fermarsi (qui, uscendo dal programma)». La pagina I/O su file spiega più nel dettaglio read_to_string e i suoi errori.
Ecco lo stesso programma con il testo già dentro, così puoi eseguirlo nel Playground. Cambia solo main:
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}", "PAROLA", "VOLTE"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // facciamo finta che arrivi da un file 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!("{} parole, {} diverse\n", total, pairs.len()); print_table(&pairs, top_n);}Output:
20 parole, 11 diverse
PAROLA VOLTE----------------is 5rust 3fun 2it 2Errori comuni
Sezione intitolata “Errori comuni”Dimenticare l’* quando conti. or_insert ti dà un riferimento al numero, non il numero. Senza *, Rust si rifiuta di sommarci qualcosa:
*counts.entry(word).or_insert(0) += 1; // giustocounts.entry(word).or_insert(0) += 1; // erroreerror[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; | +Il compilatore dice che += non si può usare su un &mut {integer}, e ti mostra perfino la soluzione: aggiungi l’* per dereferenziare il lato sinistro.
Stampare direttamente la HashMap. println!("{:?}", counts) funziona, ma l’ordine è casuale e può cambiare da un’esecuzione all’altra. Quando l’ordine conta, ordina sempre prima in un Vec.
Ordinare solo per conteggio. Se ordini solo con b.1.cmp(&a.1), le parole con lo stesso conteggio possono comparire in qualsiasi ordine. Aggiungi lo spareggio .then(...), così l’output è sempre lo stesso.
Idee per ampliarlo
Sezione intitolata “Idee per ampliarlo”Hai uno strumento che funziona. Ecco alcuni modi per farlo tuo, dal più facile al più difficile:
- Ignora le stop word. Parole come «il», «di», «è» ed «e» sono ovunque e non molto interessanti. Mettile in un
HashSete salta ogni parola che ci sta dentro. - Mostra le percentuali. Accanto a ogni conteggio, stampa che quota di tutte le parole rappresenta, come
the 6 28.6%. Usa{:.1}per mostrare una cifra decimale. - Leggi da stdin quando non c’è un file. Usa
std::io::read_to_string(std::io::stdin())così puoi passare il testo con una pipe:cat story.txt | cargo run. - Conta più file. Fai un ciclo su ogni percorso in
args[1..]e aggiungi tutte le loro parole alla stessa mappa. - Usa
clapper gli argomenti. Eseguicargo add clap --features derivee aggiungi veri flag come--top 5e--ignore-case, più un messaggio--helpgratis. Guarda la pagina Argomenti da riga di comando per un esempio. - Scrivi dei test. Sposta le funzioni in
src/lib.rse aggiungi qualche test pernormalizeecount_words.
Riepilogo
Sezione intitolata “Riepilogo”split_whitespace()taglia il testo in parole;trim_matcheseto_lowercasele normalizzano.*map.entry(key).or_insert(0) += 1è il modo standard per contare le cose con una HashMap.- Una HashMap non ha un ordine. Raccogli le coppie in un
Vece usasort_bycon uno spareggio per avere un output stabile. {:<10}e{:>5}allineano il testo in colonne;.take(n)limita quante righe stampi.- Leggi il percorso del file da
env::args(), gestisci gli errori con un messaggio gentile ed esci conprocess::exit(1).
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Cosa restituisce "Hello!".trim_matches(|c: char| !c.is_alphanumeric())?
2. Perché raccogliamo la HashMap in un Vec prima di stampare?
3. In pairs.sort_by(|a, b| b.1.cmp(&a.1)), perché b è scritto prima di a?
4. Cosa fa {:>5} in println!?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- Storing Keys with Associated Values in Hash Maps — The Rust Book (in inglese): include un esempio di conteggio delle parole con
entryeor_insert, molto vicino al Passo 3. - An I/O Project: Building a Command Line Program — The Rust Book (in inglese): costruisce un piccolo clone di
grepche legge argomenti e file, le stesse competenze del Passo 6, più in profondità. HashMap::entry— documentazione di std (in inglese): restituisce unaEntry, i cui metodi includonoor_insert,or_defaulteand_modify.slice::sort_by— documentazione di std (in inglese): come funzionano le closure di confronto, e perché l’ordinamento è stabile (gli elementi uguali mantengono il loro ordine).- Width and alignment — documentazione di
std::fmt(in inglese): tutte le opzioni di riempimento e allineamento, come{:<10}e{:>6}, usate nelle tabelle di questa pagina.