📚 背景知識(読んでから問題へ)
Rustのforループ・map/filter/foldといったコレクション操作は、すべてstd::iter::Iteratorトレイトという単一のインターフェースの上に成り立っています。
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// map, filter, fold, sum, collect などはすべて Item と next() から
// デフォルト実装として提供される(実装必須なのは next() だけ)
}
重要なのは、Iteratorが要求するメソッドは実質next()ただ1つだけだという点です。map・filter・fold・sum・collectなど数十個のメソッドは、すべてnext()の呼び出しを組み合わせたデフォルト実装として標準ライブラリ側に用意されています。1つのコア機能から膨大な機能群を導出する、トレイトのデフォルト実装を極限まで活用した設計です。
イテレータアダプタと消費アダプタの違いが、このテーマ最大のポイントです。
- アダプタ(adapter):
map・filter・enumerate・zip・take_while・skip_while・chainなど。イテレータを受け取り、新しいイテレータを返す。呼び出した時点では何も実行されない(遅延評価/lazy) - 消費アダプタ(consumer):
collect・sum・fold・for_each・countなど。イテレータのnext()を実際に呼び出し尽くして値を取り出す。呼び出した瞬間に初めて処理が走る(eager)
let v = vec![1, 2, 3, 4, 5];
let iter = v.iter().map(|x| x * 2).filter(|x| x % 3 == 0);
// ↑ この行だけでは何も計算されていない。iterは「計算のレシピ」を持つ値
let result: Vec<i32> = iter.collect();
// ↑ collect() が呼ばれた瞬間に初めて next() が連鎖的に呼ばれ、計算が実行される
この遅延評価の仕組みにより、Rustのイテレータチェーンはコンパイラの最適化(LLVM)によって手書きのforループと同等かそれ以上の機械語に展開されることが多く、「高レベルな書き方をしても実行時コストがゼロ」というゼロコスト抽象化の代表例になっています。中間コレクション(Vecなど)を一切ヒープに確保せず、要素ごとにmap→filter→次の要素…と1個ずつパイプライン的に処理されるのが、多くの他言語のコレクションAPI(一段階ごとに新しい配列を作る実装)との決定的な違いです。
IntoIteratorトレイトも押さえておきます。for x in collectionという構文は、collection.into_iter()を呼び出す糖衣構文です。.iter()(&Tを返す・借用)、.iter_mut()(&mut Tを返す・可変借用)、.into_iter()(Tを返す・所有権を移動)という3種類の使い分けが、Rustの所有権システムがコレクション走査にまで一貫して及んでいることを示しています。
📝 問題
以下の4つの要求に順番に答えてください。
要求1(実装: 基本のアダプタ連鎖)
Transaction { amount: i64, category: String, is_valid: bool }という構造体を定義し、&[Transaction]を受け取って有効な(is_valid == true)取引の合計金額を返す関数total_valid_amountを、filter・map・sumの連鎖のみで(forループを使わずに)実装してください。
要求2(実装: enumerate と zip)
2つの&[i32]スライスa・b(同じ長さとは限らない)を受け取り、同じインデックスの値が一致している位置のインデックス一覧をVec<usize>で返す関数matching_indicesを、zip・enumerate・filter・mapの連鎖で実装してください。
要求3(実装: 遅延評価の実証)
イテレータアダプタが呼び出し時点では実行されず、消費アダプタが呼ばれて初めて実行されることを、std::cell::Cell<u32>を使ったカウンタで実証するテストを書いてください。具体的には、mapのクロージャ内でカウンタをインクリメントし、①mapを呼んだ直後はカウンタが0のまま、②collect()した後はカウンタが要素数と一致すること、の両方をassert_eq!で検証してください。
要求4(実装: ジェネリックなイテレータ受け取り関数)
impl Iterator<Item = i64>というトレイト境界を使ったジェネリック関数として、次の2つを実装してください。
sum_until_threshold(iter: impl Iterator<Item = i64>, threshold: i64) -> i64:take_whileでthreshold未満の要素だけを取り、その合計を返すcombined_sum(a: impl Iterator<Item = i64>, b: impl Iterator<Item = i64>) -> i64:chainで2つのイテレータを連結し、その合計を返す
これらの関数がVecの.into_iter()だけでなく、Range(0..10など)のような別の型のイテレータもそのまま渡せることをテストで示してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
filterはFn(&Self::Item) -> boolを要求します。Itemが&Transactionであれば、クロージャの引数は&&Transactionになる点に注意してください(|t: &&Transaction|または型推論に任せる)zipは2つのイテレータのうち短い方の長さに揃えてペアを作ります。長さが違ってもpanicしませんCell<T>はRefCellと違いCopyな値(u32など)を、不変参照(&self)経由でも書き換えられる内部可変性の型です。クロージャは&Cell<u32>をキャプチャすれば、Fn(不変クロージャ)のままカウンタを更新できます
ヒント2 — アプローチ
total_valid_amount:transactions.iter().filter(|t| t.is_valid).map(|t| t.amount).sum()という1行で書けます。sum()は戻り値の型注釈(関数の戻り値i64)からi64版のSum実装が選ばれますmatching_indices:a.iter().zip(b.iter()).enumerate().filter(|(_, (x, y))| x == y).map(|(i, _)| i).collect()という流れです。zipが先かenumerateが先かで、後段のクロージャで受け取るタプルの形が変わることに注意してくださいCell<u32>のインクリメントはcounter.set(counter.get() + 1)で行います。クロージャの外からはcounter.get()で現在値を読みますimpl Iterator<Item = i64>はジェネリクスの糖衣構文で、fn f<I: Iterator<Item = i64>>(iter: I)と実質同じ意味です。Vec<i64>::into_iter()も(0..10)(Range<i64>)もどちらもIterator<Item = i64>を満たすため、同じ関数にそのまま渡せます
ヒント3 — コード骨格
use std::cell::Cell;
struct Transaction {
amount: i64,
category: String,
is_valid: bool,
}
fn total_valid_amount(transactions: &[Transaction]) -> i64 {
transactions
.iter()
.filter(|t| t.is_valid)
.map(|t| t.amount)
.sum()
}
fn matching_indices(a: &[i32], b: &[i32]) -> Vec<usize> {
a.iter()
.zip(b.iter())
.enumerate()
.filter(|(_, (x, y))| x == y)
.map(|(i, _)| i)
.collect()
}
fn sum_until_threshold(iter: impl Iterator<Item = i64>, threshold: i64) -> i64 {
iter.take_while(|&x| x < threshold).sum()
}
fn combined_sum(a: impl Iterator<Item = i64>, b: impl Iterator<Item = i64>) -> i64 {
a.chain(b).sum()
}
✅ 模範解答
use std::cell::Cell;
// 要求1: Transaction と total_valid_amount
struct Transaction {
amount: i64,
#[allow(dead_code)]
category: String,
is_valid: bool,
}
fn total_valid_amount(transactions: &[Transaction]) -> i64 {
transactions
.iter()
.filter(|t| t.is_valid)
.map(|t| t.amount)
.sum()
}
// 要求2: matching_indices
fn matching_indices(a: &[i32], b: &[i32]) -> Vec<usize> {
a.iter()
.zip(b.iter())
.enumerate()
.filter(|(_, (x, y))| x == y)
.map(|(i, _)| i)
.collect()
}
// 要求4: impl Iterator<Item = i64> を境界に取るジェネリック関数
fn sum_until_threshold(iter: impl Iterator<Item = i64>, threshold: i64) -> i64 {
iter.take_while(|&x| x < threshold).sum()
}
fn combined_sum(a: impl Iterator<Item = i64>, b: impl Iterator<Item = i64>) -> i64 {
a.chain(b).sum()
}
fn main() {
// 要求1の実行例
let transactions = vec![
Transaction { amount: 1000, category: "food".to_string(), is_valid: true },
Transaction { amount: 500, category: "book".to_string(), is_valid: false },
Transaction { amount: 2000, category: "rent".to_string(), is_valid: true },
];
println!("total_valid_amount = {}", total_valid_amount(&transactions)); // 3000
// 要求2の実行例
let a = [1, 2, 3, 4, 5];
let b = [1, 9, 3, 9, 5];
println!("matching_indices = {:?}", matching_indices(&a, &b)); // [0, 2, 4]
// 要求4の実行例: Vec と Range のどちらも同じ関数に渡せる
let v = vec![10_i64, 20, 30, 100, 200];
println!("sum_until_threshold(v) = {}", sum_until_threshold(v.into_iter(), 50)); // 60
println!("sum_until_threshold(range) = {}", sum_until_threshold(0..100_i64, 10)); // 45
println!("combined_sum = {}", combined_sum(vec![1, 2, 3].into_iter(), (10..13).into_iter())); // 6+33=39
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn total_valid_amount_sums_only_valid_transactions() {
let transactions = vec![
Transaction { amount: 1000, category: "food".to_string(), is_valid: true },
Transaction { amount: 500, category: "book".to_string(), is_valid: false },
Transaction { amount: 2000, category: "rent".to_string(), is_valid: true },
];
assert_eq!(total_valid_amount(&transactions), 3000);
}
#[test]
fn matching_indices_finds_equal_positions_with_shorter_length() {
let a = [1, 2, 3, 4, 5];
let b = [1, 9, 3, 9]; // aより短い。zipはbの長さに揃わる
assert_eq!(matching_indices(&a, &b), vec![0, 2]);
}
// 要求3: 遅延評価の実証
#[test]
fn iterator_adapters_are_lazy_until_consumed() {
let counter = Cell::new(0u32);
let data = vec![1, 2, 3, 4, 5];
let iter = data.iter().map(|x| {
counter.set(counter.get() + 1);
x * 2
});
// ① map を呼んだ直後: クロージャは1回も実行されていない
assert_eq!(counter.get(), 0);
// ② collect() で初めて next() が5回呼ばれ、クロージャも5回実行される
let result: Vec<i32> = iter.collect();
assert_eq!(counter.get(), 5);
assert_eq!(result, vec![2, 4, 6, 8, 10]);
}
#[test]
fn take_while_short_circuits_and_does_not_process_remaining_elements() {
let counter = Cell::new(0u32);
let data = vec![1, 2, 3, 100, 4, 5]; // 100 で take_while が止まるはず
let result: Vec<i32> = data
.iter()
.map(|x| {
counter.set(counter.get() + 1);
*x
})
.take_while(|&x| x < 10)
.collect();
assert_eq!(result, vec![1, 2, 3]);
// 100 を処理した時点で take_while が止まるため、後続の 4, 5 は map にすら渡らない
assert_eq!(counter.get(), 4); // 1, 2, 3, 100 の4要素分だけ実行される
}
// 要求4: Vec と Range のどちらでも同じ関数が使えることの確認
#[test]
fn generic_iterator_functions_accept_different_iterator_types() {
let from_vec = sum_until_threshold(vec![10_i64, 20, 30, 100].into_iter(), 50);
assert_eq!(from_vec, 60);
let from_range = sum_until_threshold(0..100_i64, 10);
assert_eq!(from_range, 45); // 0+1+...+9 = 45
let combined = combined_sum(vec![1_i64, 2, 3].into_iter(), 10..13);
assert_eq!(combined, 6 + 33); // (1+2+3) + (10+11+12)
}
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
total_valid_amount = 3000
matching_indices = [0, 2, 4]
sum_until_threshold(v) = 60
sum_until_threshold(range) = 45
combined_sum = 39
$ cargo test
running 5 tests
test tests::generic_iterator_functions_accept_different_iterator_types ... ok
test tests::iterator_adapters_are_lazy_until_consumed ... ok
test tests::matching_indices_finds_equal_positions_with_shorter_length ... ok
test tests::take_while_short_circuits_and_does_not_process_remaining_elements ... ok
test tests::total_valid_amount_sums_only_valid_transactions ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
🪜 Step-by-Step 解説
filter→map→sumの連鎖transactions.iter().filter(|t| t.is_valid).map(|t| t.amount).sum().iter()は&Transactionを要素とするイテレータを返します。filterのクロージャ引数は&&Transaction(イテレータの要素&Transactionへの参照)になりますが、フィールドアクセスt.is_validは自動参照外しが働くため、そのまま書けます。mapで&Transactionからi64(amountフィールド、Copy型)を取り出し、sum()が最終的にIterator<Item = i64>からi64を1つに畳み込みます。3つのメソッドはすべて「新しいイテレータを返すか、値を1つ返すか」であり、中間のVecは一切生成されません。
zipとenumerateの順序a.iter().zip(b.iter()).enumerate().filter(|(_, (x, y))| x == y).map(|(i, _)| i).collect()zipが先なので、enumerateが受け取るのは「(&i32, &i32)のペアの列」です。したがってenumerateの出力要素は(usize, (&i32, &i32))というタプルの中にタプルが入った形になります。filterのクロージャで(_, (x, y))とパターンマッチしてペアの中身を取り出し、x == yで比較しています。zipは短い方のイテレータがNoneを返した時点で全体が終了するため、長さの異なるスライスを渡しても安全です。
Cell<u32>によるlazinessの可視化let iter = data.iter().map(|x| { counter.set(counter.get() + 1); x * 2 });
assert_eq!(counter.get(), 0); // まだ実行されていない
let result: Vec<i32> = iter.collect();
assert_eq!(counter.get(), 5); // collect() が next() を5回呼んだ
mapの呼び出しは「Mapという名前のイテレータ構造体」を組み立てているだけで、内部のクロージャは1度も呼ばれません。Map構造体は元のイテレータ(data.iter())とクロージャを保持しているだけの、実行計画(レシピ)に過ぎないのです。collect()がVecを組み立てるために内部でnext()を5回呼び、そのnext()の実装の中で初めてクロージャが実行されます。take_whileのテストでは、4要素目(100)で条件を満たさなくなった瞬間にそれ以降の要素はmapのクロージャにすら渡されないことを示しています。イテレータチェーン全体が「要素ごとに全段のアダプタを通過してから次の要素に進む」という、1要素ずつのパイプライン処理になっているためです。
impl Iterator<Item = i64>によるジェネリック関数fn sum_until_threshold(iter: impl Iterator<Item = i64>, threshold: i64) -> i64 {
iter.take_while(|&x| x < threshold).sum()
}
impl Traitを引数位置で使うと、コンパイラは呼び出し側の実際の型(std::vec::IntoIter<i64>やstd::ops::Range<i64>など)ごとに別々の関数を生成します(単相化/monomorphization)。これはBox<dyn Iterator<Item = i64>>のような動的ディスパッチとは異なり、実行時のvtable経由の呼び出しコストが一切発生しない静的ディスパッチです。テストでVec::into_iter()とRangeの両方を同じ関数に渡せているのは、両者がまったく異なる具体型でありながら、どちらもIterator<Item = i64>というトレイト境界さえ満たしていれば区別なく扱えるという、トレイト境界によるダックタイピング的な柔軟性の表れです。
💡 設計思想・なぜこう書くのか
Iteratorトレイトの設計は、「最小限のインターフェース(nextひとつ)から、巨大な機能群をデフォルト実装で導出する」というRustのトレイト設計哲学の到達点です。実装者(新しい型にIteratorを実装する側)はnext()だけを書けばよく、map・filter・zip・sumなど数十のメソッドをタダで手に入れます。これは「継承によるコード再利用」ではなく「トレイトのデフォルト実装によるコード再利用」であり、多重継承の複雑さ(菱形継承問題など)を持ち込まずに再利用性を実現しています。Vecを作らず、コンパイラはmap().filter().sum()のような連鎖をインライン化した上で1つのforループ相当のループに融合(loop fusion)できます。これにより「高レベルで読みやすいコード」と「手書きループと同等の実行速度」を両立させています。C++の<algorithm>ヘッダのアルゴリズムやJavaのStream APIも似た思想を持ちますが、Rustはこれを言語のコア機能であるforループ自体(IntoIteratorによる糖衣構文)にまで一貫して適用している点が特徴です。🛑 コンパイルエラーが出た場合
filterのクロージャで参照の階層を誤ると、次のような型不一致エラーが出ることがあります。
読み方: filterは&Self::Itemを引数に取ります。.iter()のItemは既に&Transactionなので、filterのクロージャ引数は&&Transaction(参照の参照)になります。明示的に型注釈を書くとこの二重参照が露呈しますが、型推論に任せて|t| t.is_validと書けば、コンパイラが自動的に正しい階層を推論してくれるため実務ではほぼ問題になりません。型注釈を書きたい場合は|t: &&Transaction|と書く必要があります。
🌐 他言語との比較
| 観点 | Rust | Java | Go | JavaScript/TypeScript | C++ |
|---|---|---|---|---|---|
| 連鎖処理の仕組み | Iteratorトレイト。next()のみ実装必須、他は全てデフォルト実装 | Stream API。Collection.stream()から生成 | 標準ではfor rangeが基本。Go 1.23でiter.Seq(range-over-func)が追加されたが歴史が浅い | 配列メソッド(.map/.filter)は都度新しい配列を生成(eager)。遅延評価には別途ジェネレータが必要 | <algorithm>ヘッダの関数群、C++20のrangesで遅延評価view対応 |
| 遅延評価か即時評価か | 遅延(アダプタはcollect等の消費時まで実行されない) | 遅延(Streamは終端操作まで実行されない、Rustと同じ設計思想) | 該当なし(都度即時実行が基本) | 即時評価(.map().filter()は各段階で新配列を作る) | ranges::viewsは遅延、古典<algorithm>は即時 |
| 中間コレクションの生成 | 生成しない(要素ごとのパイプライン処理) | 生成しない(Streamも同様の設計) | 該当なし | 毎回生成する(.mapの結果、.filterの結果とそれぞれ新しい配列がヒープに確保される) | viewsは生成しない、<algorithm>は出力先次第 |
| 静的/動的ディスパッチの選択 | impl Traitで静的、Box<dyn Iterator>で動的、を明示的に選べる | JITが実行時に最適化するが、基本は仮想メソッド呼び出し(動的) | インターフェース経由は動的ディスパッチのみ | 動的(プロトタイプチェーン経由) | テンプレートは静的、仮想関数は動的、と明示的に選べる(Rustと似た設計) |
JavaScriptの配列メソッドが「各段階で新しい配列を作る」のは、Rustのイテレータアダプタと最も対照的な点です。arr.map(f).filter(g)は、mapの結果として中間配列がまるごと1つヒープに確保され、その配列に対して改めてfilterが走ります。要素数が多いパイプラインほど、Rustとの実行効率の差が開いていきます。JavaのStream APIはRustと同じ遅延評価の設計思想を持ちますが、値のボクシング(プリミティブ型をオブジェクトとして扱うオーバーヘッド)が発生しやすい点でRustのゼロコスト抽象化とは異なります。
🏆 実務での使いどころ
- ETL/データ変換パイプライン: ログ行のパース→フィルタリング→集計、CSVレコードの検証→変換→DB挿入用構造体への詰め替えなど、複数段階のデータ変換をアダプタチェーンとして宣言的に書くのが実務での最頻出パターンです
- 大量データのメモリ効率的な走査: ファイルを1行ずつ読む
BufRead::lines()とfilter/take_whileを組み合わせれば、ファイル全体をメモリに読み込まずにストリーミング処理できます。遅延評価が「必要な分だけ計算する」ことを型システムレベルで保証しています impl Iterator<Item = T>によるAPI設計: ライブラリの公開関数が「Vecを受け取る」のではなく「Iteratorを受け取る」ように設計すると、呼び出し側はVec・HashSet・Range・自作イテレータなど、あらゆるソースからそのままデータを渡せる柔軟なAPIになります
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
iter.map(...)と書いた行で処理が実行されると思い込む | 他言語(JavaScript配列メソッド等)の即時評価に慣れていると自然にそう見える | イテレータアダプタは「計算のレシピ」を作るだけで、collect/sum/for_eachなどの消費アダプタが呼ばれるまで一切実行されない。未使用のイテレータはunused_must_use系の警告が出ることもある |
.iter()・.into_iter()・.iter_mut()をなんとなく使い分けている | どれも「ループできる」という結果だけを見ると違いが分かりにくい | .iter()は&T(借用・読み取り専用)、.iter_mut()は&mut T(可変借用)、.into_iter()はT(所有権ごと消費)を生成する。for x in vecと書くとinto_iter()が呼ばれvecは以降使えなくなる点は所有権システム特有の落とし穴 |
sum()やcollect()の戻り値の型が推論できずコンパイルエラーになる | 動的型付け言語の感覚だと「なんとなく合計」で済むと思いがち | sum::<i64>()のようにターボフィッシュで明示するか、変数の型注釈(let total: i64 = ...)や関数の戻り値の型から推論させる必要がある。Iterator::sumはSumトレイトを実装した任意の型に対して汎用的に定義されているため、型が一意に決まらないと推論に失敗する(E0282) |
🚀 次のステップ
- 発展:
matching_indicesをwindows(2)(隣接ペアを走査するスライスメソッド)を使って書き換え、「連続する2要素の差が一定値以上になる箇所」を検出する関数に発展させてみてください - 次回予告: Day 017ではカスタムイテレータの実装を扱います。自作の型に対して
Iteratorトレイトを自前実装し、next()をどう設計すればアダプタ群がタダで使えるようになるかを学びます