Проект: счётчик слов на Rust
В этом проекте ты напишешь маленькую, но настоящую утилиту — счётчик слов. Даёшь ей текст, а она говорит, какие слова встречаются чаще всего. Авторы пользуются такими инструментами, чтобы найти слова, которые они повторяют слишком часто. Поисковые системы используют ту же идею, чтобы понять, о чём страница.
Будем двигаться маленькими шагами. Каждый шаг — это законченная программа, которая запускается, так что можно остановиться в любой момент, поиграть с кодом и потом продолжить. По пути тебе пригодятся строки, хеш-таблицы (HashMap), сортировка, форматированный вывод и аргументы командной строки.
В конце программа будет печатать примерно такое:
СЛОВО РАЗ----------------the 6cat 3happy 2Сам анализируемый текст в примерах оставлен на английском: порядок слов с одинаковым счётом зависит от сортировки по буквам, и так результат совпадает с оригинальным уроком. Подписи и сообщения мы переведём.
Шаг 1: разбиваем текст на слова
Заголовок раздела «Шаг 1: разбиваем текст на слова»Первая задача — нарезать текст на слова. 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." осталась точка. Если считать прямо сейчас, результат будет неверным. Сначала исправим это.
Шаг 2: нормализуем каждое слово
Заголовок раздела «Шаг 2: нормализуем каждое слово»Нормализовать слово — значит привести его к одной стандартной форме. Сделаем две вещи:
- Уберём знаки препинания в начале и в конце с помощью
trim_matches. - Переведём в нижний регистр с помощью
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! -> dogtrim_matches принимает замыкание, которое решает, какие символы убрать. Здесь оно убирает всё, что не буква и не цифра, но только по краям. Поэтому апостроф в don't остаётся на месте. «Слово», состоящее только из знаков препинания, вроде --, превращается в пустую строку, и мы его пропускаем.
Шаг 3: считаем с помощью HashMap
Заголовок раздела «Шаг 3: считаем с помощью HashMap»Теперь главная идея. 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: 6cat: 3happy: 2Разных слов: 11Прочитаем *counts.entry(clean).or_insert(0) += 1; медленно:
counts.entry(clean)находит место в таблице для этого слова..or_insert(0)кладёт туда0, если слово новое. В любом случае он возвращает изменяемую ссылку на счётчик.*переходит по этой ссылке к самому числу, а+= 1прибавляет единицу.
\ в конце первой строки текста просто продолжает строковый литерал на следующей строке. А counts["the"] ищет ключ напрямую. Если ключа нет, программа запаникует, но здесь это нормально: мы знаем, что эти слова в тексте есть. Для ключей, которых может не быть, используй .get().
Почему мы не напечатали всю таблицу в цикле? У HashMap нет фиксированного порядка. Если пройтись по ней циклом, порядок может меняться при каждом запуске. Чтобы получить стабильный и полезный порядок, нужно отсортировать.
Шаг 4: сортируем по счёту, затем по слову
Заголовок раздела «Шаг 4: сортируем по счёту, затем по слову»Переносим пары из таблицы в 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: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1Строка с сортировкой — самая хитрая часть проекта, так что разберём её по кусочкам:
sort_byдаёт нам два элемента,aиb, и спрашивает: «кто идёт первым?».b.1.cmp(&a.1)сравнивает счёт (.1— вторая часть кортежа). Мы специально написалиbпередa: так порядок переворачивается, и большие числа идут первыми..then(a.0.cmp(&b.0))срабатывает только при равном счёте. Он сравнивает слова в обычном порядке отaкb, поэтому слова с одинаковым счётом идут по алфавиту.
Больше примеров с sort_by и похожими методами — в уроке Сортировка.
Шаг 5: печатаем таблицу лучших N
Заголовок раздела «Шаг 5: печатаем таблицу лучших N»Длинный список читать тяжело. Покажем только несколько самых частых слов, ровными колонками. Выравнивание за нас сделают спецификаторы формата:
{:<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 6cat 3happy 2is 2on 2.take(top_n) останавливается после top_n элементов. Если слов меньше, он просто закончит раньше, так что не нужно бояться выйти за конец списка. Ширина полей в Rust считается в символах, а не в байтах, поэтому заголовки на кириллице выравниваются так же ровно.
Обрати внимание: print_table принимает &[(String, usize)], то есть срез. Значит, она только заимствует список и подходит для любого Vec из пар.
Шаг 6: финальная версия, которая читает файл
Заголовок раздела «Шаг 6: финальная версия, которая читает файл»Настоящий счётчик слов должен читать файл. Путь к файлу возьмём из командной строки, а необязательный второй аргумент скажет, сколько строк показать. Если что-то пошло не так, выводим понятное сообщение через eprintln! (он пишет в поток ошибок) и завершаемся с кодом 1.
Создай проект и текстовый файл для проверки:
cargo new word_countercd word_counterecho "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);}Запусти. Помни, что аргументы для твоей программы пишутся после --:
cargo run -- story.txt 3Слов: 12, разных: 7
СЛОВО РАЗ----------------the 4on 2sat 2А если забыть имя файла, вместо падения ты увидишь подсказку по использованию:
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 5rust 3fun 2it 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
Что почитать
Заголовок раздела «Что почитать»- Хранение ключей со связанными значениями в HashMap — книга «Язык программирования Rust» (перевод на русский): там есть пример подсчёта слов с
entryиor_insert, очень похожий на шаг 3. - Проект с вводом-выводом: создание программы командной строки — книга «Язык программирования Rust» (перевод на русский): строим маленький клон
grep, который читает аргументы и файлы, — те же навыки, что в шаге 6, но глубже. HashMap::entry— документация std (на английском): возвращаетEntry, у которого есть методыor_insert,or_defaultиand_modify.slice::sort_by— документация std (на английском): как работают замыкания сравнения и почему сортировка стабильная (равные элементы сохраняют свой порядок).- Ширина и выравнивание — документация
std::fmt(на английском): все варианты отступов и выравнивания вроде{:<10}и{:>6}, которые используются в таблицах на этой странице.