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<_> = ...と明示するか、turbofishcollect::<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 ループが選択肢として優れている場合があることも知っておくべきです。