Rust 基礎講座 #7 コレクションとイテレータ — Vec・String・HashMap、for の代わりにチェーン

読了 4分

第 2 回で配列は長さが固定だと言い、実務の主役は別にいると予告しました。今回がその主役です。可変長リストの Vec、文字列の String、キーと値の保管庫 HashMap、そしてこれらのコレクションを扱う Rust らしい方法であるイテレータです。

Vec — 実務の基本リスト #

src/main.rs
let mut scores: Vec<u32> = Vec::new();
scores.push(90);
scores.push(85);

let scores = vec![90, 85, 72]; // リテラルマクロ

Vec<T> はデータをヒープに置く可変長の配列です。ヒープを所有するので第 3 回の所有権規則がそのまま適用され、第 4 回で見た「反復中の push 禁止」のコンパイルエラーも Vec が舞台でした。要素へのアクセスは 2 通りあり、その違いが重要です。

src/main.rs
let third = scores[2];        // 範囲外ならパニック
let third = scores.get(2);    // 範囲外なら None (Option<&u32>)

インデックスが確実に有効なロジックでは [2] を使い、インデックスが入力や計算から来る箇所では get で受けて Option を処理します。第 5 回と第 6 回で立てた境界(「起きてはならない状態はパニック、予期できる失敗は型で」)が、アクセス方法の選択にもそのまま続いているわけです。

String — インデックスできない理由 #

Rust の String は UTF-8 バイトの Vec です。だから、他の言語では当たり前だったコードがコンパイルされません。

src/main.rs
let title = String::from("入門講座");
let first = title[0]; // コンパイルエラー: String はインデックスアクセス不可

第 2 回で char は 4 バイトの Unicode だと言い、第 4 回ではスライスで文字の途中を切るとパニックになると予告しました。ここで答えが合流します。UTF-8 では日本語の 1 文字がおおむね 3 バイトなので、title[0] が「最初の文字」だという保証はありません。バイト 1 つは文字ではないからです。Rust は「とりあえず先頭のバイトを返す」代わりにこの演算自体を禁止し、何が欲しいのかを明示させます。

src/main.rs
let first_char = title.chars().next();  // Some('入') — 文字単位
let byte_len = title.len();             // 12 — バイト単位の長さ
let char_count = title.chars().count(); // 4 — 文字数

len() が文字数ではなくバイト数だという点は、入力の長さ制限ロジックでよくある落とし穴なので覚えておく価値があります。文字列を組み立てるときは、push_strformat! マクロが基本の道具です。

HashMap — キーと値、そして entry API #

src/main.rs
use std::collections::HashMap;

let mut stock: HashMap<String, u32> = HashMap::new();
stock.insert(String::from("キーボード"), 12);

let count = stock.get("キーボード"); // Option<&u32>

検索が Option なのは、もう驚くことではないはずです。存在しないキーは null ではなく None です。HashMap で覚えておきたいイディオムは、「なければ入れ、あれば更新」を 1 回の検索で処理する entry API です。

src/main.rs
let mut word_count: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
    *word_count.entry(word).or_insert(0) += 1;
}

「キーがあるか確認 → なければ挿入 → もう一度引いて更新」という 3 段階が 1 行になります。単語の頻度集計のようなコードの標準形です。

イテレータ — for を置き換えるチェーン #

3 つのコレクションを学んだので、これらを扱う Rust らしい方法を見てみます。「80 点以上の点数の合計」を 2 通りで書きます。

src/main.rs
// for 方式
let mut total = 0;
for s in &scores {
    if *s >= 80 {
        total += s;
    }
}

// イテレータチェーン方式
let total: u32 = scores.iter().filter(|s| **s >= 80).sum();

filter に渡した |s| ... はクロージャ(無名関数)です。チェーン方式がイディオムである理由は好みではなく構造です。mut 変数が消え、「絞り込んで合計する」という意図が、実装の手順ではなく名前で表れます。変換には map、結果を新しいコレクションに集めるときは collect を使います。

src/main.rs
let names: Vec<String> = users.iter()
    .filter(|u| u.active)
    .map(|u| u.name.clone())
    .collect();

あと 2 つ知っておけば完成です。第一に、イテレータは遅延評価されます。mapfilter は計画を積むだけで、collectsum のような消費側の呼び出しで一気に実行されるので、中間のコレクションは作られません。コンパイル後の性能は手書きのループと同等で、表現力のために性能を差し出す取引ではありません。第二に、反復にも所有権の区別があります。iter() は参照で借りて反復し、into_iter() は所有権ごと取り込みながら反復します。反復後もコレクションを使い続けるなら iter() が既定値です。

まとめ #

  • Vec が実務の基本リストです。構造上確実なインデックスは [i]、外から来るインデックスは get で受けて Option を処理します。
  • String は UTF-8 バイトの Vec なので、インデックスアクセスは禁止です。文字単位は chars()len() はバイト数というのが実務の落とし穴です。
  • HashMap の検索は Option で、集計は entry(...).or_insert(...) のイディオムが標準形です。
  • コレクション処理の基本形は for より iter().filter().map().collect() のチェーンです。遅延評価なので、中間コストなしに手書きループと同等にコンパイルされます。
  • 次回はトレイトとジェネリクスです。これまで先送りしてきた derive(Debug)Copy トレイト、Box<dyn Error> の正体が一度に解けます。
X