Перейти к содержимому

Проект: счётчик слов на Rust

В этом проекте ты напишешь маленькую, но настоящую утилиту — счётчик слов. Даёшь ей текст, а она говорит, какие слова встречаются чаще всего. Авторы пользуются такими инструментами, чтобы найти слова, которые они повторяют слишком часто. Поисковые системы используют ту же идею, чтобы понять, о чём страница.

Будем двигаться маленькими шагами. Каждый шаг — это законченная программа, которая запускается, так что можно остановиться в любой момент, поиграть с кодом и потом продолжить. По пути тебе пригодятся строки, хеш-таблицы (HashMap), сортировка, форматированный вывод и аргументы командной строки.

В конце программа будет печатать примерно такое:

СЛОВО РАЗ
----------------
the 6
cat 3
happy 2

Сам анализируемый текст в примерах оставлен на английском: порядок слов с одинаковым счётом зависит от сортировки по буквам, и так результат совпадает с оригинальным уроком. Подписи и сообщения мы переведём.

Первая задача — нарезать текст на слова. split_whitespace() делит строку по пробелам, табуляциям и переводам строк и пропускает пустые кусочки между ними:

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!("Слов: {}", words.len());
}

Вывод:

["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]
Слов: 12
▶ Попробуй сам

Работает, но присмотрись. "The" и "the" считаются разными словами, а у "mat." осталась точка. Если считать прямо сейчас, результат будет неверным. Сначала исправим это.

Нормализовать слово — значит привести его к одной стандартной форме. Сделаем две вещи:

  1. Уберём знаки препинания в начале и в конце с помощью trim_matches.
  2. Переведём в нижний регистр с помощью 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} -> (пропущено)", word);
} else {
println!("{:>6} -> {}", word, clean);
}
}
}

Вывод:

The -> the
cat -> cat
sat -> sat
on -> on
the -> the
mat. -> mat
Don't -> don't
stop -> stop
-- -> (пропущено)
said -> said
the -> the
DOG! -> dog
▶ Попробуй сам

trim_matches принимает замыкание, которое решает, какие символы убрать. Здесь оно убирает всё, что не буква и не цифра, но только по краям. Поэтому апостроф в don't остаётся на месте. «Слово», состоящее только из знаков препинания, вроде --, превращается в пустую строку, и мы его пропускаем.

Теперь главная идея. HashMap сопоставляет каждому слову, сколько раз мы его встретили. С entry API подсчёт занимает одну строку:

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!("Разных слов: {}", counts.len());
}

Вывод:

the: 6
cat: 3
happy: 2
Разных слов: 11
▶ Попробуй сам

Прочитаем *counts.entry(clean).or_insert(0) += 1; медленно:

  • counts.entry(clean) находит место в таблице для этого слова.
  • .or_insert(0) кладёт туда 0, если слово новое. В любом случае он возвращает изменяемую ссылку на счётчик.
  • * переходит по этой ссылке к самому числу, а += 1 прибавляет единицу.

\ в конце первой строки текста просто продолжает строковый литерал на следующей строке. А counts["the"] ищет ключ напрямую. Если ключа нет, программа запаникует, но здесь это нормально: мы знаем, что эти слова в тексте есть. Для ключей, которых может не быть, используй .get().

Почему мы не напечатали всю таблицу в цикле? У HashMap нет фиксированного порядка. Если пройтись по ней циклом, порядок может меняться при каждом запуске. Чтобы получить стабильный и полезный порядок, нужно отсортировать.

Переносим пары из таблицы в Vec и сортируем его. Мы хотим, чтобы наибольший счёт шёл первым. Если у двух слов счёт одинаковый, пусть они идут по алфавиту — тогда вывод всегда будет одним и тем же:

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}");
}
}

Вывод:

the: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Попробуй сам

Строка с сортировкой — самая хитрая часть проекта, так что разберём её по кусочкам:

  • sort_by даёт нам два элемента, a и b, и спрашивает: «кто идёт первым?».
  • b.1.cmp(&a.1) сравнивает счёт (.1 — вторая часть кортежа). Мы специально написали b перед a: так порядок переворачивается, и большие числа идут первыми.
  • .then(a.0.cmp(&b.0)) срабатывает только при равном счёте. Он сравнивает слова в обычном порядке от a к b, поэтому слова с одинаковым счётом идут по алфавиту.

Больше примеров с sort_by и похожими методами — в уроке Сортировка.

Длинный список читать тяжело. Покажем только несколько самых частых слов, ровными колонками. Выравнивание за нас сделают спецификаторы формата:

  • {:<10} значит «выровнять по левому краю в поле шириной 10 символов».
  • {:>5} значит «выровнять по правому краю в поле шириной 5 символов». Числа обычно лучше смотрятся по правому краю.

Заодно разнесём код по функциям, чтобы 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}", "СЛОВО", "РАЗ");
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);
}

Вывод:

СЛОВО РАЗ
----------------
the 6
cat 3
happy 2
is 2
on 2
▶ Попробуй сам

.take(top_n) останавливается после top_n элементов. Если слов меньше, он просто закончит раньше, так что не нужно бояться выйти за конец списка. Ширина полей в Rust считается в символах, а не в байтах, поэтому заголовки на кириллице выравниваются так же ровно.

Обрати внимание: print_table принимает &[(String, usize)], то есть срез. Значит, она только заимствует список и подходит для любого Vec из пар.

Шаг 6: финальная версия, которая читает файл

Заголовок раздела «Шаг 6: финальная версия, которая читает файл»

Настоящий счётчик слов должен читать файл. Путь к файлу возьмём из командной строки, а необязательный второй аргумент скажет, сколько строк показать. Если что-то пошло не так, выводим понятное сообщение через eprintln! (он пишет в поток ошибок) и завершаемся с кодом 1.

Создай проект и текстовый файл для проверки:

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

Положи это в src/main.rs. Эта версия для твоего компьютера, потому что Playground не умеет читать твои файлы:

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}", "СЛОВО", "РАЗ");
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] — имя программы, args[1] — путь к файлу
let Some(path) = args.get(1) else {
eprintln!("Использование: word_counter <файл> [top_n]");
process::exit(1);
};
// второй аргумент необязателен; если его нет или это не число, берём 10
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!("Не удалось прочитать {path}: {e}");
process::exit(1);
}
};
let pairs = sorted_counts(count_words(&text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("Слов: {}, разных: {}\n", total, pairs.len());
print_table(&pairs, top_n);
}

Запусти. Помни, что аргументы для твоей программы пишутся после --:

Terminal window
cargo run -- story.txt 3
Слов: 12, разных: 7
СЛОВО РАЗ
----------------
the 4
on 2
sat 2

А если забыть имя файла, вместо падения ты увидишь подсказку по использованию:

Terminal window
cargo run
Использование: word_counter <файл> [top_n]

let Some(path) = args.get(1) else { ... }; называется let-else. Это значит: «если образец подошёл, идём дальше; иначе выполняем блок else, который обязан прервать выполнение (здесь — выходом из программы)». Подробнее о read_to_string и его ошибках — в уроке Работа с файлами.

Вот та же программа со встроенным текстом, чтобы её можно было запустить в Playground. Отличается только 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}", "СЛОВО", "РАЗ");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
// представим, что это прочитано из файла
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!("Слов: {}, разных: {}\n", total, pairs.len());
print_table(&pairs, top_n);
}

Вывод:

Слов: 20, разных: 11
СЛОВО РАЗ
----------------
is 5
rust 3
fun 2
it 2
▶ Попробуй сам

Забыть * при подсчёте. or_insert даёт ссылку на число, а не само число. Без * Rust откажется к нему прибавлять:

*counts.entry(word).or_insert(0) += 1; // правильно
counts.entry(word).or_insert(0) += 1; // ошибка
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;
| +

Компилятор сообщает, что += нельзя применить к типу &mut {integer}, и даже показывает исправление: добавь *.

Печатать HashMap напрямую. println!("{:?}", counts) работает, но порядок случайный и может меняться от запуска к запуску. Если порядок важен, всегда сначала сортируй в Vec.

Сортировать только по счёту. Если сортировать одним b.1.cmp(&a.1), слова с одинаковым счётом могут оказаться в любом порядке. Добавь .then(...) для равных случаев, чтобы вывод каждый раз был одинаковым.

У тебя есть рабочая утилита. Вот несколько способов сделать её своей — от простого к сложному:

  • Игнорируй стоп-слова. Слова вроде «the», «a», «is» и «and» встречаются везде и не очень интересны. Сложи их в HashSet и пропускай каждое слово, которое там есть.
  • Показывай проценты. Рядом с каждым счётом выводи долю от всех слов, например the 6 28.6%. Чтобы показать один знак после запятой, используй {:.1}.
  • Читай из stdin, если файл не указан. Используй std::io::read_to_string(std::io::stdin()), чтобы можно было передавать текст через конвейер: cat story.txt | cargo run.
  • Считай несколько файлов. Пройдись по каждому пути в args[1..] и сложи все слова в одну таблицу.
  • Возьми clap для аргументов. Выполни cargo add clap --features derive и добавь настоящие флаги вроде --top 5 и --ignore-case, а заодно бесплатное сообщение --help. Пример есть в уроке Аргументы командной строки.
  • Напиши тесты. Перенеси функции в src/lib.rs и добавь несколько тестов для normalize и count_words.
  • split_whitespace() режет текст на слова; trim_matches и to_lowercase их нормализуют.
  • *map.entry(key).or_insert(0) += 1 — стандартный способ считать что-либо с помощью HashMap.
  • У HashMap нет порядка. Собери пары в Vec и сортируй через sort_by с правилом для равных значений, чтобы вывод был стабильным.
  • {:<10} и {:>5} выравнивают текст по колонкам; .take(n) ограничивает число выводимых строк.
  • Путь к файлу бери из env::args(), ошибки обрабатывай понятным сообщением и выходи через process::exit(1).

Проверь себя

1. Что вернёт "Hello!".trim_matches(|c: char| !c.is_alphanumeric())?

2. Зачем мы собираем HashMap в Vec перед печатью?

3. Почему в pairs.sort_by(|a, b| b.1.cmp(&a.1)) b стоит перед a?

4. Что делает {:>5} в println!?

Результат: 0 / 4