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

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

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

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

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

考慮一下這個(gè)假定的場景:我們在一個(gè)通過 app 生成自定義健身計(jì)劃的初創(chuàng)企業(yè)工作。其后端使用 Rust 編寫,而生成健身計(jì)劃的算法需要考慮很多不同的因素,比如用戶的年齡、身體質(zhì)量指數(shù)(Body Mass Index)、用戶喜好、最近的健身活動和用戶指定的強(qiáng)度系數(shù)。本例中實(shí)際的算法并不重要,重要的是這個(gè)計(jì)算只花費(fèi)幾秒鐘。我們只希望在需要時(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:一個(gè)用來代替假定計(jì)算的函數(shù),它大約會執(zhí)行兩秒鐘

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

所需的輸入有這些:

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

程序的輸出將會是建議的鍛煉計(jì)劃。示例 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ù)的模擬用戶輸入和模擬隨機(jī)數(shù)輸入

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

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

文件名: 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ù)來打印出健身計(jì)劃

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

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

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

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

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

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

有多種方法可以重構(gòu)此程序。我們首先嘗試的是將重復(fù)的 simulated_expensive_calculation 函數(shù)調(diào)用提取到一個(gè)變量中,如示例 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)用提取到一個(gè)位置,并將結(jié)果儲存在變量 expensive_result 中

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

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

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

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

文件名: src/main.rs

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

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

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

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

注意這個(gè) let 語句意味著 expensive_closure 包含一個(gè)匿名函數(shù)的 定義,不是調(diào)用匿名函數(shù)的 返回值?;貞浺幌率褂瞄]包的原因是我們需要在一個(gè)位置定義代碼,儲存代碼,并在之后的位置實(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ù)雜計(jì)算只被定義了一次,并只會在需要結(jié)果的時(shí)候執(zhí)行該代碼。

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

閉包類型推斷和注解

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

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

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

類似于變量,如果相比嚴(yán)格的必要性你更希望增加明確性并變得更啰嗦,可以選擇增加類型注解;為示例 13-5 中定義的閉包標(biāo)注類型將看起來像示例 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ù)了。如下是一個(gè)對其參數(shù)加一的函數(shù)的定義與擁有相同行為閉包語法的縱向?qū)Ρ?。這里增加了一些空格來對齊相應(yīng)部分。這展示了閉包語法如何類似于函數(shù)語法,除了使用豎線而不是括號以及幾個(gè)可選的語法之外:

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  ;

第一行展示了一個(gè)函數(shù)定義,而第二行展示了一個(gè)完整標(biāo)注的閉包定義。第三行閉包定義中省略了類型注解,而第四行去掉了可選的大括號,因?yàn)殚]包體只有一行。這些都是有效的閉包定義,并在調(diào)用時(shí)產(chǎn)生相同的行為。調(diào)用閉包是 add_one_v3 和 add_one_v4 能夠編譯的必要條件,因?yàn)轭愋蛯钠溆梅ㄖ型茢喑鰜怼?br>

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

文件名: src/main.rs

    let example_closure = |x| x;

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

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

編譯器給出如下錯誤:

$ 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 時(shí),編譯器推斷 x 和此閉包返回值的類型為 String。接著這些類型被鎖定進(jìn)閉包 example_closure 中,如果嘗試對同一閉包使用不同類型則會得到類型錯誤。

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

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

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

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

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

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

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

文件名: src/main.rs

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

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

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

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

字段 value 是 Option<u32> 類型的。在執(zhí)行閉包之前,value 將是 None。如果使用 Cacher 的代碼請求閉包的結(jié)果,這時(shí)會執(zhí)行閉包并將結(jié)果儲存在 value 字段的 Some 成員中。接著如果代碼再次請求閉包的結(jié)果,這時(shí)不再執(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)體的字段是私有的,因?yàn)槲覀兿M?nbsp;Cacher 管理這些值而不是任由調(diào)用代碼潛在的直接改變他們。

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

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

如果 self.value 是 None,則會調(diào)用 self.calculation 中儲存的閉包,將結(jié)果保存到 self.value 以便將來使用,并同時(shí)返回結(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)體來抽象出緩存邏輯

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

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

Cacher 實(shí)現(xiàn)的限制

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

第一個(gè)問題是 Cacher 實(shí)例假設(shè)對于 value 方法的任何 arg 參數(shù)值總是會返回相同的值。也就是說,這個(gè) 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);
    }

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

使用示例 13-9 和示例 13-10 的 Cacher 實(shí)現(xiàn)運(yù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.value,Cacher 實(shí)例將 Some(1) 保存進(jìn) self.value。在這之后,無論傳遞什么值調(diào)用 value,它總是會返回 1。

嘗試修改 Cacher 存放一個(gè)哈希 map 而不是單獨(dú)一個(gè)值。哈希 map 的 key 將是傳遞進(jìn)來的 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 實(shí)現(xiàn)的第二個(gè)問題是它的應(yīng)用被限制為只接受獲取一個(gè) u32 值并返回一個(gè) u32 值的閉包。比如說,我們可能需要能夠緩存一個(gè)獲取字符串 slice 并返回 usize 值的閉包的結(jié)果。請嘗試引入更多泛型參數(shù)來增加 Cacher 功能的靈活性。

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

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

示例 13-12 有一個(gè)儲存在 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:一個(gè)引用了其周圍作用域中變量的閉包示例

這里,即便 x 并不是 equal_to_x 的一個(gè)參數(shù),equal_to_x 閉包也被允許使用變量 x,因?yàn)樗c 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));
}

這會得到一個(gè)錯誤:

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

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

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

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

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

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

第十六章討論并發(fā)時(shí)會展示更多 move 閉包的例子,不過現(xiàn)在這里修改了示例 13-12 中的代碼(作為演示),在閉包定義中增加 move 關(guān)鍵字并使用 vector 代替整型,因?yàn)檎涂梢员豢截惗皇且苿?;注意這些代碼還不能編譯:

文件名: 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));
}

這個(gè)例子并不能編譯,會產(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 被移動進(jìn)了閉包,因?yàn)殚]包使用 move 關(guān)鍵字定義。接著閉包獲取了 x 的所有權(quán),同時(shí) main 就不再允許在 println! 語句中使用 x 了。去掉 println! 即可修復(fù)問題。

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

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


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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號