Rust 閉包:可以捕獲環(huán)境的匿名函數(shù)

2023-03-22 15:12 更新
ch13-01-closures.md
commit 8acef6cfd40a36be60a3c62458d9f78e2427e190

Rust 的 閉包closures)是可以保存在一個變量中或作為參數(shù)傳遞給其他函數(shù)的匿名函數(shù)??梢栽谝粋€地方創(chuàng)建閉包,然后在不同的上下文中執(zhí)行閉包運算。不同于函數(shù),閉包允許捕獲被定義時所在作用域中的值。我們將展示閉包的這些功能如何復(fù)用代碼和自定義行為。

使用閉包創(chuàng)建行為的抽象

讓我們來看一個存儲稍后要執(zhí)行的閉包的示例。其間我們會討論閉包的語法、類型推斷和 trait。

考慮一下這個假定的場景:我們在一個通過 app 生成自定義健身計劃的初創(chuàng)企業(yè)工作。其后端使用 Rust 編寫,而生成健身計劃的算法需要考慮很多不同的因素,比如用戶的年齡、身體質(zhì)量指數(shù)(Body Mass Index)、用戶喜好、最近的健身活動和用戶指定的強度系數(shù)。本例中實際的算法并不重要,重要的是這個計算只花費幾秒鐘。我們只希望在需要時調(diào)用算法,并且只希望調(diào)用一次,這樣就不會讓用戶等得太久。

這里將通過調(diào)用 simulated_expensive_calculation 函數(shù)來模擬調(diào)用假定的算法,如示例 13-1 所示,它會打印出 calculating slowly...,等待兩秒,并接著返回傳遞給它的數(shù)字:

文件名: src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

示例 13-1:一個用來代替假定計算的函數(shù),它大約會執(zhí)行兩秒鐘

接下來,main 函數(shù)中將會包含本例的健身 app 中的重要部分。這代表當(dāng)用戶請求健身計劃時 app 會調(diào)用的代碼。因為與 app 前端的交互與閉包的使用并不相關(guān),所以我們將硬編碼代表程序輸入的值并打印輸出。

所需的輸入有這些:

  • 一個來自用戶的 intensity 數(shù)字,請求健身計劃時指定,它代表用戶喜好低強度還是高強度健身。
  • 一個隨機數(shù),其會在健身計劃中生成變化。

程序的輸出將會是建議的鍛煉計劃。示例 13-2 展示了我們將要使用的 main 函數(shù):

文件名: src/main.rs

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

示例 13-2:main 函數(shù)包含了用于 generate_workout 函數(shù)的模擬用戶輸入和模擬隨機數(shù)輸入

出于簡單考慮這里硬編碼了 simulated_user_specified_value 變量的值為 10 和 simulated_random_number 變量的值為 7;一個實際的程序會從 app 前端獲取強度系數(shù)并使用 rand crate 來生成隨機數(shù),正如第二章的猜猜看游戲所做的那樣。main 函數(shù)使用模擬的輸入值調(diào)用 generate_workout 函數(shù):

現(xiàn)在有了執(zhí)行上下文,讓我們編寫算法。示例 13-3 中的 generate_workout 函數(shù)包含本例中我們最關(guān)心的 app 業(yè)務(wù)邏輯。本例中余下的代碼修改都將在這個函數(shù)中進行:

文件名: src/main.rs

fn generate_workout(intensity: u32, random_number: u32) {
    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            simulated_expensive_calculation(intensity)
        );
        println!(
            "Next, do {} situps!",
            simulated_expensive_calculation(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                simulated_expensive_calculation(intensity)
            );
        }
    }
}

示例 13-3:程序的業(yè)務(wù)邏輯,它根據(jù)輸入并調(diào)用 simulated_expensive_calculation 函數(shù)來打印出健身計劃

示例 13-3 中的代碼有多處調(diào)用了慢計算函數(shù) simulated_expensive_calculation 。第一個 if 塊調(diào)用了 simulated_expensive_calculation 兩次, else 中的 if 沒有調(diào)用它,而第二個 else 中的代碼調(diào)用了它一次。

generate_workout 函數(shù)的期望行為是首先檢查用戶需要低強度(由小于 25 的系數(shù)表示)鍛煉還是高強度(25 或以上)鍛煉。

低強度鍛煉計劃會根據(jù)由 simulated_expensive_calculation 函數(shù)所模擬的復(fù)雜算法建議一定數(shù)量的俯臥撐和仰臥起坐。

如果用戶需要高強度鍛煉,這里有一些額外的邏輯:如果 app 生成的隨機數(shù)剛好是 3,app 相反會建議用戶稍做休息并補充水分。如果不是,則用戶會從復(fù)雜算法中得到數(shù)分鐘跑步的高強度鍛煉計劃。

現(xiàn)在這份代碼能夠應(yīng)對我們的需求了,但數(shù)據(jù)科學(xué)部門的同學(xué)告知我們將來會對調(diào)用 simulated_expensive_calculation 的方式做出一些改變。為了在要做這些改動的時候簡化更新步驟,我們將重構(gòu)代碼來讓它只調(diào)用 simulated_expensive_calculation 一次。同時還希望去掉目前多余的連續(xù)兩次函數(shù)調(diào)用,并不希望在計算過程中增加任何其他此函數(shù)的調(diào)用。也就是說,我們不希望在完全無需其結(jié)果的情況調(diào)用函數(shù),不過仍然希望只調(diào)用函數(shù)一次。

使用函數(shù)重構(gòu)

有多種方法可以重構(gòu)此程序。我們首先嘗試的是將重復(fù)的 simulated_expensive_calculation 函數(shù)調(diào)用提取到一個變量中,如示例 13-4 所示:

文件名: src/main.rs

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_result = simulated_expensive_calculation(intensity);

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_result);
        println!("Next, do {} situps!", expensive_result);
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!", expensive_result);
        }
    }
}

示例 13-4:將 simulated_expensive_calculation 調(diào)用提取到一個位置,并將結(jié)果儲存在變量 expensive_result 中

這個修改統(tǒng)一了 simulated_expensive_calculation 調(diào)用并解決了第一個 if 塊中不必要的兩次調(diào)用函數(shù)的問題。不幸的是,現(xiàn)在所有的情況下都需要調(diào)用函數(shù)并等待結(jié)果,包括那個完全不需要這一結(jié)果的內(nèi)部 if 塊。

我們希望在 generate_workout 中只引用 simulated_expensive_calculation 一次,并推遲復(fù)雜計算的執(zhí)行直到我們確實需要結(jié)果的時候。這正是閉包的用武之地!

重構(gòu)使用閉包儲存代碼

不同于總是在 if 塊之前調(diào)用 simulated_expensive_calculation 函數(shù)并儲存其結(jié)果,我們可以定義一個閉包并將其儲存在變量中,如示例 13-5 所示。實際上可以選擇將整個 simulated_expensive_calculation 函數(shù)體移動到這里引入的閉包中:

文件名: src/main.rs

    let expensive_closure = |num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

示例 13-5:定義一個閉包并儲存到變量 expensive_closure 中

閉包定義是 expensive_closure 賦值的 = 之后的部分。閉包的定義以一對豎線(|)開始,在豎線中指定閉包的參數(shù);之所以選擇這個語法是因為它與 Smalltalk 和 Ruby 的閉包定義類似。這個閉包有一個參數(shù) num;如果有多于一個參數(shù),可以使用逗號分隔,比如 |param1, param2|

參數(shù)之后是存放閉包體的大括號 —— 如果閉包體只有一行則大括號是可以省略的。大括號之后閉包的結(jié)尾,需要用于 let 語句的分號。因為閉包體的最后一行沒有分號(正如函數(shù)體一樣),所以閉包體(num)最后一行的返回值作為調(diào)用閉包時的返回值 。

注意這個 let 語句意味著 expensive_closure 包含一個匿名函數(shù)的 定義,不是調(diào)用匿名函數(shù)的 返回值?;貞浺幌率褂瞄]包的原因是我們需要在一個位置定義代碼,儲存代碼,并在之后的位置實際調(diào)用它;期望調(diào)用的代碼現(xiàn)在儲存在 expensive_closure 中。

定義了閉包之后,可以改變 if 塊中的代碼來調(diào)用閉包以執(zhí)行代碼并獲取結(jié)果值。調(diào)用閉包類似于調(diào)用函數(shù);指定存放閉包定義的變量名并后跟包含期望使用的參數(shù)的括號,如示例 13-6 所示:

文件名: src/main.rs

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

示例 13-6:調(diào)用定義的 expensive_closure

現(xiàn)在如何執(zhí)行復(fù)雜計算只被定義了一次,并只會在需要結(jié)果的時候執(zhí)行該代碼。

然而,我們又重新引入了示例 13-3 中的問題:仍然在第一個 if 塊中調(diào)用了閉包兩次,這調(diào)用了慢計算代碼兩次而使得用戶需要多等待一倍的時間??梢酝ㄟ^在 if 塊中創(chuàng)建一個本地變量存放閉包調(diào)用的結(jié)果來解決這個問題,不過閉包可以提供另外一種解決方案。我們稍后會討論這個方案,不過目前讓我們首先討論一下為何閉包定義中和所涉及的 trait 中沒有類型注解。

閉包類型推斷和注解

閉包不要求像 fn 函數(shù)那樣在參數(shù)和返回值上注明類型。函數(shù)中需要類型注解是因為他們是暴露給用戶的顯式接口的一部分。嚴格的定義這些接口對于保證所有人都認同函數(shù)使用和返回值的類型來說是很重要的。但是閉包并不用于這樣暴露在外的接口:他們儲存在變量中并被使用,不用命名他們或暴露給庫的用戶調(diào)用。

閉包通常很短,并只關(guān)聯(lián)于小范圍的上下文而非任意情境。在這些有限制的上下文中,編譯器能可靠的推斷參數(shù)和返回值的類型,類似于它是如何能夠推斷大部分變量的類型一樣。

強制在這些小的匿名函數(shù)中注明類型是很惱人的,并且與編譯器已知的信息存在大量的重復(fù)。

類似于變量,如果相比嚴格的必要性你更希望增加明確性并變得更啰嗦,可以選擇增加類型注解;為示例 13-5 中定義的閉包標注類型將看起來像示例 13-7 中的定義:

文件名: src/main.rs

    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

示例 13-7:為閉包的參數(shù)和返回值增加可選的類型注解

有了類型注解閉包的語法就更類似函數(shù)了。如下是一個對其參數(shù)加一的函數(shù)的定義與擁有相同行為閉包語法的縱向?qū)Ρ?。這里增加了一些空格來對齊相應(yīng)部分。這展示了閉包語法如何類似于函數(shù)語法,除了使用豎線而不是括號以及幾個可選的語法之外:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

第一行展示了一個函數(shù)定義,而第二行展示了一個完整標注的閉包定義。第三行閉包定義中省略了類型注解,而第四行去掉了可選的大括號,因為閉包體只有一行。這些都是有效的閉包定義,并在調(diào)用時產(chǎn)生相同的行為。調(diào)用閉包是 add_one_v3 和 add_one_v4 能夠編譯的必要條件,因為類型將從其用法中推斷出來。

閉包定義會為每個參數(shù)和返回值推斷一個具體類型。例如,示例 13-8 中展示了僅僅將參數(shù)作為返回值的簡短的閉包定義。除了作為示例的目的這個閉包并不是很實用。注意其定義并沒有增加任何類型注解:如果嘗試調(diào)用閉包兩次,第一次使用 String 類型作為參數(shù)而第二次使用 u32,則會得到一個錯誤:

文件名: src/main.rs

    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);

示例 13-8:嘗試調(diào)用一個被推斷為兩個不同類型的閉包

編譯器給出如下錯誤:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |                             ^- help: try using a conversion method: `.to_string()`
  |                             |
  |                             expected struct `String`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` due to previous error

第一次使用 String 值調(diào)用 example_closure 時,編譯器推斷 x 和此閉包返回值的類型為 String。接著這些類型被鎖定進閉包 example_closure 中,如果嘗試對同一閉包使用不同類型則會得到類型錯誤。

使用帶有泛型和 Fn trait 的閉包

回到我們的健身計劃生成 app ,在示例 13-6 中的代碼仍然把慢計算閉包調(diào)用了比所需更多的次數(shù)。解決這個問題的一個方法是在全部代碼中的每一個需要多個慢計算閉包結(jié)果的地方,可以將結(jié)果保存進變量以供復(fù)用,這樣就可以使用變量而不是再次調(diào)用閉包。但是這樣就會有很多重復(fù)的保存結(jié)果變量的地方。

幸運的是,還有另一個可用的方案??梢詣?chuàng)建一個存放閉包和調(diào)用閉包結(jié)果的結(jié)構(gòu)體。該結(jié)構(gòu)體只會在需要結(jié)果時執(zhí)行閉包,并會緩存結(jié)果值,這樣余下的代碼就不必再負責(zé)保存結(jié)果并可以復(fù)用該值。你可能見過這種模式被稱 memoization 或 lazy evaluation (惰性求值)。

為了讓結(jié)構(gòu)體存放閉包,我們需要指定閉包的類型,因為結(jié)構(gòu)體定義需要知道其每一個字段的類型。每一個閉包實例有其自己獨有的匿名類型:也就是說,即便兩個閉包有著相同的簽名,他們的類型仍然可以被認為是不同。為了定義使用閉包的結(jié)構(gòu)體、枚舉或函數(shù)參數(shù),需要像第十章討論的那樣使用泛型和 trait bound。

Fn 系列 trait 由標準庫提供。所有的閉包都實現(xiàn)了 trait Fn、FnMut 或 FnOnce 中的一個。在 “閉包會捕獲其環(huán)境” 部分我們會討論這些 trait 的區(qū)別;在這個例子中可以使用 Fn trait。

為了滿足 Fn trait bound 我們增加了代表閉包所必須的參數(shù)和返回值類型的類型。在這個例子中,閉包有一個 u32 的參數(shù)并返回一個 u32,這樣所指定的 trait bound 就是 Fn(u32) -> u32。

示例 13-9 展示了存放了閉包和一個 Option 結(jié)果值的 Cacher 結(jié)構(gòu)體的定義:

文件名: src/main.rs

struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    calculation: T,
    value: Option<u32>,
}

示例 13-9:定義一個 Cacher 結(jié)構(gòu)體來在 calculation 中存放閉包并在 value 中存放 Option 值

結(jié)構(gòu)體 Cacher 有一個泛型 T 的字段 calculationT 的 trait bound 指定了 T 是一個使用 Fn 的閉包。任何我們希望儲存到 Cacher 實例的 calculation 字段的閉包必須有一個 u32 參數(shù)(由 Fn 之后的括號的內(nèi)容指定)并必須返回一個 u32(由 -> 之后的內(nèi)容)。

注意:函數(shù)也都實現(xiàn)了這三個 ?Fn? trait。如果不需要捕獲環(huán)境中的值,則可以使用實現(xiàn)了 ?Fn? trait 的函數(shù)而不是閉包。

字段 value 是 Option<u32> 類型的。在執(zhí)行閉包之前,value 將是 None。如果使用 Cacher 的代碼請求閉包的結(jié)果,這時會執(zhí)行閉包并將結(jié)果儲存在 value 字段的 Some 成員中。接著如果代碼再次請求閉包的結(jié)果,這時不再執(zhí)行閉包,而是會返回存放在 Some 成員中的結(jié)果。

剛才討論的有關(guān) value 字段邏輯定義于示例 13-10:

文件名: src/main.rs

impl<T> Cacher<T>
where
    T: Fn(u32) -> u32,
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}

示例 13-10:Cacher 的緩存邏輯

Cacher 結(jié)構(gòu)體的字段是私有的,因為我們希望 Cacher 管理這些值而不是任由調(diào)用代碼潛在的直接改變他們。

Cacher::new 函數(shù)獲取一個泛型參數(shù) T,它定義于 impl 塊上下文中并與 Cacher 結(jié)構(gòu)體有著相同的 trait bound。Cacher::new 返回一個在 calculation 字段中存放了指定閉包和在 value 字段中存放了 None 值的 Cacher 實例,因為我們還未執(zhí)行閉包。

當(dāng)調(diào)用代碼需要閉包的執(zhí)行結(jié)果時,不同于直接調(diào)用閉包,它會調(diào)用 value 方法。這個方法會檢查 self.value 是否已經(jīng)有了一個 Some 的結(jié)果值;如果有,它返回 Some 中的值并不會再次執(zhí)行閉包。

如果 self.value 是 None,則會調(diào)用 self.calculation 中儲存的閉包,將結(jié)果保存到 self.value 以便將來使用,并同時返回結(jié)果值。

示例 13-11 展示了如何在示例 13-6 的 generate_workout 函數(shù)中利用 Cacher 結(jié)構(gòu)體:

文件名: src/main.rs

fn generate_workout(intensity: u32, random_number: u32) {
    let mut expensive_result = Cacher::new(|num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    });

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_result.value(intensity));
        println!("Next, do {} situps!", expensive_result.value(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_result.value(intensity)
            );
        }
    }
}

示例 13-11:在 generate_workout 函數(shù)中利用 Cacher 結(jié)構(gòu)體來抽象出緩存邏輯

不同于直接將閉包保存進一個變量,我們保存一個新的 Cacher 實例來存放閉包。接著,在每一個需要結(jié)果的地方,調(diào)用 Cacher 實例的 value 方法??梢哉{(diào)用 value 方法任意多次,或者一次也不調(diào)用,而慢計算最多只會運行一次。

嘗試使用示例 13-2 中的 main 函數(shù)來運行這段程序,并改變 simulated_user_specified_value 和 simulated_random_number 變量中的值來驗證在所有情況下在多個 if 和 else 塊中,閉包打印的 calculating slowly... 只會在需要時出現(xiàn)并只會出現(xiàn)一次。Cacher 負責(zé)確保不會調(diào)用超過所需的慢計算所需的邏輯,這樣 generate_workout 就可以專注業(yè)務(wù)邏輯了。

Cacher 實現(xiàn)的限制

值緩存是一種更加廣泛的實用行為,我們可能希望在代碼中的其他閉包中也使用他們。然而,目前 Cacher 的實現(xiàn)存在兩個小問題,這使得在不同上下文中復(fù)用變得很困難。

第一個問題是 Cacher 實例假設(shè)對于 value 方法的任何 arg 參數(shù)值總是會返回相同的值。也就是說,這個 Cacher 的測試會失?。?br>

    #[test]
    fn call_with_different_values() {
        let mut c = Cacher::new(|a| a);

        let v1 = c.value(1);
        let v2 = c.value(2);

        assert_eq!(v2, 2);
    }

這個測試使用返回傳遞給它的值的閉包創(chuàng)建了一個新的 Cacher 實例。使用為 1 的 arg 和為 2 的 arg 調(diào)用 Cacher 實例的 value 方法,同時我們期望使用為 2 的 arg 調(diào)用 value 會返回 2。

使用示例 13-9 和示例 13-10 的 Cacher 實現(xiàn)運行測試,它會在 assert_eq! 失敗并顯示如下信息:

$ cargo test
   Compiling cacher v0.1.0 (file:///projects/cacher)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests (target/debug/deps/cacher-074d7c200c000afa)

running 1 test
test tests::call_with_different_values ... FAILED

failures:

---- tests::call_with_different_values stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/lib.rs:43:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::call_with_different_values

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

這里的問題是第一次使用 1 調(diào)用 c.valueCacher 實例將 Some(1) 保存進 self.value。在這之后,無論傳遞什么值調(diào)用 value,它總是會返回 1。

嘗試修改 Cacher 存放一個哈希 map 而不是單獨一個值。哈希 map 的 key 將是傳遞進來的 arg 值,而 value 則是對應(yīng) key 調(diào)用閉包的結(jié)果值。相比之前檢查 self.value 直接是 Some 還是 None 值,現(xiàn)在 value 函數(shù)會在哈希 map 中尋找 arg,如果找到的話就返回其對應(yīng)的值。如果不存在,Cacher 會調(diào)用閉包并將結(jié)果值保存在哈希 map 對應(yīng) arg 值的位置。

當(dāng)前 Cacher 實現(xiàn)的第二個問題是它的應(yīng)用被限制為只接受獲取一個 u32 值并返回一個 u32 值的閉包。比如說,我們可能需要能夠緩存一個獲取字符串 slice 并返回 usize 值的閉包的結(jié)果。請嘗試引入更多泛型參數(shù)來增加 Cacher 功能的靈活性。

閉包會捕獲其環(huán)境

在健身計劃生成器的例子中,我們只將閉包作為內(nèi)聯(lián)匿名函數(shù)來使用。不過閉包還有另一個函數(shù)所沒有的功能:他們可以捕獲其環(huán)境并訪問其被定義的作用域的變量。

示例 13-12 有一個儲存在 equal_to_x 變量中閉包的例子,它使用了閉包環(huán)境中的變量 x

文件名: src/main.rs

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

示例 13-12:一個引用了其周圍作用域中變量的閉包示例

這里,即便 x 并不是 equal_to_x 的一個參數(shù),equal_to_x 閉包也被允許使用變量 x,因為它與 equal_to_x 定義于相同的作用域。

函數(shù)則不能做到同樣的事,如果嘗試如下例子,它并不能編譯:

文件名: src/main.rs

fn main() {
    let x = 4;

    fn equal_to_x(z: i32) -> bool {
        z == x
    }

    let y = 4;

    assert!(equal_to_x(y));
}

這會得到一個錯誤:

$ cargo run
   Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0434]: can't capture dynamic environment in a fn item
 --> src/main.rs:5:14
  |
5 |         z == x
  |              ^
  |
  = help: use the `|| { ... }` closure form instead

For more information about this error, try `rustc --explain E0434`.
error: could not compile `equal-to-x` due to previous error

編譯器甚至?xí)崾疚覀冞@只能用于閉包!

當(dāng)閉包從環(huán)境中捕獲一個值,閉包會在閉包體中儲存這個值以供使用。這會使用內(nèi)存并產(chǎn)生額外的開銷,在更一般的場景中,當(dāng)我們不需要閉包來捕獲環(huán)境時,我們不希望產(chǎn)生這些開銷。因為函數(shù)從未允許捕獲環(huán)境,定義和使用函數(shù)也就從不會有這些額外開銷。

閉包可以通過三種方式捕獲其環(huán)境,他們直接對應(yīng)函數(shù)的三種獲取參數(shù)的方式:獲取所有權(quán),可變借用和不可變借用。這三種捕獲值的方式被編碼為如下三個 Fn trait:

  • ?FnOnce? 消費從周圍作用域捕獲的變量,閉包周圍的作用域被稱為其 環(huán)境environment。為了消費捕獲到的變量,閉包必須獲取其所有權(quán)并在定義閉包時將其移動進閉包。其名稱的 ?Once? 部分代表了閉包不能多次獲取相同變量的所有權(quán)的事實,所以它只能被調(diào)用一次。
  • ?FnMut? 獲取可變的借用值所以可以改變其環(huán)境
  • ?Fn? 從其環(huán)境獲取不可變的借用值

當(dāng)創(chuàng)建一個閉包時,Rust 根據(jù)其如何使用環(huán)境中變量來推斷我們希望如何引用環(huán)境。由于所有閉包都可以被調(diào)用至少一次,所以所有閉包都實現(xiàn)了 FnOnce 。那些并沒有移動被捕獲變量的所有權(quán)到閉包內(nèi)的閉包也實現(xiàn)了 FnMut ,而不需要對被捕獲的變量進行可變訪問的閉包則也實現(xiàn)了 Fn 。 在示例 13-12 中,equal_to_x 閉包不可變的借用了 x(所以 equal_to_x 具有 Fn trait),因為閉包體只需要讀取 x 的值。

如果你希望強制閉包獲取其使用的環(huán)境值的所有權(quán),可以在參數(shù)列表前使用 move 關(guān)鍵字。這個技巧在將閉包傳遞給新線程以便將數(shù)據(jù)移動到新線程中時最為實用。

注意:即使其捕獲的值已經(jīng)被移動了,?move? 閉包仍需要實現(xiàn) ?Fn? 或 ?FnMut?。這是因為閉包所實現(xiàn)的 trait 是由閉包所捕獲了什么值而不是如何捕獲所決定的。而 ?move? 關(guān)鍵字僅代表了后者。

第十六章討論并發(fā)時會展示更多 move 閉包的例子,不過現(xiàn)在這里修改了示例 13-12 中的代碼(作為演示),在閉包定義中增加 move 關(guān)鍵字并使用 vector 代替整型,因為整型可以被拷貝而不是移動;注意這些代碼還不能編譯:

文件名: src/main.rs

fn main() {
    let x = vec![1, 2, 3];

    let equal_to_x = move |z| z == x;

    println!("can't use x here: {:?}", x);

    let y = vec![1, 2, 3];

    assert!(equal_to_x(y));
}

這個例子并不能編譯,會產(chǎn)生以下錯誤:

$ cargo run
   Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0382]: borrow of moved value: `x`
 --> src/main.rs:6:40
  |
2 |     let x = vec![1, 2, 3];
  |         - move occurs because `x` has type `Vec<i32>`, which does not implement the `Copy` trait
3 | 
4 |     let equal_to_x = move |z| z == x;
  |                      --------      - variable moved due to use in closure
  |                      |
  |                      value moved into closure here
5 | 
6 |     println!("can't use x here: {:?}", x);
  |                                        ^ value borrowed here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `equal-to-x` due to previous error

x 被移動進了閉包,因為閉包使用 move 關(guān)鍵字定義。接著閉包獲取了 x 的所有權(quán),同時 main 就不再允許在 println! 語句中使用 x 了。去掉 println! 即可修復(fù)問題。

大部分需要指定一個 Fn 系列 trait bound 的時候,可以從 Fn 開始,而編譯器會根據(jù)閉包體中的情況告訴你是否需要 FnMut 或 FnOnce。

為了展示閉包作為函數(shù)參數(shù)時捕獲其環(huán)境的作用,讓我們繼續(xù)下一個主題:迭代器。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號