Rust のイテレータとクロージャ

1. 概要

クロージャとイテレータは、Rust で関数型のスタイルでデータを処理しながら、手書きのループと同等のパフォーマンスを保つ 2 つの強力なツールです。このガイドでは、クロージャが周囲のスコープから変数をキャプチャする方法と、Fn、FnMut、FnOnce の 3 つのトレイトについて説明します。その後、Iterator トレイト、map、filter といった一般的なアダプタ、そしてカスタムイテレータの実装方法を取り上げます。最後には、filter → map → collect のようなパイプラインを自信を持って書けるようになります。また、コンパイラがときにコードを拒否する理由も理解できるでしょう。

すべての例は Rust 1.80 以上(edition 2021)で、標準ライブラリのみで動作します。

2. クロージャの基本

2.1. 構文

クロージャは、周囲のスコープにある変数を「見る」ことができる無名関数です。

fn main() {
    let discount = 10; // パーセント
    let apply_discount = |price: u32| price - price * discount / 100;

    println!("{}", apply_discount(250));
    println!("{}", apply_discount(1000));
}
225
900

パラメータは 2 つのパイプ記号 |...| の間に書き、クロージャの本体はその直後に続きます。このクロージャは渡されていない discount を使用しています。通常の fn 関数ではこれができません。

パラメータの型と戻り値の型は通常、自動的に推論されます。コンパイラが十分な情報を持っていれば、|price| ... と書くことができます。ただし、型は一度だけ推論されます。最初の呼び出しで u32 を使い、次に i64 で呼び出すとコンパイルエラーになります。

2.2. 変数をキャプチャする 3 つの方法

クロージャは、クロージャの本体が必要とする「最も軽い」方法で変数をキャプチャします。優先順位は不変借用、可変借用、最後に所有権の移動です。

fn main() {
    let mut cart = vec!["ペン"];

    let print_cart = || println!("カート: {:?}", cart); // &cart を借用
    print_cart();

    let mut add_item = |item| cart.push(item);        // &mut cart を借用
    add_item("ノート");
    add_item("ものさし");

    println!("最終的に: {:?}", cart);
}
カート: ["ペン"]
最終的に: ["ペン", "ノート", "ものさし"]

それぞれのクロージャを分析してみます。

  • print_cart は cart を読み取るだけなので、&cart の参照を保持します。
  • add_item は push を呼び出すため、&mut cart が必要です。クロージャの変数も mut で宣言する必要があります。なぜなら、それを呼び出すと内部状態が変わるからです。
  • コードがコンパイルできるのは、non-lexical lifetimes のおかげです。print_cart は最初の呼び出しの後は使われなくなるため、不変借用は可変借用が始まる前に終わります。

クロージャが所有権を持つ必要がある場合は、move キーワードを追加します。典型的なケースは、クロージャを別のスレッドに渡すときです。

use std::thread;

fn main() {
    let customer = String::from("Lan");

    let handle = thread::spawn(move || {
        println!("顧客 {} の注文を処理中です", customer);
    });

    handle.join().unwrap();
}
顧客 Lan の注文を処理中です

move がなければ、コンパイラはエラーを報告します。新しいスレッドは main より長く生存する可能性があるため、customer への参照は安全ではありません。move を使うと、String がクロージャに移動されます。その後、main では customer を使用できなくなります。

3. Fn、FnMut、FnOnce の 3 つのトレイト

各クロージャは、以下の 3 つのトレイトのいずれか(または複数)を自動的に実装します。どのトレイトを実装するかは、クロージャがキャプチャした変数に何をするかによります。

トレイト キャプチャした変数をどう扱うか 呼び出し可能な回数
Fn 読み取りのみ 複数回
FnMut 変更 複数回
FnOnce 消費(外へ move) 1 回

これら 3 つのトレイトには継承関係があります。すべての Fn は FnMut でもあり、すべての FnMut は FnOnce でもあります。クロージャを受け取る関数を書くときは、その関数が必要とする最も広いトレイトを選択してください。そうすることで、呼び出す側により多くの選択肢が与えられます。

fn run_twice<F: Fn()>(f: F) {
    f();
    f();
}

fn run_twice_mut<F: FnMut()>(mut f: F) {
    f();
    f();
}

fn run_once<F: FnOnce() -> String>(f: F) -> String {
    f()
}

fn main() {
    let name = String::from("Minh");
    run_twice(|| println!("こんにちは {name}"));

    let mut count = 0;
    run_twice_mut(|| count += 1);
    println!("count = {count}");

    let receipt = run_once(move || name + " - 支払い完了");
    println!("{receipt}");
}
こんにちは Minh
こんにちは Minh
count = 2
Minh - 支払い完了

3 つの呼び出しはそれぞれ 3 つのトレイトに対応しています。

  • 最初のクロージャは name を読み取るだけなので、それは Fn です。
  • 2 番目のクロージャは count をインクリメントするため、それは FnMut です。これを run_twice に渡すとエラーになります。
  • 最後のクロージャは name + "..." を使っています。String の + 演算子は name の所有権を取るため、このクロージャは 1 回だけ呼び出せ、FnOnce だけを実装します。

3.1. 関数からクロージャを返す

各クロージャは独自の匿名型を持っています。そのため、その型の名前を書くことはできません。クロージャを返す方法は 2 つあります。

fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
    move |price| price - price * percent / 100
}

fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
    if is_vip {
        Box::new(|price| price * 80 / 100)
    } else {
        Box::new(|price| price)
    }
}

fn main() {
    let summer_sale = make_discount(15);
    println!("{}", summer_sale(200));

    let rule = pick_rule(true);
    println!("{}", rule(500));
}
170
400
  • impl Fn(...) は、関数が常に1 つの固定されたクロージャ型を返す場合に使います。この方法はヒープ割り当てや動的呼び出しのコストがありません。
  • Box<dyn Fn(...)> は、異なるブランチが異なるクロージャを返す場合に使います。pick_rule の 2 つのクロージャは異なる型を持つため、共通のトレイトオブジェクトにする必要があります。
  • make_discount の move は必須です。なければ、クロージャは percent を借用しますが、percent は関数が終わるときに消えてしまいます。

4. Iterator トレイト

4.1. コア:next メソッド

Rust のすべてのイテレータは、非常に小さなトレイトの周りに回転しています。

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... map、filter、sum など何十ものメソッドが用意されている ...
}

next はデータがなくなるまで Some(要素) を返し、その後 None を返します。for ループは本質的に、next を連続して呼び出すための構文糖です。

4.2. iter、iter_mut、into_iter

コレクションは 3 種類のイテレータを提供できます。

fn main() {
    let mut prices = vec![100, 200, 300];

    for p in prices.iter() {          // p: &i32
        print!("{p} ");
    }
    println!();

    for p in prices.iter_mut() {      // p: &mut i32
        *p += 10;
    }

    for p in prices.into_iter() {     // p: i32、vector は move される
        print!("{p} ");
    }
    println!();
    // println!("{:?}", prices); // エラー:move 後は使用できない
}
100 200 300 
110 210 310 
  • iter() は不変参照を提供します。コレクションはその後も使用できます。
  • iter_mut() は可変参照を提供して、要素をその場で変更します。
  • into_iter() は所有権を取ります。for p in prices と書くと、暗黙的に into_iter() が呼ばれます。

5. アダプタと消費者

イテレータのメソッドは 2 つのグループに分かれます。

  • アダプタ (map、filter、take、zip、enumerate など)は、このイテレータを別のイテレータに変換します。それらは怠惰です。消費者が現れるまで何も実行されません。
  • 消費者 (collect、sum、count、fold、for_each、find など)は実際に next を呼び出し、結果を生成します。

サンプルデータとして Order 構造体を使用します。

#[derive(Debug)]
pub struct Order {
    pub id: u32,
    pub customer: String,
    pub total: u32,
    pub paid: bool,
}

pub fn unpaid_total(orders: &[Order]) -> u32 {
    orders.iter()
        .filter(|o| !o.paid)
        .map(|o| o.total)
        .sum()
}

pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
    orders.iter()
        .filter(|o| o.total >= threshold)
        .map(|o| o.customer.as_str())
        .collect()
}

fn main() {
    let orders = vec![
        Order { id: 1, customer: "An".into(),   total: 150_000, paid: true },
        Order { id: 2, customer: "Bình".into(), total: 80_000,  paid: false },
        Order { id: 3, customer: "Chi".into(),  total: 320_000, paid: false },
    ];

    println!("未徴収: {}", unpaid_total(&orders));
    println!("大口顧客: {:?}", big_customers(&orders, 100_000));

    let summary = orders.iter()
        .fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
    println!("注文: {summary}");
}
未徴収: 400000
大口顧客: ["An", "Chi"]
注文: #1 #2 #3 

例の各ステップを説明します。

  • filter(|o| !o.paid) は bool を返すクロージャを受け取ります。パラメータ o の型は &&Order です。これは filter が要素への参照を渡すため、そして iter() の要素がすでに &Order であるためです。自動解参照のおかげで、o.paid と通常通り書けます。
  • map(|o| o.total) は各注文を金額に変換します。sum() はすべてを足し合わせます。
  • collect() は結果をコレクションに集約します。ターゲット型は関数の戻り値の型 Vec<&str> から取られます。
  • fold は初期値とクロージャ (acc, 要素) -> 新しい acc を受け取ります。これは最も一般的な消費者です。

5.1. 遅延実行

次のコードは、アダプタが消費されるときだけ実行されることを示しています。

fn main() {
    let ids = vec![1, 2, 3];
    let doubled = ids.iter().map(|x| {
        println!("map {x}");
        x * 2
    });

    println!("まだ何も実行されていない");
    let result: Vec<i32> = doubled.collect();
    println!("{result:?}");
}
まだ何も実行されていない
map 1
map 2
map 3
[2, 4, 6]

遅延実行のおかげで、filter → map → sum のパイプラインはデータを1 回だけ走査し、中間コレクションを作成しません。コンパイラは通常、このパイプラインを手書きのループと同等のマシンコードに最適化します。これが、イテレータがゼロコスト抽象化と呼ばれる理由です。

6. カスタムイテレータを書く

next を実装するだけです。他のすべてのアダプタは無料で付いてきます。以下の例は HD-0001 形式で請求書番号を生成します。

pub struct InvoiceNumbers {
    current: u32,
    end: u32,
}

impl InvoiceNumbers {
    pub fn new(start: u32, end: u32) -> Self {
        Self { current: start, end }
    }
}

impl Iterator for InvoiceNumbers {
    type Item = String;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current > self.end {
            return None;
        }
        let number = format!("HD-{:04}", self.current);
        self.current += 1;
        Some(number)
    }
}

fn main() {
    let customers = ["An", "Bình", "Chi"];

    for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
        println!("{invoice} -> {customer}");
    }
}
HD-0007 -> An
HD-0008 -> Bình
HD-0009 -> Chi

説明します。

  • type Item = String はイテレータが返す要素の型を宣言します。
  • next は &mut self を通じて状態を更新し、end を超えたら None を返します。
  • zip は 2 つのイテレータをペアに結合し、短い方が尽きるとき停止します。そのため、数字の範囲は 100 まで続きますが、3 行だけが出力されます。

7. テストを書く

Rust には組み込みのテストフレームワークがあります。テストモジュールを unpaid_total と InvoiceNumbers を含むファイルに直接配置できます。

#[cfg(test)]
mod tests {
    use super::*;

    fn order(id: u32, total: u32, paid: bool) -> Order {
        Order { id, customer: format!("顧客{id}"), total, paid }
    }

    #[test]
    fn unpaid_total_only_counts_unpaid_orders() {
        let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
        assert_eq!(unpaid_total(&orders), 120);
    }

    #[test]
    fn unpaid_total_of_empty_slice_is_zero() {
        assert_eq!(unpaid_total(&[]), 0);
    }

    #[test]
    fn invoice_numbers_stop_after_end() {
        let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
        assert_eq!(numbers, vec!["HD-0009", "HD-0010", "HD-0011"]);
    }
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

2 番目のテストは境界ケースを確認しています。空のイテレータに対する sum() は 0 を返し、パニックは発生しません。

8. よくあるエラーと使わない方がいい場合

注意: 消費者を忘れることが最も一般的なエラーです。orders.iter().map(|o| println!("{}", o.id)); という行は何も出力しません。コンパイラは「イテレータは遅延実行されるため、消費されないと何もしない」という警告を出します。副作用だけが必要な場合は、for ループまたは for_each を使ってください。

その他のエラーには以下が含まれます。

  • collect がターゲット型を知らない。 let v = it.collect(); と書くとエラーになります。「型アノテーションが必要」とのこと。let v: Vec<_> = ... と明示するか、turbofish collect::<Vec<_>>() を使う必要があります。
  • into_iter() を使った後にコレクションを再利用する。 読み取るだけなら iter() を使ってください。
  • クロージャが借用を長く保持する。 FnMut クロージャが &mut cart をキャプチャしている場合、クロージャの最後の使用まで cart を読み取ることができません。クロージャの範囲を狭めるか、使用後に読み取ってください。
  • クロージャ内で ? が必要。 ? 演算子はクロージャから外側の関数に return しません。collect::<Result<Vec<_>, _>>() を使ってください。この方法は最初のエラーで停止し、Err を返します。

イテレータのチェーンを使わない方がいい場合:

  • ロジックに多くの分岐があったり、多くの break/continue があったり、複数の変数を同時に更新したりする場合。明確な for ループの方が読みやすい 10 行の fold より良いです。
  • 多くの分岐からクロージャを返しますが、パフォーマンスを心配する場合。Box<dyn Fn> はヒープ割り当てと動的呼び出しのコストがあります。代わりに enum またはジェネリックの使用を検討してください。

9. まとめ

Rust のクロージャは、可能な限り軽い方法で変数をキャプチャします。借用、可変借用、または move です。これにより、クロージャは自動的に Fn、FnMut、または FnOnce を実装します。クロージャを受け取る関数を書くときは、その関数が必要とする最も広いトレイトを選択してください。

イテレータはたった 1 つのメソッド next に基づいています。map、filter、zip のようなアダプタは遅延実行で、collect、sum、fold のような消費者がパイプラインを実際に実行します。この 2 つのツールを組み合わせることで、データを処理する短くて安全で高速なコードを書くことができます。また、単純な for ループが選択肢として優れている場合があることも知っておくべきです。

rustイテレータクロージャ関数型プログラミング