Rust的 閉包(closures) 是可以儲存進變量或作為參數傳遞給其它函數的匿名函數。可以在一個地方建立閉包,然後在不同的上下文中執行閉包運算。不同于函數,閉包允許捕獲調用者作用域中的值。我們将學習閉包的這些功能如何複用代碼和自定義行為。
使用閉包建立行為的抽象
讓我們試一個存儲稍後要執行的閉包的示例。 其間我們會學習閉包的文法、類型推斷和trait。
考慮一下這個假定的場景:我們在一個通過 app 生成自定義健身計劃的初創企業工作。其後端使用 Rust 編寫,而生成健身計劃的算法需要考慮很多不同的因素,比如使用者的年齡、身體品質指數(Body Mass Index)、使用者喜好、最近的健身活動和使用者指定的強度系數。本例中實際的算法并不重要,重要的是這個計算隻花費幾秒鐘。我們隻希望在需要時調用算法,并且隻希望調用一次,這樣就不會讓使用者等得太久。
我們将通過調用simulated_expensive_calculation函數來模拟調用假定的算法,如下所示,它會失印出calculating slowly...,等待兩秒,并接着傳回傳遞給它的數字:
接下來,main函數中将會包含本例健身app中的重要部分。這代表當使用者請求健身計劃時app會調用的代碼。因為與app前端的互動與閉包的使用并不相關,是以我們将寫死代表程式輸入的值并列印輸出。
所需的輸入有這些:
一個來自使用者的intensity數字,請求健身計劃時指定,它代表使用者喜好低強度還是高強度健身。
一個随機數,其會在健身計劃中生成變化。
程式的輸出将會是建議的鍛練計劃。
main函數使用了generate_workout函數的模拟使用者輸入和模拟随機數輸入。generate_workout函數包含本例中我們最關心的app業務邏輯。
以上函數裡,現在所有情況下都需要調用函數并等待結果,包括那個完全不需要這一結果的内部if塊。
我們希望能夠在程式的一個位置指定某些代碼,并隻在程式的某處實際需要結果的時候執行這些代碼。這正是閉包的用武之地。
重構使用閉包儲存代碼
不同于總是在if塊之前調用simulated_expensive_calculation函數并儲存其結果,我們可以定義一個閉包并将其儲存在變量中,如下所示,實際上可以選擇将整個simulated_expensive_calculation函數體移動到這裡引入的閉包中:
定義一個閉包并儲存到變量expensive_closure中。
閉包定義是expensive_closure指派的=之後的部分。閉包的定義以一對豎線( | )開始,在豎線中指定閉包的參數;之是以選擇這個文法是因為它與Smalltalk和Ruby的閉包定義類似。這個閉包有一個參數num;如果有多于一個參數,可以使用逗号分隔,比如 | param1, param2 | 。
參數之後是存放閉包體的大括号--如果閉包體隻有一行則大括号是可以省略的。大括号之後閉包的結尾,需要用于let語句的分号。因為閉包體的最後一行沒有分号(正如函數體一樣),是以閉包體(num)最後一行的傳回值作為調用閉包時的傳回值。
注意這個let語句意味着expensive_closure包含一個匿名函數的定義,不是調用匿名函數的傳回值。想一下使用閉包的原因是我們需要在一個位置定義代碼,儲存代碼,并在之後的位置實際調用它;期望調用的代碼現在儲存在expensive_closure中。
定義了閉包之後,可以改變if塊中的代碼來調用閉包以執行代碼并擷取結果值。調用閉包類似于調用函數;指定存放閉包定義的變量名并後跟包含期望使用的參數的括号,如下所示:
現在耗時的計算隻在一個地方被調用,并隻會在需要結果的時候執行該代碼。
閉包類型推斷和注解
閉包不要求像fn函數那樣在參數和傳回值上注明類型。函數中需要類型注解是因為他們是暴露給使用者的顯式接口的一部分。嚴格的定義這些接口對于保證所有人都認同函數使用和傳回值的類型來說是很重要的。但是閉包并不用于這樣暴露在外的接口:他們儲存在變量中并被使用,不用命名他們或暴露給庫的使用者調用。
閉包通常很短,并隻關聯于小範圍的上下文而非任意情境。在這些有限制的上下文中,編譯器能可靠的推斷參數和傳回值的類型,類似于它是如何能夠推斷大部分變量的類型一樣。
強制在這些小的匿名函數中注明類型是很惱人的,并且與編譯器已知的資訊存在大量的重複。
類型似變量,如果相比嚴格的必要性你更希望增加明确性并變得羅嗦,可以選擇增加類型注解:
使用帶有泛型和Fn trait的閉包
回到我們的健身計劃生成app,在上面的代碼仍然把慢計算閉包調用了比所需要更多的次數。解決這個問題的一個方法是在全部代碼中的每一個需要多個慢計算閉包結果的地方,可以将結果儲存進變量以供複用,這樣就可以使用變量而不是再次調用閉包。但是這樣就會有很多重複的儲存結果變量的地方。
幸運的是,還有另一個可用的方案。可以建立一個存放閉包和調用閉包結果的結構體。該結構體隻會在需要結果時執行閉包,并會緩存結果值,這樣餘下的代碼就不必再負責儲存結果并可以複用該值。這種模式被稱為 memoization 或 lazy evaluation (惰性求值)。
為了讓結構體存放閉包,我們需要指定閉包的類型,因為結構體定義需要知道其每一個字段的類型。每一個閉包執行個體有其它自已獨有的匿名類型:也就是說,即便兩個閉包有着相同的簽名,他們的類型仍然可以被認為是不同。為了定義使用閉包的結構體、枚舉或函數參數,我們可以使用泛型和trait bound。
Fn系統trait由标準庫提供。所有的閉包都實作了trait Fn、FnMut 或 FnOnce 中的一個。
為了滿足Fn trait bound 我們增加了代表閉包包必須的參數和傳回值類型的類型。在以下例子中,閉包有一個u32的參數并傳回一個u32,這樣所指定的trait bound就是 Fn(32)->u32。
以下代碼展示了閉包和一個Option結果值的Cacher結構體的定義:
定義一個Cacher結構體來在calculation中存放閉包并在value中存放Option值。
結構體Cacher有一個泛型T的字段calculation。T的trait bound指定了T是一個使用Fn的閉包。任何我們希望儲存到Cacher執行個體的calculation字段的閉包必須有一個u32參數(由Fn之後的括号的内容指定)并必須傳回一個u32(由->之後的内容)。
注意:函數也都實作了這個Fn trait。如果不需要捕獲環境中的值,則可以使用實作了Fn trait的函數而不是閉包。
字段value是Option<u32>類型的。在執行閉包之前,value将是None。如果使用Cacher的代碼請求閉包的結果,這時會執行閉包并将結果儲存在value字段的Some成員中。接着如果代碼再次請求閉包的結果,這時不再執行閉包,而是會傳回存放在Some成員中的結果。
以上是Cacher的緩存邏輯。
Cacher結構體的字希是私有的,因為我們希望Cacher管理這些值而不是任由調用代碼潛在的直接改變他們。
Cacher::new函數擷取一個泛型參數T,它定義于impl塊上下文中并與Cacher結構體有着相同的trait bound。Cacher::new傳回一個在calculation字段中存放了指定閉包和在value字段中存放了None值的Cacher執行個體,因為我們還沒執行閉包。
當調用代碼需要閉包的執行結果時,不同于直接調用閉包,它會調用value方法。這個方法會檢查self.value是否已經有了一個Some的結果值;如果有,它傳回Some中的值并不會再次執行閉包。
如果self.value是None,則會調用self.calculation中儲存的閉包,将結果儲存到self.value以便将來使用,并同時傳回結果值。
以下在generate_workout函數中利用Cacher結構體來抽象出緩存邏輯:
不同于直接将閉包儲存進一個變量,我們儲存一個新的Cacher執行個體來存放閉包。接着,在每一個需要結果的地方,調用Cacher執行個體的value方法。可以調用value方法任意多次,或者一次也不調用,而慢計算最多隻會運作一次。
我們嘗試改變simulated_user_specified_value和simulated_random_number變量中的值來驗證在所有情況下在多個if和else塊中,閉包列印的calculation slowly...隻會在需要時出現并隻會出現一次。Cacher負責確定不會調用超過所需的慢計算所需的邏輯,這樣generate_workout就可以專注業務邏輯了。
Cacher實作的限制
值緩存是一種更加廣泛的實用行為,我們可能希望在代碼中的其它閉包中也使用他們。然而,目前Cacher的實作存在兩個小問題,這使得在不同上下文中的使用變得很困難。
第一個問題是Cacher執行個體假設對于value方法的任何arg參數值總是會傳回相同的值。也就是說,這個Cacher的測試會失敗:
這個測試使用傳回傳遞給它的值的閉包建立了一個新的Cacher執行個體。使用為1的arg和為2的arg調用Cacher執行個體的value方法,同時我們期望使用為2的arg調用value會傳回2。
以上測試會在assert_eq!失敗并顯示如下資訊:
這裡的問題是第一次使用 1 調用 c.value,Cacher 執行個體将 Some(1) 儲存進 self.value。在這之後,無論傳遞什麼值調用 value,它總是會傳回 1。
嘗試修改 Cacher 存放一個哈希 map 而不是單獨一個值。哈希 map 的 key 将是傳遞進來的 arg 值,而 value 則是對應 key 調用閉包的結果值。相比之前檢查 self.value 直接是 Some 還是 None 值,現在 value 函數會在哈希 map 中尋找 arg,如果找到的話就傳回其對應的值。如果不存在,Cacher 會調用閉包并将結果值儲存在哈希 map 對應 arg 值的位置。
目前 Cacher 實作的第二個問題是它的應用被限制為隻接受擷取一個 u32 值并傳回一個 u32 值的閉包。比如說,我們可能需要能夠緩存一個擷取字元串 slice 并傳回 usize 值的閉包的結果。請嘗試引入更多泛型參數來增加 Cacher 功能的靈活性。
閉包會捕獲其環境
在健身計劃生成器的例子中,我們隻将閉包作為内聯匿名函數來使用。不過閉包還有另一個函數所沒有的功能:他們可以捕獲其環境并通路其被定義的作用域的變量。
以下示例有一個儲存在equal_to_x變量中閉包的例子,它使用了閉包環境中的變量x:
這裡,即便x并不是equal_to_x的一個參數,equal_to_x閉包也被允許使用變量x,因為它與equal_to_x定義于相同的作用域。
函數則不能做到同樣的事,如下例子,它并不能編譯:
會得到一個錯誤:
編譯器還會提示我們這隻能用于閉包。
當閉包從環境中捕獲一個值,閉包會在閉包體中儲存這個值以供使用。這會使用記憶體并産生額外的開銷,在更一般的場景中,當我們不需要閉包來捕獲環境時,我們不希望産生這些開銷。因為函數從末允許捕獲環境,定義和使用函數也不從不會有這些額外開銷。
閉包可以通過三種方式捕獲其環境,他們直接對應函數的三種擷取參數方式:擷取所有權,可變借用和不可變借用。這三種捕獲值的方式被編碼為如下三個Fn trait:
FnOnce 消費從周圍作用域捕獲的變量,閉包周圍的作用域被稱為其環境,environment。為了消費捕擷取的變量,閉包必須擷取其所用權并在定義閉包時将其移動進閉包。其名稱的 Once 部分代表了閉包不能多次擷取相同變量的所有權的事實,是以它隻能被調用一次。
FnMut 擷取可變的借用值是以可以改變其環境
Fn 從其環境擷取不可變的借用值
當建立一個閉包時,Rust根據其如何使用環境中變量來推斷我們希望如何引用環境。
由于所有閉包都可以被調用至少一次,是以所有閉包都實作了FnOnce。那些并沒有移動被捕獲變量的所有權到閉包内的閉包也實作了FnMut,而不需要對被捕獲的變量進行可變通路的閉包則也實作了Fn。
equal_to_x閉包不可變的借用了x(是以equal_to_x具有Fn trait),因為閉包體隻需要讀取x的值。
如果你希望強制閉包擷取其使用的環境值的所有權,可以在參數清單前使用move關鍵字。這個技巧在将閉包傳遞給新線程以便将資料移動到新線程中時最為實用。