Projeto: Contador de palavras em Rust
Neste projeto você vai construir uma ferramenta pequena, mas de verdade: um contador de palavras. Você entrega um texto, e ele diz quais palavras aparecem mais vezes. Quem escreve usa ferramentas assim para descobrir as palavras que repete demais. Os buscadores usam a mesma ideia para entender do que uma página trata.
Vamos construir em passos pequenos. Cada passo é um programa completo que roda, então você pode parar quando quiser, brincar com ele e depois continuar. No caminho, você vai usar strings, HashMaps, ordenação, saída formatada e argumentos de linha de comando.
No final, o seu programa vai mostrar algo assim:
PALAVRA VEZES----------------the 6cat 3happy 2Um aviso antes de começar: os textos de exemplo desta página estão em inglês. Assim, as contagens, a ordem e o alinhamento das tabelas ficam exatamente iguais aos da versão original do tutorial. O seu contador funciona do mesmo jeito com texto em português: to_lowercase e is_alphanumeric entendem letras como ã, é e ç.
Passo 1: Divida o texto em palavras
Seção intitulada “Passo 1: Divida o texto em palavras”A primeira tarefa é cortar o texto em palavras. split_whitespace() divide uma string nos espaços, tabulações e quebras de linha, e pula os pedaços vazios entre eles:
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!("{} palavras", words.len());}Saída:
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 palavrasFunciona, mas olhe com atenção. "The" e "the" são tratadas como palavras diferentes, e "mat." ainda está com o ponto final. Se contássemos agora, o resultado estaria errado. Vamos arrumar isso primeiro.
Passo 2: Normalize cada palavra
Seção intitulada “Passo 2: Normalize cada palavra”Normalizar uma palavra quer dizer colocá-la numa forma padrão. Vamos fazer duas coisas:
- Tirar a pontuação do começo e do fim, com
trim_matches. - Passar tudo para minúsculas, com
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} -> (ignorada)", word); } else { println!("{:>6} -> {}", word, clean); } }}Saída:
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (ignorada) said -> said the -> the DOG! -> dogtrim_matches recebe uma closure (função anônima) que decide quais caracteres remover. Aqui ela remove tudo o que não for letra ou dígito, mas só nas pontas. É por isso que o apóstrofo de don't continua lá. Uma “palavra” feita só de pontuação, como --, vira uma string vazia, e nós a ignoramos.
Passo 3: Conte com um HashMap
Seção intitulada “Passo 3: Conte com um HashMap”Agora vem a ideia principal. Um HashMap liga cada palavra ao número de vezes que ela apareceu. A API entry deixa a contagem bem curta:
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!("{} palavras diferentes", counts.len());}Saída:
the: 6cat: 3happy: 211 palavras diferentesVamos ler *counts.entry(clean).or_insert(0) += 1; com calma:
counts.entry(clean)encontra o lugar no mapa para essa palavra..or_insert(0)coloca um0ali se a palavra for nova. Nos dois casos, ele devolve uma referência mutável para o contador.*segue essa referência até o número em si, e+= 1soma um.
A \ no fim da primeira linha do texto só serve para continuar a string na linha seguinte. E counts["the"] busca uma chave diretamente. Se a chave não existir, dá panic, mas aqui não tem problema, porque sabemos que essas palavras estão no texto. Para chaves que podem faltar, use .get().
Por que não mostramos o mapa inteiro com um loop? Porque um HashMap não tem ordem fixa. Se você percorrer o mapa, a ordem pode mudar a cada execução do programa. Para ter uma ordem estável e útil, precisamos ordenar.
Passo 4: Ordene pela contagem e depois pela palavra
Seção intitulada “Passo 4: Ordene pela contagem e depois pela palavra”Passamos os pares do mapa para um Vec e ordenamos. Queremos a maior contagem primeiro. Quando duas palavras têm a mesma contagem, queremos que fiquem em ordem alfabética, para que a saída seja sempre a mesma:
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}"); }}Saída:
the: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1A linha de ordenação é a parte mais difícil deste projeto, então vamos desmontá-la:
sort_bynos entrega dois itens,aeb, e pergunta “qual vem primeiro?”.b.1.cmp(&a.1)compara as contagens (.1é a segunda parte da tupla). Escrevemosbantes deade propósito: isso inverte a ordem, e as contagens maiores vêm primeiro..then(a.0.cmp(&b.0))só é usado quando as contagens são iguais. Ele compara as palavras na ordem normal, deaparab, então os empates ficam em ordem alfabética.
Um detalhe se você contar texto em português: essa ordem “alfabética” compara o código de cada caractere, então palavras que começam com á ou é vão parar depois do z.
A página Ordenação e busca tem mais exemplos de sort_by e companhia.
Passo 5: Mostre uma tabela com as N primeiras
Seção intitulada “Passo 5: Mostre uma tabela com as N primeiras”Uma lista longa é difícil de ler. Vamos mostrar só as primeiras palavras, em colunas bem alinhadas. Os especificadores de formato cuidam do alinhamento para nós:
{:<10}quer dizer “alinhar à esquerda num espaço de 10 caracteres”.{:>5}quer dizer “alinhar à direita num espaço de 5 caracteres”. Números costumam ficar melhor alinhados à direita.
Os cabeçalhos PALAVRA (7 letras) e VEZES (5 letras) cabem nessas larguras e não têm acentos, então a tabela continua alinhada com a linha de 16 traços.
Também vamos separar o código em funções, para que o main se leia como uma história curta:
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}", "PALAVRA", "VEZES"); 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);}Saída:
PALAVRA VEZES----------------the 6cat 3happy 2is 2on 2.take(top_n) para depois de top_n itens. Se houver menos palavras do que isso, ele simplesmente para antes, então você nunca precisa se preocupar em passar do fim.
Repare que print_table recebe &[(String, usize)], um slice (fatia). Isso quer dizer que ela só pega emprestada a lista, e funciona com qualquer Vec de pares.
Passo 6: A versão final, lendo um arquivo
Seção intitulada “Passo 6: A versão final, lendo um arquivo”Um contador de palavras de verdade deveria ler um arquivo. Vamos pegar o caminho do arquivo da linha de comando, e um segundo argumento opcional com quantas linhas mostrar. Se algo der errado, mostramos uma mensagem amigável com eprintln! (que escreve na saída de erros) e saímos com o código 1.
Crie o projeto e um arquivo de texto para testar:
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtColoque isto em src/main.rs. Esta versão é para a sua máquina, porque o Playground não consegue ler os seus arquivos:
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}", "PALAVRA", "VEZES"); 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] é o nome do programa, args[1] é o caminho do arquivo let Some(path) = args.get(1) else { eprintln!("Uso: word_counter <arquivo> [top_n]"); process::exit(1); };
// o segundo argumento é opcional; usa 10 se ele faltar ou não for um número 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!("Não foi possível ler {path}: {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} palavras, {} diferentes\n", total, pairs.len()); print_table(&pairs, top_n);}Rode o programa. Lembre que os argumentos para o seu programa vêm depois de --:
cargo run -- story.txt 312 palavras, 7 diferentes
PALAVRA VEZES----------------the 4on 2sat 2E se você esquecer o nome do arquivo, recebe a mensagem de uso em vez de um travamento:
cargo runUso: word_counter <arquivo> [top_n]let Some(path) = args.get(1) else { ... }; se chama let-else. Quer dizer “se este padrão casar, siga em frente; senão, rode o bloco else, que precisa parar a execução (aqui, saindo do programa)”. A página Arquivos em Rust explica read_to_string e os erros dele com mais detalhes. Se o arquivo não existir, {e} mostra algo como No such file or directory (os error 2) (“arquivo ou diretório não encontrado”). Essa mensagem vem do sistema, por isso costuma aparecer em inglês.
Aqui está o mesmo programa com o texto dentro do código, para você rodar no Playground. Só o main muda:
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}", "PALAVRA", "VEZES"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // imagine que isto veio de um arquivo 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!("{} palavras, {} diferentes\n", total, pairs.len()); print_table(&pairs, top_n);}Saída:
20 palavras, 11 diferentes
PALAVRA VEZES----------------is 5rust 3fun 2it 2Erros comuns
Seção intitulada “Erros comuns”Esquecer o * na contagem. or_insert devolve uma referência para o número, não o número em si. Sem o *, o Rust se recusa a somar:
*counts.entry(word).or_insert(0) += 1; // corretocounts.entry(word).or_insert(0) += 1; // erroerror[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; | +O compilador avisa que += não pode ser usado numa referência mutável (&mut {integer}) e ainda mostra a correção: adicionar o * para chegar ao número.
Imprimir o HashMap direto. println!("{:?}", counts) funciona, mas a ordem é aleatória e pode mudar entre execuções. Quando a ordem importa, passe sempre os dados para um Vec e ordene antes.
Ordenar só pela contagem. Se você ordenar apenas com b.1.cmp(&a.1), palavras com a mesma contagem podem aparecer em qualquer ordem. Adicione o desempate com .then(...) para que a saída seja sempre a mesma.
Ideias para ir além
Seção intitulada “Ideias para ir além”Você tem uma ferramenta funcionando. Aqui vão algumas formas de deixá-la com a sua cara, da mais fácil para a mais difícil:
- Ignore as stop words (palavras muito comuns). Palavras como “the”, “a”, “is” e “and” (ou, em português, “o”, “a”, “de” e “e”) estão em todo lugar e não dizem muita coisa. Coloque-as num
HashSete pule qualquer palavra que esteja nele. - Mostre porcentagens. Ao lado de cada contagem, mostre quanto ela representa do total, como
the 6 28.6%. Use{:.1}para mostrar uma casa decimal. - Leia da entrada padrão quando não houver arquivo. Use
std::io::read_to_string(std::io::stdin())para poder mandar texto por um pipe:cat story.txt | cargo run. - Conte vários arquivos. Percorra cada caminho em
args[1..]e some todas as palavras num único mapa. - Use o
clappara os argumentos. Rodecargo add clap --features derivee adicione opções de verdade como--top 5e--ignore-case, além de uma mensagem--helpde brinde. Veja um exemplo na página Argumentos de linha de comando. - Escreva testes. Mova as funções para
src/lib.rse adicione alguns testes paranormalizeecount_words.
split_whitespace()corta o texto em palavras;trim_matcheseto_lowercaseas normalizam.*map.entry(key).or_insert(0) += 1é o jeito padrão de contar coisas com um HashMap.- Um HashMap não tem ordem. Passe os dados para um
Vece usesort_bycom um desempate para ter uma saída estável. {:<10}e{:>5}alinham o texto em colunas;.take(n)limita quantas linhas você mostra.- Leia o caminho do arquivo com
env::args(), trate os erros com uma mensagem amigável e saia comprocess::exit(1).
Teste o que aprendeu
Seção intitulada “Teste o que aprendeu”Teste o que aprendeu
1. O que "Olá!".trim_matches(|c: char| !c.is_alphanumeric()) retorna?
2. Por que passamos o HashMap para um Vec antes de imprimir?
3. Em pairs.sort_by(|a, b| b.1.cmp(&a.1)), por que o b vem antes do a?
4. O que {:>5} faz no println!?
Pontuação: 0 / 4
Para saber mais
Seção intitulada “Para saber mais”- Storing Keys with Associated Values in Hash Maps — The Rust Book (em inglês): inclui um exemplo de contagem de palavras com
entryeor_insert, bem parecido com o Passo 3. - An I/O Project: Building a Command Line Program — The Rust Book (em inglês): constrói um pequeno clone do
grepque lê argumentos e arquivos, as mesmas habilidades do Passo 6, com mais profundidade. HashMap::entry— documentação da std (em inglês): retorna umEntry, cujos métodos incluemor_insert,or_defaulteand_modify.slice::sort_by— documentação da std (em inglês): como funcionam as closures de comparação e por que a ordenação é estável (itens iguais mantêm a ordem).- Largura e alinhamento — documentação de
std::fmt(em inglês): todas as opções de preenchimento e alinhamento, como{:<10}e{:>6}, usadas nas tabelas desta página.