Rust 中的 Closure
原理
有些語言中沒有 closure 和普通函式的區分,但 Rust 有。對 Rust 來說普通函式就是一段程式碼。而 closure 和 C++ 類似:每個 closure 會建立一個匿名的struct
,編譯器會在當前上下文捕獲 closure 程式碼中的外部變數然後塞進這個結構體裡面。
這件事非常重要,請默唸三遍一個 closure 就是一個捕獲了當前上下文變數的結構體(外加一段程式碼,這不重要)。
這解釋了為什麼 Rust 中兩個引數和返回值一樣的 closure 不被視作同一型別[1],因為它們背後的匿名結構體不同,有著不同的大小、欄位和 lifetime。
let m = 1.0;
let c = 2.0;
let line = |x| m*x + c;
// 等價於
struct SomeUnknownType<'a> {
m: &'a f64,
c: &'a f64
}
impl<'a> SomeUnknownType<'a> {
fn call(&self, x: f64) -> f64 {
self.m * x + self.c
}
}
例子來源於Why Rust Closures are (Somewhat) Hard。
這也是 closure 難用的根源:
- Rust 中結構體的可變性以及 liftime 本身就很煩人。
- Closure 的規則都是隱式的:closure 捕獲值的方式及所生成的closure的型別都是按照隱式的規則決定的。
- Closure 一直會捕獲整個複合型別,如
struct
,tuple
和enum
。而不只是單個欄位[2]。
對於 (3),Rust 團隊已經接受了一個提案,旨在改進不相交欄位的捕獲規則。(當前看起來沒多少進展)
為什麼
對於 (1) 和 (2) 是語言設計思路所帶來的結果,為什麼會這樣呢?
因為 closure 很好用,但是我們不想付出執行時代價。所有語言都有類似的東西,但是它們把 closure 捕獲的結構丟到堆上以保證所有 closure 型別大小一樣,且藉助了 GC 管理資源。
Rust選擇「零額外開銷」(Zero Overhead)所以必須用這種方式來實現 closure。使用高階抽象的同時保持了效能無損。比如說我們能用很函式式的方法處理迭代器,但最後生成的彙編和手寫迴圈沒什麼區別。
並且Rust提供了Box<Fn() -> T>
和Rc
讓你可以手動做到別的語言自動做到的事情。你需要顯式使用這些設施,因為這代表額外的開銷。
而選擇隱式的捕獲規則是因為closure被設計為在某個特定上下文內以短小、簡潔而頻繁的方式書寫
規則
捕獲規則最簡單的情形是move || {...}
它會嘗試獲取closure中用到的值的ownership,如果值是Copy
的則 copy 一個。
而預設的捕獲方式是:
- 如果可以,則儘量用
&
借用 - 否則,如果可以,則總是
&mut
借用 - 最後,無計可施必須要 ownership 的話,才會 move
捕獲之後,根據你在 closure 程式碼中如何使用捕獲到的值,編譯器會為 closure 實現函式 traits。最後實現了哪些 traits 和捕獲的方式(有沒有加move
)或者捕獲到了哪些變數是無關的。
- 所有函式都至少能呼叫一次,所以都會實現
FnOnce
。 - 另外,對於那些不會移走匿名結構體中變數的 closure 實現
FnMut
。 - 並且,對於那些不會修改匿名結構體中變數的 closure 實現
Fn
。
FnOnce
,FnMut
和Fn
,下圖中可以看出這三者是包含的關係。
其中FnMut
和Fn
能呼叫多次。FnMut
呼叫時需要對自己匿名結構體的&mut self
引用。呼叫Fn
只需要&self
引用就足夠了。
以下內容可以跳過。
即使是面臨必須要 ownership 的情況,如果值可以Copy
,編譯器依然會避免 move,而是用&
的方式借用值,之後在需要的時候*
。相關文章是《Rust 閉包環境捕獲行為與 Copy trait》。
我們都認為是 bug,直到語言團隊成員回覆說這是預料中的行為。之後我注意到這是規則1較為反直覺的特例。
實踐
現在來寫下不同型別的 closure。然後去看編譯器產出的 MIR。
MIR 是中級中間表示(簡稱中二表示)詳細可以看官方部落格的這篇文章。我們關注的只是少部分內容,大部分看不懂也沒關係。
總而言之,MIR 告訴我們「程式碼究竟會變成什麼樣」但又保留了型別資訊,不像彙編那樣面目全非。
FnOnce
Closure 中必須移走某個變數的 ownership,這種 closure 需要self
來執行,所以只能FnOnce
。
Playground(點右上角 “RUN” 按鈕旁的「…」按鈕,再點 “MIR” 看結果。)
fn main() {
let homu = Homura;
let get_homu = || homu;
get_homu();
}
呼叫時的 MIR
let mut _4: [closure@src/main.rs:9:20: 9:27 homu:Homura];
let mut _5: ();
_3 = const std::ops::FnOnce::call_once(move _4, move _5) -> bb1;
可以看到它是以FnOnce
方式呼叫的。
_4
作為第一個引數傳進去,它的型別[closure@src/main.rs:10:20: 10:27 homu:Homura]
就是本文一直在叨唸的匿名結構體了。其中home:Homura
則是這個結構體捕獲的變數和她的型別。
_5: ()
代表著無引數。
Closure 程式碼所編譯成的普通函式:
fn main::(_1: [closure@src/main.rs:9:20: 9:27 homu:Homura]) -> Homura {
let mut _0: Homura; // return place
bb0: {
_0 = move (_1.0: Homura); // bb0[0]: scope 0 at src/main.rs:9:23: 9:27
return; // bb0[1]: scope 0 at src/main.rs:9:27: 9:27
}
}
注意這裡_1
的型別:[closure@src/main.rs:9:20: 9:27 homu:Homura]
前沒有&
或者&mut
,代表這個呼叫後會消耗掉匿名結構體。
_0 = move (_1.0: Homura);
可以看見內部移走了homu
。
FnMut
在 closure 中修改某個可變的引用[4],但無需移走任何捕獲到的值。這種 closure 必須請求一個&mut
,所以有FnMut
。
呼叫時:
let mut _6: &mut [closure@src/main.rs:9:25: 9:41 madoka:&mut std::option::Option<Madoka>];
let mut _7: ();
_5 = const std::ops::FnMut::call_mut(move _6, move _7) -> bb1;
Closure 所生成的函式體:
fn main::(_1: &mut [closure@src/main.rs:9:25: 9:41 madoka:&mut std::option::Option<Madoka>]) -> () {
// ...
}
可以看到_1
變成一個&mut
引用了。能多次呼叫而不會消耗匿名結構體。
被捕獲的值變成了madoka:&mut std::option::Option<Madoka>
。於是在這個 closure 銷燬之前別人都不能訪問madoka
了。
Fn
在 closure 中只會讀取外部的值,只需要&self
就能執行,當然全部三種都實現了。
fn main() {
let homu = Homura;
let mado = Madoka;
let marry = || (&homu, &mado);
marry();
}
呼叫時:
let mut _7: &[closure@src/main.rs:10:17: 10:34 homu:&Homura, mado:&Madoka];
let mut _8: ();
_6 = const std::ops::Fn::call(move _7, move _8) -> bb1;
是用Fn
的方式呼叫的。
Closure 生成的函式體:
fn main::(_1: &[closure@src/main.rs:10:17: 10:34 homu:&Homura, mado:&Madoka]) -> (&Homura, &Madoka) {
// ...
}
如果 closure 根本不捕獲任何東西,則匿名結構體是Zero Sized Types,在執行時不會被建立。這類 closure 等價於普通函式,自然也實現了全部三種。程式碼略。
實現哪些 traits 和捕獲到的值無關
就算用move
強制捕獲變數的所有權,只要不移走它而僅僅是修改或讀取它。這種情況依然會實現FnMut
或Fn
。Playground
fn main() {
let homu = Homura;
let mado = Madoka;
let marry = move || {
(&homu, &mado);
};
marry();
}
這種程式碼,用了move
所以會捕獲homu
和mado
的所有權,但是MIR可以看到是通過Fn::call
呼叫的:
let mut _5: &[closure@src/main.rs:10:17: 12:6 homu:Homura, mado:Madoka];
let mut _6: ();
_4 = const std::ops::Fn::call(move _5, move _6) -> bb1;
看看closure所生成的函式體吧:
fn main::(_1: &[closure@src/main.rs:10:17: 12:6 homu:Homura, mado:Madoka]) -> () {
let mut _0: (); // return place
let mut _2: (&Homura, &Madoka);
let mut _3: &Homura;
let mut _4: &Madoka;
bb0: {
// ...
_3 = &((*_1).0: Homura);
_4 = &((*_1).1: Madoka);
(_2.0: &Homura) = move _3;
(_2.1: &Madoka) = move _4;
// ...
return;
}
}
不同於前一個沒有加move
的例子。homu:Homura
和mado:Madoka
前沒有&
,代表匿名結構體捕獲了這兩個變數的所有權。
然而捕獲了那些變數的匿名結構體本身又是以_1: &[closure...]
的方式傳入的。因為函式體內根本不會移走homu
或者mado
。
如果修改這份程式碼在 closure 過程內修改mado
的話會變成什麼樣呢?留作習題。