はじめに
前回の記事で、rustの基本的な考え方や概念の話をしました。
それを踏まえて、今回はrustで何かしら動くものを作ってみようと思い単語出現頻度カウンターを実装してみました。
use std::collections::HashMap;
use std::io;
enum SortMode {
ByFreqDesc,
ByAlphaAsc,
}
fn sort_counts(v: &mut Vec<(String, usize)>, mode: SortMode) {
match mode {
SortMode::ByFreqDesc => {
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
}
SortMode::ByAlphaAsc => {
v.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
}
}
}
fn clean_word(word: &str) -> Option<String> {
let word = word.to_lowercase();
let word = word.trim_matches(|c: char| !c.is_alphanumeric()).to_string();
let stopword = ["the", "a", "to", "is", "and", "he", "was", "you", "him", "as", "it", "in"];
if word.is_empty() || stopword.contains(&word.as_str()) {
None
} else {
Some(word)
}
}
fn word_count(text: &str) -> Vec<(String, usize)> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
if let Some(word) = clean_word(word){
*counts.entry(word).or_insert(0) += 1;
}
}
let mut vec: Vec<_> = counts.into_iter().collect();
sort_counts(&mut vec, SortMode::ByFreqDesc);
vec
}
fn main() {
println!("文章を入力してください。");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("読み込み失敗");
let counts = word_count(&input);
for (word, count) in counts.into_iter().take(5) {
println!("{}: {}", word, count);
}
}
技術ポイント
1. String と &str(所有権と借用)
-
&str
は「借用した文字列」- 軽量でコピー不要だが、自分ではデータを持たない。
- 元の文字列が生きている間しか使えない。
- String は「所有する文字列」
- ヒープにデータを持ち、変更や保管が可能。
- HashMap のキーやコレクションに入れるなら必須。
今回の設計
fn clean_word(word: &str) -> Option<String> {
let word = word.to_lowercase();
let word = word.trim_matches(|c: char| !c.is_alphanumeric()).to_string();
Some(word)
}
- 入力は &str(軽量に借りる)
- 加工後は String(所有して HashMap に保持できるようにする)
なぜ String
が必要?
let mut map: HashMap<String, usize> = HashMap::new();
map.insert("hello".to_string(), 1); // 所有権を渡すからOK
もし HashMap<&str, usize> にすると、元の文字列が消えた瞬間に参照が無効になるため使えない。
→ だから String をキーにする必要がある。
2. ption と if let
Rust では「値がある/ない」を Option で表現する。
- Some(value) … 値が存在する
- None … 値が存在しない
fn clean_word(word: &str) -> Option<String> {
let word = word.to_lowercase();
let word = word.trim_matches(|c: char| !c.is_alphanumeric()).to_string();
let stopword = ["the", "a", "to", "is", "and"];
if word.is_empty() || stopword.contains(&word.as_str()) {
None // 無効な単語は捨てる
} else {
Some(word) // 有効なら String を返す
}
}
→ 空文字やストップワード は None で表現。
呼び出し側
for raw in text.split_whitespace() {
if let Some(word) = clean_word(raw) {
*counts.entry(word).or_insert(0) += 1;
}
}
- if let Some(word) で「値があるときだけ処理」できる
- None の場合は自動でスキップ
ポイント
-
Option
によって エラー処理用の特別な値 を作る必要がない - if let で シンプルに分岐処理 が書ける
- 「値が無いこと」を型で表現する → Rust らしい安全設計
3. HashMap の entry().or_insert(0)
Rust で「単語ごとに出現回数を数える」には HashMap<String, usize>
を使います。
ただし 初めて出る単語はまだキーが存在しない ので、普通に書くと「存在チェック → 挿入 → 加算」が必要です。
それを一発で書けるのが entry().or_insert(0)
パターン です。
サンプル
*counts.entry(word).or_insert(0) += 1;
-
counts.entry(word)
- そのキー word に対応する「エントリ」を返す(存在しなければ「空のエントリ」を用意)
-
.or_insert(0)
- キーがなければ 0 を挿入する
- 戻り値は &mut usize(カウントへの可変参照)
-
... += 1
- &mut usize をデリファレンスして中身を取り出し
- 1 加算する
ポイント
- 初出単語 → 自動で 0 がセットされる
- 既出単語 → 既存のカウント値をそのまま参照
- これで += 1 すれば OK
4. イテレータと collect::<Vec<_>>()
HashMap
は「順序なし」
HashMap はキーと値の対応関係を持ちますが、順序は保証されません。
なので for で回しても「バラバラ」に出てきます。
→ ソートしたい場合は Vec に変換する必要がある。
into_iter().collect::<Vec<_>>()
-
let mut vec: Vec<_> = counts.into_iter().collect();
-
counts.into_iter()
→ (String, usize) のペアを次々に取り出すイテレータに変換
(into_iter なので所有権ごと取り出す) -
.collect::<Vec<_>>()
→ イテレータをまとめて Vec に変換する
-
-
型注釈
Vec<_>
の意味
Rust の型推論でVec<_>
は「中身の型はイテレータから推論してね」という意味。
この場合は (String, usize) のペアが出てくるので、実際には Vec<(String, usize)> になります。
ソート可能になる
Vec
にしてしまえば vec.sort_by(...)
が使えるようになるので、「頻度順」や「アルファベット順」に整列できる。
5. sort_by
とカスタム比較(cmp / then_with)
基本
-
Vec<(String, usize)>
を「(単語, 出現回数)」として持っていて、
sort_by(|a, b| …)
のクロージャで“並べ替え規則”を自作します。
ここで a と b は 参照(&(String, usize))
なので、- a.0 / b.0 … 単語(String)
- a.1 / b.1 … 回数(usize)
を指します。
頻度降順+同率は単語昇順(決定性のためのタイブレーク)
v.sort_by(|a, b| {
b.1.cmp(&a.1) // ① 回数: 大きいほうが先(降順)
.then_with(|| a.0.cmp(&b.0)) // ② 同率なら単語: 小さいほうが先(昇順)
});
-
x.cmp(&y)
はOrdering(Less / Equal / Greater)
を返します。 -
then_with
は ①がEqualのときだけ ②の比較を実行します(遅延評価)。
すでに Ordering があれば .then(…) でもOK。
なぜタイブレークが必要?
HashMap
→Vec
にした直後の順序は元が未定(HashMapは順序なし)。
sort_by
で “回数だけ” 比べると、同じ回数同士の相対順 が元の順序に依存します。
→ 実行ごとに結果が揺れる可能性がある。
→ タイブレーク(ここでは単語昇順)を入れると 常に同じ結果 になります。
※ slice::sort
は安定ソートですが、元順序が未定なので「常に同じ並び」を保証するには明示的なタイブレークが有効です。
単語昇順+同語は頻度降順
v.sort_by(|a, b| {
a.0.cmp(&b.0) // ① 単語: アルファベット昇順
.then(b.1.cmp(&a.1)) // ② 同じ単語なら回数: 降順
});
逆順の作り方メモ
- 昇順:
a.field.cmp(&b.field)
- 降順
:b.field.cmp(&a.field)
- タイブレークは
.then(…) / .then_with(|| …)
で追加
6. enum
と match
でモード切替
基本の考え方
Rust では「状態」や「戦略」を enum(列挙型) で表現し、
match
文で分岐させるのが定番の設計パターンです。
enum SortMode {
ByFreqDesc, // 頻度降順
ByAlphaAsc, // アルファベット昇順
}
fn sort_counts(v: &mut Vec<(String, usize)>, mode: SortMode) {
match mode {
SortMode::ByFreqDesc => {
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
}
SortMode::ByAlphaAsc => {
v.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
}
}
}
ポイント
-
enum は「有限の選択肢」を型で表現できる。
(ByFreqDesc
かByAlphaAsc
のどちらかしか渡せない)
match は「必ず全てのパターンを処理する」ため、漏れのない分岐になる。
(コンパイル時に網羅性チェックされる)
if/else のフラグ管理より 可読性・安全性が高い
設計パターンとしての意味
これは「戦略パターン」に近い考え方で、「同じデータに対して異なる処理ルールを適用する」 仕組みを、
enum と match で型安全に実現している。
- ゲーム → enum State { Menu, Playing, GameOver }
- 通信処理 → enum Protocol { Http, Https, Ftp }
- 今回 → enum SortMode { ByFreqDesc, ByAlphaAsc }
Rust では 「フラグを渡すより、enum で表現する」 が鉄板。
それにより 「取りうる状態が限定され、型が保証してくれる」 のが強みです。
7. クロージャと文字処理
let word = word.trim_matches(|c: char| !c.is_alphanumeric()).to_string();
trim_matches
とクロージャ
-
trim_matches
は「文字列の先頭と末尾から、条件に一致する文字を削除する」関数。 - 通常は
' ' や '.'
など特定の文字を渡すけど、**クロージャ(無名関数)**を渡せば柔軟な条件を作れる。
|c: char| !c.is_alphanumeric()
-
|c: char|
は「引数 c(型は char)」を受け取るクロージャ。
!c.is_alphanumeric()
は「c が英数字でないなら true」を返す。
つまり「先頭と末尾の 非英数字 を全部削除」する処理になる。
"!!Hello!!".trim_matches(|c: char| !c.is_alphanumeric());
// => "Hello"
Rust の char
の特徴
- Rust の char は Unicode スカラー値。
- 1文字 = 4バイト固定。
- ASCII だけでなく「ひらがな」「絵文字」も 1 文字として扱える。
let c1: char = 'A'; // OK
let c2: char = 'あ'; // OK
let c3: char = '🚀'; // OK
これにより 国際化対応が自然にできるのが Rust の強み。
(C 言語の char が 1 バイトで、マルチバイト文字処理が面倒なのと対照的)
ポイント
-
trim_matches
にクロージャを渡すと柔軟なフィルタが書ける。 -
|c: char| !c.is_alphanumeric()
は「非英数字を除去する」条件。 - Rust の
char
は Unicode スカラー値(4バイト)なので、日本語や絵文字も 1 文字として扱える。