Pular para o conteúdo

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

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

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 palavras
▶ Experimente você mesmo

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

Normalizar uma palavra quer dizer colocá-la numa forma padrão. Vamos fazer duas coisas:

  1. Tirar a pontuação do começo e do fim, com trim_matches.
  2. 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! -> dog
▶ Experimente você mesmo

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

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: 6
cat: 3
happy: 2
11 palavras diferentes
▶ Experimente você mesmo

Vamos 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 um 0 ali 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 += 1 soma 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: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Experimente você mesmo

A linha de ordenação é a parte mais difícil deste projeto, então vamos desmontá-la:

  • sort_by nos entrega dois itens, a e b, e pergunta “qual vem primeiro?”.
  • b.1.cmp(&a.1) compara as contagens (.1 é a segunda parte da tupla). Escrevemos b antes de a de 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, de a para b, 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.

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 6
cat 3
happy 2
is 2
on 2
▶ Experimente você mesmo

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

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:

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

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

Terminal window
cargo run -- story.txt 3
12 palavras, 7 diferentes
PALAVRA VEZES
----------------
the 4
on 2
sat 2

E se você esquecer o nome do arquivo, recebe a mensagem de uso em vez de um travamento:

Terminal window
cargo run
Uso: 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 5
rust 3
fun 2
it 2
▶ Experimente você mesmo

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; // correto
counts.entry(word).or_insert(0) += 1; // erro
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;
| +

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.

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 HashSet e 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 clap para os argumentos. Rode cargo add clap --features derive e adicione opções de verdade como --top 5 e --ignore-case, além de uma mensagem --help de brinde. Veja um exemplo na página Argumentos de linha de comando.
  • Escreva testes. Mova as funções para src/lib.rs e adicione alguns testes para normalize e count_words.
  • split_whitespace() corta o texto em palavras; trim_matches e to_lowercase as 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 Vec e use sort_by com 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 com process::exit(1).

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