11.2 迭代器(Iterators)的高效處理與運作原理#
上一節的閉包解決了「行為如何被捕捉與傳遞」,本節把它放進最大的應用舞台:迭代器。for 迴圈在 Rust 底層就是迭代器驅動的,而 map、filter 這些組合子讓整條資料處理流程可以用一串宣告式呼叫表達。更關鍵的是,這些看似高階的抽象在編譯後與手寫迴圈同樣快——迭代器是 Rust「零成本抽象」承諾最完整的示範。
Iterator 特徵與惰性求值#
迭代器的全部核心只有一個方法。標準庫的定義是:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// 其餘方法皆有預設實作
}
關聯型別 Item 宣告這個迭代器吐出什麼,next 每次回傳一個元素,耗盡後回傳 None。所有其他的組合子——map、filter、take 等上百個方法——都只是這個方法的包裝。
fn main() {
let v = vec![10, 20, 30];
let mut it = v.iter();
println!("{:?}", it.next());
println!("{:?}", it.next());
println!("{:?}", it.next());
println!("{:?}", it.next());
}
與 Iterator 配對的是 IntoIterator 特徵:任何實作它的型別都可以用 into_iter() 轉成迭代器,而 for 迴圈的語法糖正是「對 into_iter() 的結果不斷呼叫 next」。Vec、陣列、HashMap、範圍 0..n 都實作了它。
迭代器是惰性的(lazy):建立一條迭代器鏈不執行任何計算,只有真正呼叫 next(通常由 for 迴圈或 collect 觸發)時,元素才會一個個被拉出來處理。
fn main() {
let v = vec![1, 2, 3, 4, 5];
let iter = v.iter().map(|x| x * 2).filter(|&x| x > 4);
println!("還沒有任何計算發生");
let result: Vec<i32> = iter.collect();
println!("{result:?}");
}
這段程式在 collect 之前只建立了描述,沒有跑過一次乘法或比較。惰性不只是效能特性,更是正確性特性:無限序列因此可以存在,take 也才能在取滿之後停止推進。
適配器與消費者#
迭代器方法分為兩類。適配器(adapters)回傳新的迭代器,惰性地轉換元素串流;消費者(consumers)驅動 next,把串流收成最終結果。常用的適配器與消費者整理如下:
| 類別 | 方法 | 作用 |
|---|---|---|
| 適配器 | map | 對每個元素套用函數 |
| 適配器 | filter | 保留判準為真的元素 |
| 適配器 | filter_map | 轉換並過濾,None 被丟棄 |
| 適配器 | take / skip | 取前 N 個 / 略過前 N 個 |
| 適配器 | enumerate | 附加索引 |
| 適配器 | zip | 兩條迭代器配對成元組 |
| 適配器 | chain | 串接兩條迭代器 |
| 適配器 | flat_map | 映射後攤平 |
| 消費者 | collect | 收集成集合型別 |
| 消費者 | fold | 摺疊成單一值 |
| 消費者 | sum / any / all / find | 求和、存在、全稱、首次命中 |
fn main() {
let squares: Vec<i32> = (1..=5).map(|x| x * x).collect();
println!("{squares:?}");
let evens: Vec<i32> = (1..=10).filter(|x| x % 2 == 0).collect();
println!("{evens:?}");
let words = ["apple", "fig", "banana"];
let lengths: Vec<(usize, String)> = words
.iter()
.enumerate()
.map(|(i, w)| (i, w.len()))
.collect();
println!("{lengths:?}");
let total: i32 = (1..=100).sum();
let any_gt_90 = (1..=100).any(|x| x > 90);
let all_pos = (1..=100).all(|x| x > 0);
let first_sq_gt_500 = (1..=100).map(|x| x * x).find(|&x| x > 500);
println!("{total} {any_gt_90} {all_pos} {first_sq_gt_500:?}");
}
這些組合子能串成任意長度的鏈,每個環節只描述一步轉換。鏈越長,惰性管線的優勢越明顯——元素像流水線上的工件,一個接一個穿過所有工位,而不是每個工位各自等一整批。
graph LR
SRC["來源 (1..=5)<br/>每次 next 吐一個"] --> MAP["map(x * x)<br/>1, 4, 9, 16, 25"]
MAP --> FIL["filter(x > 5)<br/>9, 16, 25"]
FIL --> TAKE["take(2)<br/>9, 16"]
TAKE --> COL["collect()"]
COL --> OUT["Vec [9, 16]"]
style SRC fill:#d1ecf7
style COL fill:#d6f5d6collect 的目標型別由接收端的型別標註決定,這是它最重要的推導規則。收集到 Vec 最常見;收集到 String 適用於 char 或 &str 的串流;收集到 HashMap 需要元素是 (K, V) 元組:
use std::collections::HashMap;
fn main() {
let v: Vec<i32> = (1..=3).collect();
let s: String = ['R', 'u', 's', 't'].iter().collect();
let m: HashMap<&str, i32> =
[("a", 1), ("b", 2)].into_iter().collect();
println!("{v:?} {s} {m:?}");
}
三種借用語意也需要區分:iter() 借用元素(&T)、iter_mut() 可變借用(&mut T)、into_iter() 取得所有權(T,容器被消耗)。選擇哪一個,就是在宣告迴圈結束後原容器是否還要使用。
零成本的數學直觀#
迭代器鏈為什麼不比手寫迴圈慢?直觀的答案藏在編譯器做的事裡。一條像 map(f).filter(g).take(2) 的鏈,執行語意等價於一個帶三層狀態的單一迴圈;編譯器對每個適配器做內聯之後,鏈就坍縮成一個扁平迴圈,所有中間狀態都被放進暫存器。若把整條鏈看成對序列施加的函數合成,一個元素 \(x\) 穿過鏈的成本是:
而對全體元素的總成本是 \(\sum_{x \in S} c_{\text{chain}}(x)\) ——每個元素恰好穿過鏈一次,沒有任何批次中間集合被建立。相比之下,先 map 收成 Vec 再 filter 收成另一個 Vec 的寫法,會產生 \(\lvert S \rvert\) 與中間結果大小兩倍的配置與搬移。
實際上 LLVM 的內聯與逃逸分析通常做得更好:固定長度的小迴圈會被完全展開,邊界檢查被合併消除。觀察的方式很直接——cargo install cargo-show-asm 之後用 cargo asm --rust 核心函式名 就能看到編譯後的組譯,確認迭代器鏈是否真的坍縮成了與手寫版本相同的機器碼。
迭代器與借用檢查器的張力#
零成本不等於零限制。迭代器本質上持有對容器的借用,因此借用規則會延伸到迴圈內部:一個可變迭代器存活期間,不能再造出第二個。
fn main() {
let mut v = vec![1, 2, 3, 4];
for x in v.iter_mut() {
*x *= 2;
// let y = v.len(); // 編譯錯誤:v 已被 iter_mut 可變借用
}
println!("{v:?}");
}
這段程式若取消註解就無法編譯——iter_mut 佔住了整個 Vec 的可變借用。張力的典型場景是「同時處理前半與後半」,例如一個分割演算法需要兩個互不重疊的可變區段。借用檢查器只看得到「整個容器的一次可變借用」,看不到「兩個不重疊的區段」,此時標準庫的 split_at_mut 提供了解法:
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let (left, right) = v.split_at_mut(3);
for x in left.iter_mut() {
*x += 100;
}
for y in right.iter_mut() {
*y *= 10;
}
println!("{v:?}");
}
split_at_mut 回傳兩個獨立的可變切片,借用檢查器接受它們並存——因為標準庫在實作中用 unsafe 證明了兩個切片指向的記憶體確實不重疊,然後把這個證明封裝成安全介面。這與上一節 UnsafeCell 的故事一脈相承:unsafe 被隔離在證明成立之處,安全抽象在其上開放。
效能誤區:過早 collect#
惰性最常被破壞的方式,是在鏈的中間插入 collect:
fn main() {
let data: Vec<i32> = (1..=1_000_000).collect();
let slow: Vec<i32> = data
.iter()
.map(|x| x * 2)
.collect::<Vec<i32>>()
.into_iter()
.filter(|x| x % 3 == 0)
.collect();
let fast: Vec<i32> = data
.iter()
.map(|x| x * 2)
.filter(|x| x % 3 == 0)
.copied()
.collect();
println!("{} {}", slow.len(), fast.len());
}
slow 的寫法把一百萬個元素的 Vec 建了又拆、拆了又建,每次 collect 都是一次完整的配置與寫入;fast 讓所有元素只流過一條管線。兩者結果相同,但 slow 付出了數次百萬級的記憶體流量。判準很簡單:中間結果沒有被單獨使用,就不要 collect。反過來說,如果中間集合確實需要被多次讀取(例如先建索引再多次查詢),collect 就是必要且正確的。
用 fold 重寫一條迭代器鏈#
迭代器鏈與 fold 之間存在精確的對應:任何消費者鏈都可以收斂成一次 fold。下面的例子把「過濾、轉換、累加」三步寫成兩種形式:
fn main() {
let data = [3, 14, 15, 9, 26, 5];
let chain_total: i32 = data
.iter()
.filter(|&&x| x > 5)
.map(|&x| x * 10)
.sum();
let fold_total = data.iter().fold(0, |acc, &x| {
if x > 5 {
acc + x * 10
} else {
acc
}
});
assert_eq!(chain_total, fold_total);
println!("鏈式 = {chain_total}, fold = {fold_total}");
}
兩者產出完全相同的值,且編譯後的機器碼也幾乎相同。fold 的價值在於當累積邏輯無法拆成純串流的映射與過濾時——例如同時維護最大值與總和——它提供了一個不犧牲單次遍歷的統一出口。fold 的簽名正是「以狀態機的方式消化串流」:初值是起始狀態,閉包是轉移函數。
小結#
迭代器把「逐一處理元素」抽象成 next 方法之上的可組合管線:適配器惰性地描述轉換,消費者驅動計算,collect 的目標型別由接收端決定。零成本抽象在這裡得到最完整的體現——整條鏈在編譯後坍縮成單一迴圈,抽象層數不產生任何執行期稅。同時,借用檢查器的規則穿過抽象層持續生效,split_at_mut 展示了標準庫如何在必要處用受控的 unsafe 化解張力。
閉包與迭代器至此構成完整的第 11 章:前者把環境打包成值,後者把迴圈提升為管線。下一步要把這些工具推向真正的並行世界——第 12 章將介紹執行緒與 Send/Sync,屆時 move 閉包會成為跨執行緒轉移所有權的標準載體,Arc<Mutex<T>> 會接手第 10 章留下的共享可變狀態,而迭代器鏈也將在 rayon 這類函式庫中被平行化成一條程式碼不變、速度翻倍的管線。