Rustのイテレータは「怠け者」だ — ログで証明する
Rustのイテレータは 遅延評価(lazy evaluation) だ。
「知ってる」と思ったかもしれない。でも本当に理解しているかを確かめるには、実際にログを仕込んで動かしてみるのが一番だ。
.filter() → .map() → .take() と3つのアダプタをチェーンしたとき、何回処理が走ると思うか? 要素数をNとして、3N回? それとも別の数?
答えは「必要な分だけ」だ。
1. 素朴な予想 vs 実際の動作
10要素のVecに対して次の処理をするとしよう:
- 偶数だけを残す(filter)
- 10倍にする(map)
- 最初の3件だけ取る(take)
素朴な予想:
filter × 10回 → map × 5回(偶数が5個)→ take × 3件 = 合計18回
全部のfilterが終わってから次に進む、というイメージ。実際にはそうならない。
コード
fn main() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let result: Vec<i32> = data.iter()
.filter(|&&x| {
println!(" filter: {}", x);
x % 2 == 0
})
.map(|&x| {
println!(" map: {}", x);
x * 10
})
.take(3)
.collect();
println!("\nresult: {:?}", result);
}
実行結果
filter: 1
filter: 2
map: 2
filter: 3
filter: 4
map: 4
filter: 5
filter: 6
map: 6
result: [20, 40, 60]
何が起きているか
filterとmapが交互に呼ばれている。filterが10回走り終わってからmapが走るのではない。
1要素ずつ、パイプラインを通過する。take(3) が3件揃った時点で、残りの要素には一切触れない。
- 処理された要素数:1〜6(7〜10は触れられていない)
- filterの呼び出し回数:6回(10回ではない)
- mapの呼び出し回数:3回(5回ではない)
これが遅延評価だ。collect() が呼ばれるまで、パイプライン全体は何もしない。要素を一個ずつ引っ張りながら、必要な数だけ処理して止まる。
2. N回 vs 4N回 — チェーンしても増えない
では、アダプタを4つにしたら処理回数は4倍になるか?
fn main() {
let data: Vec<i32> = (1..=20).collect();
let result: Vec<i32> = data.iter()
.filter(|&&x| {
println!(" filter: {}", x);
x % 2 == 0
})
.map(|&x| {
println!(" map×10: {}", x);
x * 10
})
.filter(|&x| {
println!(" filter2: {}", x);
x > 50
})
.map(|x| {
println!(" map+1: {}", x);
x + 1
})
.take(3)
.collect();
println!("\nresult: {:?}", result);
}
実行結果
filter: 1
filter: 2
map×10: 2
filter2: 20
filter: 3
filter: 4
map×10: 4
filter2: 40
filter: 5
filter: 6
map×10: 6
filter2: 60
map+1: 60
filter: 7
filter: 8
map×10: 8
filter2: 80
map+1: 80
filter: 9
filter: 10
map×10: 10
filter2: 100
map+1: 100
result: [61, 81, 101]
アダプタが4つになっても、処理は「1要素がパイプラインを通り抜ける」という単位で進む。20要素に対して4N回 = 80回走るのではなく、3件取れた時点で止まる。
これがRustのイテレータがゼロコスト抽象と呼ばれる理由の一つだ。どれだけアダプタをチェーンしても、実行時のループは1本になる。
3. ベンチマークで確認する — criterion
ログで動作は分かった。ではパフォーマンスはどうか。
チェーンしたイテレータは読みやすいが、手書きの for ループより遅いのでは? というのが自然な疑問だ。criterion を使って確かめる。
セットアップ
プロジェクト構成:
benches/iterator_bench.rs
Cargo.toml
src/lib.rs ← 空でよい
# Cargo.toml
[package]
name = "rust-iter"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "iterator_bench"
harness = false
// benches/iterator_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
#[inline(never)]
fn chained_iterator(data: &[i32]) -> Vec<i32> {
data.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 10)
.filter(|&x| x > 50)
.map(|x| x + 1)
.take(3)
.collect()
}
#[inline(never)]
fn manual_loop(data: &[i32]) -> Vec<i32> {
let mut result = Vec::new();
for &x in data {
if x % 2 == 0 {
let y = x * 10;
if y > 50 {
result.push(y + 1);
if result.len() == 3 { break; }
}
}
}
result
}
fn bench(c: &mut Criterion) {
let data: Vec<i32> = (1..=1000).collect();
c.bench_function("chained_iterator", |b| b.iter(|| chained_iterator(black_box(&data))));
c.bench_function("manual_loop", |b| b.iter(|| manual_loop(black_box(&data))));
}
criterion_group!(benches, bench);
criterion_main!(benches);
#[inline(never)] を付ける理由:付けないとコンパイラがベンチマークハーネス側に関数をインライン展開し、境界をまたいだ最適化が走って測定値が信頼できなくなる。black_box が入力側の最適化を防ぎ、#[inline(never)] が関数単体の計測を保証する。
cargo bench
結果
chained_iterator time: [17.628 ns 17.669 ns 17.716 ns]
manual_loop time: [18.279 ns 18.347 ns 18.416 ns]
数値はほぼ同じだ。誤差の範囲内で、むしろイテレータの方がわずかに速い。
これは偶然ではない。LLVMはクリーンなイテレータチェーンに対して、手書きループよりも積極的な最適化を適用することがある。少なくとも「遅くなる」ことはない。
チェーンしたイテレータは、手書きループと同等以上のパフォーマンスになる。アダプタをいくつ重ねても、実行時のコストは増えない。
これがRustの「ゼロコスト抽象」の意味だ — 抽象化のための実行時コストがゼロ。
4. なぜ同じなのか — cargo-show-asm で確認する
criterionで数値が同じになった理由を、アセンブリで確かめる。
コンパイラはチェーンしたイテレータを、リリースビルドで1本のループに畳み込む。アダプタごとの関数呼び出しは全てインライン展開され、中間アロケーションも発生しない。
セットアップ
cargo install cargo-show-asm
関数はベンチファイルにあるため、--bench フラグで対象を指定する:
cargo show-asm --release --bench iterator_bench chained_iterator
cargo show-asm --release --bench iterator_bench manual_loop
結果
両方のASMを並べると、コンパイラが同じ構造のコードを生成していることがわかる。
共通パターン(両方に存在):
take(3)のアンロール — カウントループではなく、3回分のブロックが個別に展開されている(LBB38_7/14/21とLBB39_1/6/10)- ビットテストによる偶数チェック —
x % 2 == 0がtbnz w8, #01命令にコンパイルされている - 乗算なしの
x * 10—add w8, w8, w8, lsl #2+lsl w8, #1(シフトと加算で代替)
注目すべき差異:
chained_iterator の方がASMが短い。manual_loop は例外処理のセットアップ(.cfi_personality、Lexception ブロック)と grow_one 呼び出しを持つが、chained_iterator はコンパイラがアロケーションパターンを最適化し、upfrontの __rust_alloc 1回に畳み込んでいる。
これがベンチマークでイテレータがわずかに速かった理由だ。抽象化のコストがゼロなだけでなく、LLVMがクリーンなイテレータチェーンに対してより積極的な最適化を適用できた結果、手書きループより少ないオーバーヘッドになった。
補足: このASMはApple Silicon(ARM64/AArch64)上の結果。x86_64では命令セットは異なるが、同じ最適化パターンが適用される。
まとめると:
ログ → 動作の証明(遅延評価、早期終了)
bench → パフォーマンスの証明(同等以上)
ASM → なぜ同等なのかの説明(同じ構造にコンパイルされる)
チェーンしたイテレータは読みやすく、手書きループと同等以上に速い。これがゼロコスト抽象だ。