詳解閉包: Rust 中的函數式編程

本節參考:

Rust 中函數式編程的大梁由四大天王頂起: - 模式匹配 - 枚舉 - 迭代器 - 閉包

使用 閉包(Closure) 可以做到將一系列語句和表達式賦值給變量,因此也可以將語句和表達式作爲參數傳遞,將語句和表達式作爲函數返回值返回,它具有如此一系列神奇的特性。閉包的使用很簡單,但其中一些細節需要仔細推敲。 下面,我們從閉包如何捕獲環境,閉包如何使用捕獲值,以及閉包實現的角度,來介紹這個編程利器。

開始——捕獲環境

Rust 中的函數是無法捕獲其所在環境的。對於以下代碼:

fn main() {
    let num = 0;
    println!("{num}");
    fn func() {
        println!("{num}");
    }
}

即使該函數定義在 main() 內部,它仍然無法訪問到自己被定義的環境中定義的變量。若要訪問這些變量,只能通過傳遞函數參數的方式。需要注意,static 和 const 這樣的量具有靜態生命週期,是可以訪問的。 而使用閉包,便可以起到捕獲環境的作用。 那麼如何定義一個閉包呢?Rust 中通過閉包表達式定義一個閉包類型,在其他語言中也稱爲 lambda 表達式。

閉包表達式的句法規則是:可選的 move ,後跟由 || 圍住的參數模式列表(可以省略類型標註),後跟可選的返回值標註 -> type ,後跟一個塊表達式(無返回值標註時,若塊內只有一個表達式則可以直接寫在 || 後)。例如:

fn main() {  
    // 函數
    fn func(a: i32, b: i32) -> i32 {  
        a + b  
    }  

    // 閉包定義1
    let func = |a: i32, b: i32| -> i32 {  
        a + b  
    };
    // 閉包定義2
    let func = |a, b| {  
        a + b  
    };  
    // 閉包定義3
    let func = |a, b| a + b;  
    // 閉包定義4
    let func = move |a, b| a + b;  

    // 相同的調用方式 
    let res = func(1, 2);  
    assert_eq!(res, 3);  
}

閉包捕獲環境的方式

閉包是可以捕獲環境的,捕獲的方式有這幾種(不捕獲環境的閉包見後文): - 不可變引用 &T - 可變引用 &mut T - 移動語義(獲取所有權) T 當在 || 前使用 move 時,將強制閉包以移動語義(move)捕獲值,獲取值的所有權。對於實現了 Copy Trait 的類型,則使用 Copy 複製語義。當沒有使用 move 時,編譯器會按照如下順序進行檢查,選擇捕獲方式,直到遇到第一個能通過編譯的選項: 1. 不可變引用 2. 唯一不可變引用 3. 可變引用 4. 移動語義 此處,唯一不可變引用 是基於借用規則而出現的一種特殊的捕獲方式。對於下述代碼:

let mut a = 0;  
let b = &mut a;  
{  
    let mut x = || { *b = 1; };  
    // 下行代碼不正確  
    // let y = &b;  
    x();  
    println!("{y}");   // 由於NLL,這裏需要使用 y
}  
let z = &b;

代碼中 b 是對 a 的可變借用,因此可以通過解引用 b 來修改 a 的值。但在這裏我們將修改的操作放在一個閉包中,其中使用了 b,因此閉包需要捕獲它。由於 b 本身不是 mut 的,因此無法以可變引用的形式捕獲。但若以不可變引用的形式捕獲,那麼就會獲得對可變引用的引用 & &mut,它將不是唯一的,這違反了借用規則。 這時,閉包便使用唯一不可變引用的方式來捕獲變量,即它會對 b 進行不可變引用,同時會確保對 b 的引用只有一個。

3 種閉包 Trait

這裏需要做一區別,閉包如何捕獲環境,和閉包如何使用捕獲到的值,兩者是不同的。

Rust 編譯器會根據閉包 如何使用 捕獲到的值,來決定爲閉包實現哪些閉包 Trait。 或者說,編譯器通過這 3 種 Trait 來描述和分類不同的閉包: - FnOnce :閉包可能會消耗掉捕獲值的所有權,表示閉包至少能使用一次,因此所有的閉包均實現了該 Trait。 - FnMut :閉包不會消耗掉捕獲值的所有權,同時會對捕獲值進行修改。 - Fn :閉包不會消耗掉捕獲值的所有權,同時不會對捕獲值進行修改。 所有閉包都 至少 實現了 FnOnce。

所有類型的閉包中,有些閉包可能會消耗掉捕獲值的所有權,這種閉包在調用一次後無法再次調用(要處理的值已經不見了),因此對於所有的閉包來說,閉包最少是可以使用一次的,使用 FnOnce 描述。如果閉包並不消耗掉捕獲值的所有權,便可以多次被調用,它對捕獲值的操作,只可能是修改或者不修改,前者使用 FnMut 描述,後者使用 Fn 描述。

因此可以說, 3 種閉包 Trait,是在閉包如何使用捕獲值的角度上,對閉包的分類。

現在觀察這 3 種 Trait 的定義簽名(簡化):

pub trait Fn<Args> : FnMut<Args> {
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
pub trait FnMut<Args> : FnOnce<Args> {
    extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}
pub trait FnOnce<Args> {
    type Output;
    extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

可以看到,實現 FnMut 的條件是,已經實現了 FnOnce,而實現 Fn 的條件是已經實現了 FnMut,因此,閉包對這 3 種 Trait 的實現有這三種情況: 1. 只實現了 FnOnce 2. 實現了 FnOnce 和 FnMut 3. 實現了 FnOnce ,FnMut 和 Fn 分別對應上述三種 Trait 的情況。

函數式編程:作爲參數和返回值

由於 Rust 中的閉包實現了上文介紹的幾種閉包特徵,因此可以使用特徵約束的方法讓閉包作爲函數參數或返回值來使用,例如:

// 接收一個 FnOnce() 類型的閉包並調用
fn function<F> (f: F) 
where F: FnOnce() {
    f();
}

// 返回一個 FnOnce() -> &'static str 類型的閉包
fn some_func() -> impl FnOnce() -> &'static str {  
    || { "666" }  
}
// 返回一個特徵對象,不常用
fn dyn_func() -> Box<dyn FnOnce() -> &'static str> {
    Box::new(|| { "999" })  
}

對於函數而言,只要符合特徵約束,也可以作爲其他函數的參數:

// 將要接收函數和閉包作爲參數的函數
fn call_me<F: Fn()>(f: F) {
    f()
}
// 一個函數
fn function() {
    println!("I'm a function!");
}
fn main() {
    // 一個閉包
    let closure = || println!("I'm a closure!");

    call_me(closure);
    call_me(function);
}

閉包的實際類型

當使用閉包表達式定義一個閉包時,編譯器會隱式生成一個匿名結構體,結構體中的字段會存儲閉包捕獲的變量。同時,會爲該結構體實現閉包特徵,並由此實現閉包的函數功能。 例如,對於以下閉包:

fn closure<F> (f: F)  
where F: FnOnce() -> &'static str  
{  
    println!("closure: {}", f());  
}  

fn main() {  
    let s = || { "Hello" };  
    closure(s);  
}

編譯器會大致生成如下的代碼:

struct ClosureSome {
    a: &'static str,
}

impl FnOnce() for ClosureSome {
    type Output = &'static str;
    fn call_once(self) -> &'static str {
        "Hello"
    }
}

因此每個閉包都具有自己獨特的類型,且無法被寫出。 由此可以看出,當傳遞一個閉包時,傳遞的實際上是一個結構體,而調用一個閉包時,則是調用相應 Trait 定義的方法。

上文中介紹了編譯器根據閉包如何使用捕獲到的值而實現不同的閉包特徵,而對於 閉包沒有捕獲值 的情況,該閉包可以被 自動強轉 爲函數指針:

fn main() {
    let add = |x, y| x + y;
    let mut x = add(5,7);

    type Binop = fn(i32, i32) -> i32;
    let bo: Binop = add;

    x = bo(5,7);
}

總結

Rust 中的閉包可以實現一些函數式編程的功能,它與函數類似,但也不同,主要便在於閉包可以捕獲環境。

閉包 捕獲環境 的方式分爲三種,即 &T &mut T 和 T,當閉包不捕獲環境時,可以被自動強轉爲函數指針。 閉包 使用捕獲值 的方式也分爲三種,即消耗所有權,不消耗所有權並進行修改,不消耗所有權且不修改。與此對應的,有三種閉包特徵,即 FnOnce, FnMut 和 Fn,實現了後一個特徵則肯定實現了前一個特徵,如一個閉包實現了 Fn,它肯定實現了 FnMut 和 FnOnce。

通過使用特徵約束,利用 3 種 Trait,可以將閉包作爲參數傳遞,或作爲返回值返回。 最後,閉包實現這樣一系列功能,它的真實類型便是一個編譯器自動生成的匿名結構體,結構體的字段存儲着閉包捕獲的環境,編譯器爲它實現相應的 Trait,並將閉包包含的語句和表達式作爲具體的實現。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eE0UctPUOjS3tSp6grureg