seansie's blog

Ch6:閉包與迭代器 — 持續開出的支票

· Sean Sie

閉包 (Closure):帶環境的匿名函數

閉包語法很精簡:|參數| 表達式。它與普通函數 (fn) 的最大區別在於它能 「捕獲環境」。普通函數像是個冷酷的機器人,只看參數;閉包則像是個有感情的人,會記得周圍發生了什麼事。

捕獲環境的三種姿勢

Rust 的編譯器會根據你如何使用變數,自動決定最寬鬆(效能最高)的捕獲方式。這其實是借用檢查器在背後幫你操心:

  • 不可變借用 (&T):預設模式。只是拿來讀取,不改動。像是閉包隔著櫥窗看了一眼你手上的書,這對環境影響最小。
  • 可變借用 (&mut T):當閉包內部需要修改環境變數時觸發。注意,這會導致該變數在閉包存活期間被鎖定,不能有其他借用。
  • 所有權轉移 (move):直接把環境變數「綁架」過來,強行轉移所有權。
    • ⚡ 為什麼要 move? 最常見的場景是「跨執行緒」。當你把閉包丟進 thread::spawn 時,主執行緒可能在子執行緒跑完前就掛了(變數被銷毀)。透過 move,閉包把資料帶進自己的口袋,確保資料在閉包執行完畢前都是有效的.

Fn 系列 Trait:閉包的證書

閉包也有自己的階級制度,這決定了它們如何處理被捕獲的資料,也決定了它們能被呼叫幾次:

  1. Fn:最高等級. 可以被呼叫多次,且不會消耗或改變捕獲的環境. 它是「純讀取」,像是一張隨便看的照片.
  2. FnMut:次之. 可以呼叫多次,但會修改捕獲的環境. 因為它會改動內部狀態(例如一個 mut counter),所以呼叫者也必須擁有閉包的可變權限.
  3. FnOnce:最後一名. 只能呼叫一次. 通常是因為它在執行時會把捕獲的資料「吃掉」(轉移所有權). 一旦吃掉,就再也沒有資料可以跑第二次了.

💡 小知識: 這三者有繼承關係. 能當 Fn 的一定能當 FnMut,能當 FnMut 的一定能當 FnOnce. Rust 社群的建議是:傳入參數時盡量要求 Fn,真的不行才降級.

迭代器 (Iterator):懶惰就是力量

迭代器是 Rust 的招牌. 別把它想得太玄,它本質上就是一個 「帶有 next() 方法的物件」 .

⚡ 「支票」比喻:為什麼它這麼神?

你可以把迭代器鏈想像成去銀行領錢的過程. 這套機制的核心在於 「懶惰求值 (Lazy Evaluation)」

  1. 開支票 (Creation):例如 iter()into_iter() .
    • 這時資料根本還沒開始跑. 這張支票只是個「承諾」,銀行還沒點錢,你口袋也還是空的. 你甚至可以開出一張「無限」的支票(例如 0.. ),只要你不去兌現,它就不會爆掉.
  2. 換支票 (Adapters)mapfiltertakeskip 等.
    • 重點:這些動作是「懶惰的」. 你加註了再多規則(例如:過濾偶數、乘以二),只要你不去領錢,銀行員(編譯器)連動都不會動一下. 它們只是把新的邏輯封裝進一個新的迭代器物件裡.
  3. 兌現 (Consumers)collectfoldsumcountnth 等.
    • 直到這一步,你把支票拍在櫃檯說:「現在,給我領錢!」,程式才會真正開始跑 next() 迴圈,從頭到尾按照你剛才疊加的所有規則去計算資料.

FP 高階函數深度解析

這才是工程師展示「專業感」的地方. 學會這些組合技,你的程式碼會從三、四十行縮減到三、四行,而且更難寫錯.

1. 轉換與展平:Map, FlatMap 與 FilterMap

  • map(f):一對一. 把每個元素換成另一種樣貌.
  • flat_map(f):一對多或一對零.
    • ⚡ 必學場景:如果轉換函數回傳的是一個 OptionVecflat_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)」

  1. 內聯與優化:Rust 編譯器會把一長串的鏈接全部「展開」,把所有閉包的邏輯融合在一起. 產生的組合語言通常跟頂尖工程師手寫的 for 迴圈完全一樣.
  2. 消除邊界檢查 (Bound Check Elimination):手寫 v[i] 時,Rust 每次都要檢查索引有沒有越界. 但迭代器是透過 next() 移動的,它天生知道資料在哪裡結束,所以編譯器可以安全地跳過這些檢查,這讓它甚至比手寫的 for 迴圈還要快!
  3. 迴圈融合 (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 程式碼不僅會變漂亮,執行速度也會讓其他語言汗顏!