0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustで単語出現頻度カウンターを実装してみる【HashMap + sort_by】

Posted at

はじめに

前回の記事で、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。

なぜタイブレークが必要?

HashMapVec にした直後の順序は元が未定(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. enummatch でモード切替

基本の考え方

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 は「有限の選択肢」を型で表現できる。
    ByFreqDescByAlphaAsc のどちらかしか渡せない)

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"

Rustchar の特徴

  • 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 文字として扱える。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?