原理

有些語言中沒有 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 難用的根源:

  1. Rust 中結構體的可變性以及 liftime 本身就很煩人。
  2. Closure 的規則都是隱式的:closure 捕獲值的方式及所生成的closure的類型都是按照隱式的規則決定的。
  3. Closure 一直會捕獲整個複合類型,如 struct, tupleenum 。而不只是單個欄位[2]

對於 (3),Rust 團隊已經接受了一個提案,旨在改進不相交欄位的捕獲規則。(當前看起來沒多少進展)

為什麼

對於 (1) 和 (2) 是語言設計思路所帶來的結果,為什麼會這樣呢?

因為 closure 很好用,但是我們不想付出運行時代價。所有語言都有類似的東西,但是它們把 closure 捕獲的結構丟到堆上以保證所有 closure 類型大小一樣,且藉助了 GC 管理資源。

Rust選擇「零額外開銷」(Zero Overhead)所以必須用這種方式來實現 closure。使用高級抽象的同時保持了性能無損。比如說我們能用很函數式的方法處理迭代器,但最後生成的彙編和手寫循環沒什麼區別。

並且Rust提供了Box<Fn() -> T>Rc讓你可以手動做到別的語言自動做到的事情。你需要顯式使用這些設施,因為這代表額外的開銷。

而選擇隱式的捕獲規則是因為closure被設計為在某個特定上下文內以短小、簡潔而頻繁的方式書寫[3]。因此採用了這種隱式且最保守的捕獲方式。代價就是容易讓人摸不著頭腦。雖說利大於弊,但的確是一個缺點(參見下一節的引用部分)。

規則

捕獲規則最簡單的情形是 move || {...} 它會嘗試獲取closure中用到的值的ownership,如果值是 Copy 的則 copy 一個。

而默認的捕獲方式是:

  1. 如果可以,則盡量用 & 借用
  2. 否則,如果可以,則總是 &mut 借用
  3. 最後,無計可施必須要 ownership 的話,才會 move

捕獲之後,根據你在 closure 代碼中如何使用捕獲到的值,編譯器會為 closure 實現函數 traits。最後實現了哪些 traits 和捕獲的方式(有沒有加move)或者捕獲到了哪些變數是無關的。

  • 所有函數都至少能調用一次,所以都會實現FnOnce
    • 另外,對於那些不會移走匿名結構體中變數的 closure 實現 FnMut
      • 並且,對於那些不會修改匿名結構體中變數的 closure 實現 Fn

FnOnce, FnMutFn,下圖中可以看出這三者是包含的關係。

(Google Docs)

其中FnMutFn能調用多次。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

Playground

調用時:

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();
}

Playground

調用時:

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 強制捕獲變數的所有權,只要不移走它而僅僅是修改或讀取它。這種情況依然會實現 FnMutFn。Playground

fn main() {
let homu = Homura;
let mado = Madoka;
let marry = move || {
(&homu, &mado);
};
marry();
}

這種代碼,用了 move 所以會捕獲 homumado 的所有權,但是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:Homuramado:Madoka沒有 &,代表匿名結構體捕獲了這兩個變數的所有權。

然而捕獲了那些變數的匿名結構體本身又是以 _1: &[closure...] 的方式傳入的。因為函數體內根本不會移走 homu 或者 mado

如果修改這份代碼在 closure 過程內修改 mado 的話會變成什麼樣呢?留作習題。


感謝 Telegram「Rust 眾」群網友們對本文的幫助。

相關閱讀

  1. Why Rust Closures are (Somewhat) Hard
  2. Closure types
  3. std::ops::{Fn, FnMut, FnOnce}
  4. 閉包 - Rust編程
  5. Higher-Rank Trait Bounds (HRTBs)

參考

  1. ^其實所有的普通函數也都是唯一的類型。被視作 Zero Sized Types。 https://doc.rust-lang.org/nomicon/exotic-sizes.html#zero-sized-types-zsts
  2. ^Reference - Closure Types https://doc.rust-lang.org/reference/types/closure.html
  3. ^比如說參數和返回值的類型都可以省略
  4. ^有一種符合直覺的例外在這裡 https://doc.rust-lang.org/reference/types/closure.html#unique-immutable-borrows-in-captures

推薦閱讀:

相关文章