Rustで作る単語カウンター
このプロジェクトでは、小さいけれど実際に使えるツール、単語カウンターを作ります。テキストを渡すと、どの単語がいちばん多く出てくるかを教えてくれます。文章を書く人は、同じ言葉を使いすぎていないか確かめるためにこういうツールを使います。検索エンジンも、ページが何について書かれているかをつかむために同じ考え方を使っています。
小さなステップに分けて作っていきます。どのステップも、そのまま動く完全なプログラムです。なので、どこで止めても大丈夫です。いじって遊んでから、また続きに進みましょう。途中で文字列、HashMap、並べ替え、書式付きの出力、コマンドライン引数を使います。
最後には、プログラムがこんな表を表示するようになります。
WORD COUNT----------------the 6cat 3happy 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." にはピリオドが付いたままです。このまま数えると、結果がおかしくなります。まずはそこを直しましょう。
ステップ2:単語を正規化する
Section titled “ステップ2:単語を正規化する”単語を正規化するとは、決まった1つの形にそろえることです。ここでは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で数える
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)は、マップの中からこの単語の場所を探します。.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: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 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 やその仲間の例は、並べ替えと検索のページにもっとあります。
ステップ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)]、つまりスライスを受け取っていることにも注目してください。リストを借用するだけなので、ペアの Vec ならどれでも渡せます。
ステップ6:完成版、ファイルを読み込む
Section titled “ステップ6:完成版、ファイルを読み込む”本物の単語カウンターなら、ファイルを読めるべきです。ファイルのパスはコマンドラインから受け取り、表示する行数は省略可能な2番目の引数で指定できるようにします。何か問題が起きたら、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); };
// 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);}実行してみましょう。自分のプログラムへの引数は -- のあとに書くことを忘れないでください。
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 ブロックを実行する。ただし 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よくある間違い
Section titled “よくある間違い”数えるときに * を忘れる。 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(...) で同点のときの決め方を加えて、毎回同じ結果になるようにしましょう。
発展させるアイデア
Section titled “発展させるアイデア”これで動くツールができました。自分なりに改造するアイデアを、やさしい順に紹介します。
- ストップワードを無視する。 “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)で終了します。
理解度チェック
Section titled “理解度チェック”理解度チェック
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
さらに詳しく
Section titled “さらに詳しく”- キーとそれに紐づいた値をハッシュマップに格納する — The Rust Programming Language 日本語版:
entryとor_insertを使って単語を数える例があり、ステップ3とよく似ています。 - 入出力プロジェクト:コマンドラインプログラムを構築する — The Rust Programming Language 日本語版:引数とファイルを読み込む小さな
grepのクローンを作ります。ステップ6と同じ技術を、より深く学べます。 HashMap::entry— 標準ライブラリのドキュメント(英語):Entryを返します。そのメソッドにはor_insert、or_default、and_modifyなどがあります。slice::sort_by— 標準ライブラリのドキュメント(英語):比較用のクロージャのしくみと、このソートが安定である(等しい要素の順番が保たれる)理由がわかります。- 幅と配置 —
std::fmtのドキュメント(英語):このページの表で使った{:<10}や{:>6}など、パディングと配置のオプションがすべて載っています。