1. 程式人生 > 實用技巧 >Rust 中的 Closure

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 難用的根源:

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

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的話會變成什麼樣呢?留作習題。