Salta ai contenuti

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 6
cat 3
happy 2

I 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.

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 parole
▶ Provalo tu

Funziona, 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.

Normalizzare una parola significa metterla in un’unica forma standard. Faremo due cose:

  1. Togliere la punteggiatura all’inizio e alla fine, con trim_matches.
  2. 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! -> dog
▶ Provalo tu

trim_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.

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: 6
cat: 3
happy: 2
11 parole diverse
▶ Provalo tu

Leggiamo 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 uno 0 se la parola è nuova. In ogni caso restituisce un riferimento mutabile al conteggio.
  • * segue quel riferimento fino al numero vero e proprio, e += 1 aggiunge 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.

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: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Provalo tu

La riga dell’ordinamento è la parte più delicata del progetto, quindi smontiamola:

  • sort_by ci dà due elementi, a e b, e chiede «quale viene prima?».
  • b.1.cmp(&a.1) confronta i conteggi (.1 è la seconda parte della tupla). Abbiamo scritto b prima di a apposta: 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 da a a b, 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.

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 6
cat 3
happy 2
is 2
on 2
▶ Provalo tu

.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.

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:

Terminal window
cargo new word_counter
cd word_counter
echo "The cat sat on the mat. The dog sat on the log!" > story.txt

Metti 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 --:

Terminal window
cargo run -- story.txt 3
12 parole, 7 diverse
PAROLA VOLTE
----------------
the 4
on 2
sat 2

E se ti dimentichi il nome del file, ottieni il messaggio d’uso invece di un crash:

Terminal window
cargo run
Uso: 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 5
rust 3
fun 2
it 2
▶ Provalo tu

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; // giusto
counts.entry(word).or_insert(0) += 1; // errore
error[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.

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 HashSet e 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 clap per gli argomenti. Esegui cargo add clap --features derive e aggiungi veri flag come --top 5 e --ignore-case, più un messaggio --help gratis. Guarda la pagina Argomenti da riga di comando per un esempio.
  • Scrivi dei test. Sposta le funzioni in src/lib.rs e aggiungi qualche test per normalize e count_words.
  • split_whitespace() taglia il testo in parole; trim_matches e to_lowercase le 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 Vec e usa sort_by con 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 con process::exit(1).

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