用 Rust 编写单词计数器
这个项目要做一个小巧但真正能用的工具:单词计数器。把一段文字交给它,它会告诉你哪些单词出现得最多。写作的人用这类工具找出自己重复太多的词;搜索引擎也用同样的思路来判断一个页面讲的是什么。
我们会分成几小步来做。每一步都是一个能完整运行的程序,所以你随时可以停下来,动手改一改玩一玩,再接着往下走。一路上会用到字符串、HashMap、排序、格式化输出和命令行参数。
做完以后,程序会打印出类似这样的结果:
WORD COUNT----------------the 6cat 3happy 2这个页面里,要统计的文本和表头(WORD、COUNT)都保留英文。原因有两个:{:<10} 这类对齐是按字符个数算宽度的,而一个汉字在屏幕上占两个英文字符的宽度,表格的列会对不齐;另外,中文句子里词和词之间没有空格,split_whitespace() 没法把它切成单词。
第 1 步:把文本切成单词
Section titled “第 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 步:规范化每个单词
Section titled “第 2 步:规范化每个单词”规范化(normalize)一个单词,就是把它变成一种统一的标准形式。这里做两件事:
- 用
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 计数
Section titled “第 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)在 map 里找到这个单词对应的位置。.or_insert(0)在单词第一次出现时放进一个0。不管是不是第一次,它都会返回指向这个计数的可变引用。*顺着这个引用拿到数字本身,+= 1给它加一。
第一行文本末尾的 \ 只是让字符串接着下一行继续写。counts["the"] 直接按键取值:键不存在时会 panic,不过这里没问题,因为我们知道这些单词一定在文本里。如果键可能不存在,就用 .get()。
为什么不用循环把整个 map 打印出来?因为 HashMap 没有固定的顺序。遍历它的时候,每次运行程序,顺序都可能不一样。想要稳定又有用的顺序,就得排序。
第 4 步:先按次数排序,再按单词排序
Section titled “第 4 步:先按次数排序,再按单词排序”我们把 map 里的键值对放进一个 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 名的表格
Section titled “第 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}", "WORD", "COUNT"); 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);}输出:
WORD COUNT----------------the 6cat 3happy 2is 2on 2.take(top_n) 取到 top_n 个元素就停下。如果单词没有那么多,它会提前停下,所以完全不用担心越界。
注意 print_table 接收的是 &[(String, usize)],也就是一个 slice。这说明它只是借用这个列表,任何由键值对组成的 Vec 都能传进来。
第 6 步:最终版,读取文件
Section titled “第 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}", "WORD", "COUNT"); 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 个不同
WORD COUNT----------------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}", "WORD", "COUNT"); 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 个不同
WORD COUNT----------------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}(指向整数的可变引用)上;如果对左边解引用,就可以用在 {integer} 上了。”它连修复方法都给你标出来了:加上 * 就行。
直接打印 HashMap。 println!("{:?}", counts) 也能打印,但顺序是随机的,每次运行都可能不同。只要顺序重要,就先收集到 Vec 里再排序。
只按次数排序。 如果只用 b.1.cmp(&a.1) 排序,次数相同的单词会以任意顺序出现。加上 .then(...) 作为平局时的比较规则,输出才会每次都一样。
你已经有了一个能用的工具。下面这些点子可以让它变成你自己的作品,按从易到难排列:
- 忽略停用词。 像“the”、“a”、“is”和“and”这样的词到处都是,没什么意思。把它们放进一个
HashSet,遇到集合里的词就跳过。 - 显示百分比。 在每个次数旁边,打印它占全部单词的比例,比如
the 6 28.6%。用{:.1}保留一位小数。 - 没给文件时从标准输入读取。 用
std::io::read_to_string(std::io::stdin()),这样就能用管道把文本传进来:cat story.txt | cargo run。 - 统计多个文件。 遍历
args[1..]里的每个路径,把所有单词都加到同一个 map 里。 - 用
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. println! 里的 {:>5} 有什么作用?
得分: 0 / 4
- 使用 Hash Map 储存键值对 —《Rust 程序设计语言》:里面有一个用
entry和or_insert统计单词的例子,和第 3 步非常像。 - 一个 I/O 项目:构建命令行程序 —《Rust 程序设计语言》:做一个小小的
grep克隆,读取参数和文件,用到的技能和第 6 步一样,但讲得更深入。 HashMap::entry— 标准库文档(英文):它返回一个Entry,上面的方法包括or_insert、or_default和and_modify。slice::sort_by— 标准库文档(英文):比较闭包是怎么工作的,以及为什么这个排序是稳定的(相等的元素保持原来的顺序)。- 宽度与对齐 —
std::fmt文档(英文):所有填充和对齐选项,比如本页表格里用到的{:<10}和{:>6}。