コンテンツにスキップ

Rustで作る単語カウンター

このプロジェクトでは、小さいけれど実際に使えるツール、単語カウンターを作ります。テキストを渡すと、どの単語がいちばん多く出てくるかを教えてくれます。文章を書く人は、同じ言葉を使いすぎていないか確かめるためにこういうツールを使います。検索エンジンも、ページが何について書かれているかをつかむために同じ考え方を使っています。

小さなステップに分けて作っていきます。どのステップも、そのまま動く完全なプログラムです。なので、どこで止めても大丈夫です。いじって遊んでから、また続きに進みましょう。途中で文字列、HashMap、並べ替え、書式付きの出力、コマンドライン引数を使います。

最後には、プログラムがこんな表を表示するようになります。

WORD COUNT
----------------
the 6
cat 3
happy 2

このページでは、数える対象のテキストも表の見出し(WORD、COUNT)も英語のままにしています。{:<10} のような桁そろえは文字数で幅を決めますが、日本語の文字は画面上で半角2文字分の幅を取るため、表の列がずれてしまうからです。また、日本語の文章は英語と違って単語の間にスペースがないので、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." にはピリオドが付いたままです。このまま数えると、結果がおかしくなります。まずはそこを直しましょう。

単語を正規化するとは、決まった1つの形にそろえることです。ここでは2つのことをします。

  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 で1を足します。

1行目のテキストの末尾にある \ は、文字列を次の行に続けるためのものです。また、counts["the"] はキーを直接引いています。キーがないとパニックしますが、ここではテキストにその単語があるとわかっているので問題ありません。ないかもしれないキーには .get() を使いましょう。

なぜループでマップ全体を表示しなかったのでしょうか。HashMapには決まった順番がないからです。ループで回すと、プログラムを実行するたびに順番が変わることがあります。安定した、役に立つ順番にするには、並べ替えが必要です。

ステップ4:回数順、同じなら単語順に並べ替える

Section titled “ステップ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: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ 実際に試してみよう

並べ替えの行は、このプロジェクトでいちばん難しいところです。分解して見ていきましょう。

  • sort_by は2つの要素 a と b を渡して、「どちらが先?」と聞いてきます。
  • b.1.cmp(&a.1) は回数を比べます(.1 はタプルの2番目の要素です)。わざと a より先に b を書いています。こうすると順番が逆になり、大きい回数が先に来ます。
  • .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}", "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 6
cat 3
happy 2
is 2
on 2
▶ 実際に試してみよう

.take(top_n) は top_n 個まで取ったら止まります。単語がそれより少なければ、そこで止まるだけです。なので、末尾を通り越してしまう心配はありません。

print_table が &[(String, usize)]、つまりスライスを受け取っていることにも注目してください。リストを借用するだけなので、ペアの Vec ならどれでも渡せます。

ステップ6:完成版、ファイルを読み込む

Section titled “ステップ6:完成版、ファイルを読み込む”

本物の単語カウンターなら、ファイルを読めるべきです。ファイルのパスはコマンドラインから受け取り、表示する行数は省略可能な2番目の引数で指定できるようにします。何か問題が起きたら、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}", "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);
};
// 2番目の引数は省略可能。ないときや数値でないときは 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 種類)
WORD COUNT
----------------
the 4
on 2
sat 2

ファイル名を付け忘れても、プログラムは落ちずに使い方のメッセージを表示します。

Terminal window
cargo run
使い方: word_counter <ファイル> [top_n]

let Some(path) = args.get(1) else { ... }; は let-else と呼ばれる書き方です。「このパターンに一致すればそのまま進み、一致しなければ 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 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}(整数への可変参照)には += を使えません。左辺を参照外し(dereference)すれば使えます」と言っています。直し方まで見せてくれていますね。* を付ければ解決です。

HashMapをそのまま表示する。 println!("{:?}", counts) でも表示はできますが、順番はばらばらで、実行するたびに変わることがあります。順番が大事なときは、必ず先に Vec に移して並べ替えましょう。

回数だけで並べ替える。 b.1.cmp(&a.1) だけで並べ替えると、同じ回数の単語がどんな順番で出てくるかわかりません。.then(...) で同点のときの決め方を加えて、毎回同じ結果になるようにしましょう。

これで動くツールができました。自分なりに改造するアイデアを、やさしい順に紹介します。

  • ストップワードを無視する。 “the”、“a”、“is”、“and” のような単語はどこにでも出てきて、あまり面白くありません。それらを HashSet に入れて、含まれている単語は飛ばしましょう。
  • 割合を表示する。 回数の横に、全体の何パーセントにあたるかを the 6 28.6% のように表示します。小数点以下1桁を表示するには {:.1} を使います。
  • ファイルが指定されないときは標準入力から読む。 std::io::read_to_string(std::io::stdin()) を使えば、cat story.txt | cargo run のようにテキストをパイプで渡せます。
  • 複数のファイルを数える。 args[1..] のすべてのパスをループで回して、全部の単語を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)) で、a より先に b を書いているのはなぜですか?

4. println! の中の {:>5} は何をしますか?

スコア: 0 / 4