Proyecto: Contador de palabras en Rust
En este proyecto vas a construir una herramienta pequeña pero real: un contador de palabras. Le das un texto y te dice qué palabras aparecen más veces. Quienes escriben usan herramientas así para descubrir qué palabras repiten demasiado. Los buscadores usan la misma idea para entender de qué trata una página.
Lo construiremos en pasos pequeños. Cada paso es un programa completo que funciona, así que puedes detenerte cuando quieras, experimentar con él y luego seguir. Por el camino vas a usar cadenas de texto, HashMaps, ordenación, salida con formato y argumentos de línea de comandos.
Al final, tu programa mostrará algo así:
PALABRA VECES----------------the 6cat 3happy 2Una aclaración antes de empezar: los textos de ejemplo de esta página están en inglés. Así los conteos y las tablas son exactamente los mismos que en la versión original del tutorial. Tu contador funciona igual de bien con texto en español: to_lowercase y is_alphanumeric entienden letras como á o ñ.
Paso 1: Divide el texto en palabras
Sección titulada «Paso 1: Divide el texto en palabras»Lo primero es cortar el texto en palabras. split_whitespace() divide una cadena por los espacios, tabuladores y saltos de línea, y se salta los trozos vacíos que quedan entre ellos:
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!("{} palabras", words.len());}Salida:
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 palabrasFunciona, pero mira con atención. "The" y "the" cuentan como palabras distintas, y "mat." todavía lleva su punto. Si contáramos ahora, los resultados estarían mal. Arreglemos eso primero.
Paso 2: Normaliza cada palabra
Sección titulada «Paso 2: Normaliza cada palabra»Normalizar una palabra significa llevarla a una forma estándar. Haremos dos cosas:
- Quitar los signos de puntuación del principio y del final, con
trim_matches. - Pasarla a minúsculas, 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} -> (omitida)", word); } else { println!("{:>6} -> {}", word, clean); } }}Salida:
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (omitida) said -> said the -> the DOG! -> dogtrim_matches recibe una closure (función anónima) que decide qué caracteres quitar. Aquí quita todo lo que no sea una letra o un dígito, pero solo en los extremos. Por eso el apóstrofo de don't se queda. Una “palabra” hecha solo de puntuación, como --, se convierte en una cadena vacía, y la omitimos.
Paso 3: Cuenta con un HashMap
Sección titulada «Paso 3: Cuenta con un HashMap»Ahora viene la idea principal. Un HashMap relaciona cada palabra con las veces que la hemos visto. La API entry hace que contar sea muy corto:
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!("{} palabras distintas", counts.len());}Salida:
the: 6cat: 3happy: 211 palabras distintasLeamos despacio *counts.entry(clean).or_insert(0) += 1;:
counts.entry(clean)busca el lugar del mapa que corresponde a esta palabra..or_insert(0)pone un0ahí si la palabra es nueva. En los dos casos, te devuelve una referencia mutable al contador.*sigue esa referencia hasta el número en sí, y+= 1le suma uno.
La \ al final de la primera línea del texto solo sirve para continuar la cadena en la línea siguiente. Y counts["the"] busca una clave directamente. Si la clave no existe, provoca un panic, pero aquí no pasa nada porque sabemos que esas palabras están en el texto. Para claves que podrían faltar, usa .get().
¿Por qué no mostramos el mapa completo con un bucle? Porque un HashMap no tiene un orden fijo. Si lo recorres, el orden puede cambiar cada vez que ejecutas el programa. Para tener un orden estable y útil, hay que ordenar.
Paso 4: Ordena por cantidad y luego por palabra
Sección titulada «Paso 4: Ordena por cantidad y luego por palabra»Pasamos los pares del mapa a un Vec y lo ordenamos. Queremos primero la cantidad más alta. Cuando dos palabras tienen la misma cantidad, las queremos en orden alfabético, para que la salida sea siempre igual:
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}"); }}Salida:
the: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1La línea de ordenación es la parte más difícil del proyecto, así que vamos a desarmarla:
sort_bynos da dos elementos,ayb, y nos pregunta “¿cuál va primero?”.b.1.cmp(&a.1)compara las cantidades (.1es la segunda parte de la tupla). Escribimosbantes queaa propósito: eso invierte el orden, y así las cantidades grandes van primero..then(a.0.cmp(&b.0))solo se usa cuando las cantidades son iguales. Compara las palabras en orden normal, deaab, así que los empates quedan en orden alfabético.
Un detalle si cuentas texto en español: este orden “alfabético” compara el código de cada carácter, así que las palabras que empiezan con á o ñ quedan después de la z.
En la página Ordenar y buscar hay más ejemplos de sort_by y sus parientes.
Paso 5: Muestra una tabla con las primeras N palabras
Sección titulada «Paso 5: Muestra una tabla con las primeras N palabras»Una lista larga cuesta leerla. Mostremos solo las primeras palabras, en columnas ordenadas. Los especificadores de formato se encargan de alinear:
{:<10}significa “alinear a la izquierda en un espacio de 10 caracteres”.{:>5}significa “alinear a la derecha en un espacio de 5 caracteres”. Los números suelen verse mejor alineados a la derecha.
Los encabezados PALABRA (7 letras) y VECES (5 letras) caben en esos anchos, así que la tabla queda alineada con la línea de 16 guiones.
También vamos a pasar el código a funciones, para que main se lea como una historia corta:
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}", "PALABRA", "VECES"); 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);}Salida:
PALABRA VECES----------------the 6cat 3happy 2is 2on 2.take(top_n) se detiene después de top_n elementos. Si hay menos palabras, simplemente termina antes, así que nunca tienes que preocuparte por pasarte del final.
Fíjate en que print_table recibe &[(String, usize)], un slice (porción). Eso significa que solo toma prestada la lista, y funciona con cualquier Vec de pares.
Paso 6: La versión final, que lee un archivo
Sección titulada «Paso 6: La versión final, que lee un archivo»Un contador de palabras de verdad debería leer un archivo. Tomaremos la ruta del archivo de la línea de comandos, y un segundo argumento opcional con cuántas filas mostrar. Si algo sale mal, mostramos un mensaje amable con eprintln! (que escribe en la salida de errores) y terminamos con el código 1.
Crea el proyecto y un archivo de texto para probar:
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtPon esto en src/main.rs. Esta versión es para tu propia máquina, porque el Playground no puede leer tus archivos:
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}", "PALABRA", "VECES"); 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] es el nombre del programa, args[1] es la ruta del archivo let Some(path) = args.get(1) else { eprintln!("Uso: word_counter <archivo> [top_n]"); process::exit(1); };
// el segundo argumento es opcional; usa 10 si falta o si no es un 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!("No se pudo leer {path}: {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} palabras, {} distintas\n", total, pairs.len()); print_table(&pairs, top_n);}Ejecútalo. Recuerda que los argumentos para tu programa van después de --:
cargo run -- story.txt 312 palabras, 7 distintas
PALABRA VECES----------------the 4on 2sat 2Y si olvidas el nombre del archivo, recibes el mensaje de uso en lugar de un cierre inesperado:
cargo runUso: word_counter <archivo> [top_n]let Some(path) = args.get(1) else { ... }; se llama let-else. Significa “si este patrón coincide, sigue adelante; si no, ejecuta el bloque else, que tiene que detenerse (aquí, terminando el programa)”. La página de Archivos en Rust explica read_to_string y sus errores con más detalle. Si el archivo no existe, {e} muestra algo como No such file or directory (os error 2) (“no existe el archivo o directorio”). Ese mensaje lo genera el sistema, por eso suele salir en inglés.
Aquí tienes el mismo programa con el texto incluido en el código, para que puedas ejecutarlo en el Playground. Solo cambia 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}", "PALABRA", "VECES"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // imagina que esto viene de un archivo 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!("{} palabras, {} distintas\n", total, pairs.len()); print_table(&pairs, top_n);}Salida:
20 palabras, 11 distintas
PALABRA VECES----------------is 5rust 3fun 2it 2Errores comunes
Sección titulada «Errores comunes»Olvidar el * al contar. or_insert te da una referencia al número, no el número en sí. Sin el *, Rust se niega a sumarle:
*counts.entry(word).or_insert(0) += 1; // correctocounts.entry(word).or_insert(0) += 1; // errorerror[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; | +El compilador te dice que += no se puede usar sobre una referencia mutable (&mut {integer}), y hasta te muestra la solución: agregar el * para llegar al número.
Mostrar el HashMap directamente. println!("{:?}", counts) funciona, pero el orden es aleatorio y puede cambiar entre ejecuciones. Cuando el orden importa, pasa siempre los datos a un Vec y ordénalo primero.
Ordenar solo por cantidad. Si ordenas solo con b.1.cmp(&a.1), las palabras con la misma cantidad pueden salir en cualquier orden. Agrega el desempate con .then(...) para que la salida sea siempre la misma.
Ideas para ampliarlo
Sección titulada «Ideas para ampliarlo»Ya tienes una herramienta que funciona. Aquí van algunas formas de hacerla tuya, de la más fácil a la más difícil:
- Ignora las palabras vacías. Palabras como “the”, “a”, “is” y “and” (o, en español, “el”, “la”, “de” y “y”) están en todas partes y no dicen mucho. Ponlas en un
HashSety sáltate cualquier palabra que esté en él. - Muestra porcentajes. Junto a cada cantidad, muestra qué parte del total representa, como
the 6 28.6%. Usa{:.1}para mostrar un decimal. - Lee de stdin si no te dan un archivo. Usa
std::io::read_to_string(std::io::stdin())para poder pasarle texto con una tubería:cat story.txt | cargo run. - Cuenta varios archivos. Recorre cada ruta de
args[1..]y suma todas sus palabras en un mismo mapa. - Usa
clappara los argumentos. Ejecutacargo add clap --features derivey agrega opciones de verdad como--top 5y--ignore-case, además de un mensaje--helpgratis. Mira un ejemplo en la página de Argumentos de línea de comandos. - Escribe tests. Pasa las funciones a
src/lib.rsy agrega algunos tests (pruebas) paranormalizeycount_words.
Resumen
Sección titulada «Resumen»split_whitespace()corta el texto en palabras;trim_matchesyto_lowercaselas normalizan.*map.entry(key).or_insert(0) += 1es la forma habitual de contar cosas con un HashMap.- Un HashMap no tiene orden. Pasa los datos a un
Vecy usasort_bycon un desempate para obtener una salida estable. {:<10}y{:>5}alinean el texto en columnas;.take(n)limita cuántas filas muestras.- Lee la ruta del archivo con
env::args(), maneja los errores con un mensaje amable y termina conprocess::exit(1).
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. ¿Qué devuelve "¡Hola!".trim_matches(|c: char| !c.is_alphanumeric())?
2. ¿Por qué pasamos el HashMap a un Vec antes de mostrarlo?
3. En pairs.sort_by(|a, b| b.1.cmp(&a.1)), ¿por qué se escribe b antes que a?
4. ¿Qué hace {:>5} en println!?
Puntuación: 0 / 4
Para leer más
Sección titulada «Para leer más»- Storing Keys with Associated Values in Hash Maps — The Rust Book (en inglés): incluye un ejemplo que cuenta palabras con
entryyor_insert, muy parecido al paso 3. - An I/O Project: Building a Command Line Program — The Rust Book (en inglés): construye un pequeño clon de
grepque lee argumentos y archivos, las mismas habilidades del paso 6, con más profundidad. HashMap::entry— documentación de std (en inglés): devuelve unEntry, cuyos métodos incluyenor_insert,or_defaultyand_modify.slice::sort_by— documentación de std (en inglés): cómo funcionan los closures de comparación, y por qué la ordenación es estable (los elementos iguales conservan su orden).- Ancho y alineación — documentación de
std::fmt(en inglés): todas las opciones de relleno y alineación, como{:<10}y{:>6}, que se usan en las tablas de esta página.