Ch6:閉包與迭代器 — 持續開出的支票
·
Sean Sie
閉包 (Closure):帶環境的匿名函數
閉包語法很精簡:|參數| 表達式。它與普通函數 (fn) 的最大區別在於它能 「捕獲環境」。普通函數像是個冷酷的機器人,只看參數;閉包則像是個有感情的人,會記得周圍發生了什麼事。
捕獲環境的三種姿勢
Rust 的編譯器會根據你如何使用變數,自動決定最寬鬆(效能最高)的捕獲方式。這其實是借用檢查器在背後幫你操心:
- 不可變借用 (&T):預設模式。只是拿來讀取,不改動。像是閉包隔著櫥窗看了一眼你手上的書,這對環境影響最小。
- 可變借用 (&mut T):當閉包內部需要修改環境變數時觸發。注意,這會導致該變數在閉包存活期間被鎖定,不能有其他借用。
- 所有權轉移 (move):直接把環境變數「綁架」過來,強行轉移所有權。
- ⚡ 為什麼要 move? 最常見的場景是「跨執行緒」。當你把閉包丟進
thread::spawn時,主執行緒可能在子執行緒跑完前就掛了(變數被銷毀)。透過move,閉包把資料帶進自己的口袋,確保資料在閉包執行完畢前都是有效的.
- ⚡ 為什麼要 move? 最常見的場景是「跨執行緒」。當你把閉包丟進
Fn 系列 Trait:閉包的證書
閉包也有自己的階級制度,這決定了它們如何處理被捕獲的資料,也決定了它們能被呼叫幾次:
- Fn:最高等級. 可以被呼叫多次,且不會消耗或改變捕獲的環境. 它是「純讀取」,像是一張隨便看的照片.
- FnMut:次之. 可以呼叫多次,但會修改捕獲的環境. 因為它會改動內部狀態(例如一個
mut counter),所以呼叫者也必須擁有閉包的可變權限. - FnOnce:最後一名. 只能呼叫一次. 通常是因為它在執行時會把捕獲的資料「吃掉」(轉移所有權). 一旦吃掉,就再也沒有資料可以跑第二次了.
💡 小知識: 這三者有繼承關係. 能當 Fn 的一定能當 FnMut,能當 FnMut 的一定能當 FnOnce. Rust 社群的建議是:傳入參數時盡量要求 Fn,真的不行才降級.
迭代器 (Iterator):懶惰就是力量
迭代器是 Rust 的招牌. 別把它想得太玄,它本質上就是一個 「帶有 next() 方法的物件」 .
⚡ 「支票」比喻:為什麼它這麼神?
你可以把迭代器鏈想像成去銀行領錢的過程. 這套機制的核心在於 「懶惰求值 (Lazy Evaluation)」 :
- 開支票 (Creation):例如
iter()、into_iter().- 這時資料根本還沒開始跑. 這張支票只是個「承諾」,銀行還沒點錢,你口袋也還是空的. 你甚至可以開出一張「無限」的支票(例如
0..),只要你不去兌現,它就不會爆掉.
- 這時資料根本還沒開始跑. 這張支票只是個「承諾」,銀行還沒點錢,你口袋也還是空的. 你甚至可以開出一張「無限」的支票(例如
- 換支票 (Adapters):
map、filter、take、skip等.- 重點:這些動作是「懶惰的」. 你加註了再多規則(例如:過濾偶數、乘以二),只要你不去領錢,銀行員(編譯器)連動都不會動一下. 它們只是把新的邏輯封裝進一個新的迭代器物件裡.
- 兌現 (Consumers):
collect、fold、sum、count、nth等.- 直到這一步,你把支票拍在櫃檯說:「現在,給我領錢!」,程式才會真正開始跑
next()迴圈,從頭到尾按照你剛才疊加的所有規則去計算資料.
- 直到這一步,你把支票拍在櫃檯說:「現在,給我領錢!」,程式才會真正開始跑
FP 高階函數深度解析
這才是工程師展示「專業感」的地方. 學會這些組合技,你的程式碼會從三、四十行縮減到三、四行,而且更難寫錯.
1. 轉換與展平:Map, FlatMap 與 FilterMap
- map(f):一對一. 把每個元素換成另一種樣貌.
- flat_map(f):一對多或一對零.
- ⚡ 必學場景:如果轉換函數回傳的是一個
Option或Vec,flat_map它可以幫你把「包裹」拆開並把內容物攤平,自動過濾掉None或空的容器.
- ⚡ 必學場景:如果轉換函數回傳的是一個
- filter_map(f):轉換與過濾的結合體. 如果閉包回傳
Some(v),結果就是v;如果回傳None,該元素直接被踢掉.
let words = vec!["apple", "banana", "123"];
let numbers: Vec<i32> = words.iter()
.filter_map(|s| s.parse().ok()) // 嘗試解析成數字,解析失敗的直接過濾掉
.collect();
// 結果: [123]
2. 狀態摺疊:Fold vs Scan
- fold(init, f):從初始值開始,把所有元素「揉」成一個最終結果. 這是所有迭代器消費者的老祖宗,連
sum都是用它做的. - scan(init, f):
fold的「中間過程版」 . 它會回傳一個迭代器,記錄每一次累計的結果.- ⚡ 實戰:計算「累計總合」或追蹤某個在處理過程中的狀態.
let nums = vec![1, 2, 3, 4];
let running_total: Vec<i32> = nums.iter()
.scan(0, |state, &x| {
*state += x;
Some(*state)
})
.collect();
// 結果: [1, 3, 6, 10]
3. 分類、搜尋與判斷
- partition(f):這是一個極其強大的
Consumer. 它會根據條件把資料流拆成兩個Vec(一個 True,一個 False),一口氣分類完畢. - find(f):搜尋第一個符合條件的元素. 它是 「短路 (Short-circuiting)」 的,只要找到了,後面的元素看都不看,效能極佳.
- any(f) / all(f):只要有一個符合 / 全部都符合. 同樣支援短路機制.
4. 進階操作:Zip, Rev 與 Inspect
- zip(other):把兩個迭代器像拉鍊一樣扣在一起,產生一對一的元組
(a, b). - rev():反轉迭代器. 注意:這只適用於 「雙向迭代器 (DoubleEndedIterator)」 ,比如
Vec可以,但某些串流就不行. - inspect(f):除錯神招 . 它不會改變資料,只是讓你在迭代器鏈的中間「偷看」一下現在資料長什麼樣子,非常適合拿來印
println!.
為什麼迭代器效能「頂天」?
很多人覺得 FP 寫法經過這麼多層包裝一定會變慢,但在 Rust 裡這叫 「零成本抽象 (Zero-Cost Abstractions)」 :
- 內聯與優化:Rust 編譯器會把一長串的鏈接全部「展開」,把所有閉包的邏輯融合在一起. 產生的組合語言通常跟頂尖工程師手寫的 for 迴圈完全一樣.
- 消除邊界檢查 (Bound Check Elimination):手寫
v[i]時,Rust 每次都要檢查索引有沒有越界. 但迭代器是透過next()移動的,它天生知道資料在哪裡結束,所以編譯器可以安全地跳過這些檢查,這讓它甚至比手寫的 for 迴圈還要快! - 迴圈融合 (Loop Fusion):編譯器會將多個轉換步驟合併成一個單獨的迴圈,不會產生中間的臨時容器(除非你呼叫了
collect).
實戰:手寫一個自己的迭代器
當標準庫不夠用時,你自己定義一個 struct 並實作 Iterator trait 就行了. 這就是 Rust 的美妙之處:一切皆可抽象.
struct Fibonacci {
curr: u32,
next: u32,
}
impl Iterator for Fibonacci {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
let current = self.curr;
self.curr = self.next;
self.next = current + self.next;
Some(current)
}
}
// 使用它:拿取前 10 個費氏數列中是偶數的項
let fib = Fibonacci { curr: 0, next: 1 };
let even_fib: Vec<_> = fib.take(10).filter(|x| x % 2 == 0).collect();
總結建議:別再寫那種老掉牙的索引迴圈了. 練習把邏輯拆解成「開支票 -> 換支票 -> 兌現」,並搭配 collect::<Vec<_>>() 這種 Turbofish 語法 指定型別. 你的 Rust 程式碼不僅會變漂亮,執行速度也會讓其他語言汗顏!