Categories
程式開發

Rust閉包的蟲洞穿梭


1. 閉包是什麼

閉包(Closure)的概念由來已久。 無論哪種語言,閉包的概念都被以下幾個特徵共同約束:

匿名函數(非獨有,函數指針也可以);可以調用閉包,並顯式傳遞參數(非獨有,函數指針也可以);以變量形式存在,可以傳來傳去(非獨有,函數指針也可以);可以在閉包內直接捕獲並使用定義所處作用域的值(獨有);

神奇的是最後一點,理解起來也比較彆扭的,習慣就好了。

為了說明上述特徵,可以看一個Rust例子。

fn display(age: u32, print_info: T)
where T: Fn(u32)
{
print_info(age);
}

fn main() {
let name = String::from("Ethan");

let print_info_closure = |age|{
println!("name is {}", name);
println!("age is {}", age);
};

let age = 18;
display(age, print_info_closure);
}

運行代碼:

名稱是Ethanage是18

首先,閉包作為匿名函數存在了print_info_closure棧變量中,然後傳遞給了函數display作為參數,在display內部調用了閉包,並傳遞了參數age。 最後神奇的事情出現了:在函數display中調用的閉包居然打印出了函數main作用域中的變量name。

Rust閉包的蟲洞穿梭 1

閉包的精髓,就在於它同時涉及兩個作用域,就彷佛打開了一個”蟲洞”,讓不同作用域的變量穿梭其中。

let x_closure = ||{};

單獨一行代碼,就藏著這個奧妙:

賦值=的左側,是存儲閉包的變量,它處在一個作用域中,也就是我們說的閉包定義處的環境上下文;賦值=的右側,那對花括號{}裡,也是一個作用域,它在閉包被調用處動態產生;

無論左側右側,都定義了閉包的屬性,天然的聯通了兩個作用域。

對於閉包,Rust如此,其他語言也大抵如此。 不過,Rust不是還有所有權、生命週期這一檔子事兒麼,所以還可以深入分析下。

2. Rust閉包捕獲上下文的方式

如本篇題目,Rust閉包如何捕獲上下文?

換個問法,main作用域中的變量name是以何種方式進入閉包的作用域的(第1節例子)? 轉移or借用?

It Depends,視情況而定。

Rust在std中定義了3種trait:

FnOnce:閉包內對外部變量存在轉移操作,導致外部變量不可用(所以只能call一次);FnMut:閉包內對外部變量直接使用,並進行修改;Fn:閉包內對外部變量直接使用,不進行修改;

後者能辦到的,前者一定能辦到。 反之則不然。 所以,編譯器對閉包簽名進行推理時:

實現FnMut的,同時也實現了FnOnce;實現Fn的,同時也實現了FnMut和FnOnce。

第1節的例子,將display的泛型參數從Fn改成FnMut,也可以無警告通過。

fn display(age: u32, mut print_info: T)
where T: FnMut(u32)
{
print_info(age);
}

對環境變量進行捕獲的閉包,需要額外的空間支持才能將環境變量進行存儲。

3. 作為參數的閉包簽名

上面代碼display函數定義,要接受一個閉包作為參數,揭示瞭如何顯式的描述閉包的簽名:在泛型參數上添加trait約束,比如T: FnMut(u32),其中(u32)顯式的表示了輸入參數的類型。 儘管是泛型參數約束,但是函數簽名(除了沒有函數名)描述還是非常精確的。

順便說一句,Rust的泛型真的是乾了不少事情,除了泛型該干的,還能添加trait約束,還能描述生命週期。

描述簽名是一回事,但是誰來定義閉包的簽名呢? 閉包定義處,我們沒有看到任何的類型約束,直接就可以調用。

答案是:閉包的簽名,編譯器全部一手包辦了,它會將首次調用閉包傳入參數和返回值的類型,綁定到閉包的簽名。 這就意味著,一旦閉包被調用過一次後,再次調用閉包時傳入的參數類型,就必須是和第一次相同。

傳入參數和返回值類型綁定好了,但你心中難免還會有一絲憂愁:描述生命週期的泛型參數腫麼辦?

Rust編譯器也搞得定。

fn main(){
let lifttime_closure = |a, b|{
println!("{}", a);
println!("{}", b);
b
};
let a = String::from("abc");
let c;
{
let b = String::from("xyz");
c = lifttime_closure(&a, &b);
}
println!("{}", c);
}

以上代碼無法通過編譯,成功檢測出了懸垂引用:

錯誤[E0597]:b壽命不足

顯然,對於閉包,編譯期可以對引用的生命週期進行檢查,以保證引用始終有效。

這個例子,與其​​解釋閉包與函數的區別,不如解釋匿名函數與具名函數的區別:

具名函數是簽名在先的,對於編譯器來說,調用方和函數內部實現,只要分別遵守簽名的約定即可。 匿名函數的簽名則是被推理出來的,編譯器要看全看透調用方的實際輸入,以及函數內部的實際返回,檢查自然也就順帶做掉了。

4. 函數返回閉包

第1節的例子,我們將一個閉包作為函數參數傳入,那麼根據閉包的特性,它應該能夠作為函數的返回值。 答案是肯定的。

基於前面介紹的Fn trait,我們定義一個返回閉包的函數,代碼如下:

fn closure_return() -> Fn() -> (){
||{}
}

可是,編譯失敗了:

錯誤[E0746]:返回類型不能具有未裝箱的特徵對象,並且在編譯時沒有已知的大小

失敗信息顯示,編譯器無法確定函數返回值的大小。 一個閉包有多大呢? 並不重要。

開門見山,通用的解決方法是:為了能夠返回閉包,可以使用一次裝箱,從而將棧內存變量裝箱存入堆內存,這樣無論閉包有多大,函數返回值都是一個確定大小的指針。 下面的代碼裡,使用Box::new即可完成裝箱。

fn closure_inside() -> Box ()>
{
let mut age = 1;
let mut name = String::from("Ethan");

let age_closure = move || {
name.push_str(" Yuan");
age += 1;
println!("name is {}", name);
println!("age is {}", age);
};

Box::new(age_closure)
}

fn main(){
let mut age_closure = closure_inside();
age_closure();
age_closure();
}

運行結果如下:

名字是Ethan Yuanage是2名字是Ethan Yuan Yuanage是3

上面的代碼,除了讓函數成功返回閉包之外,還有一個目的,我們想讓閉包捕獲函數內部環境中的值,但這次有些不同:

第1節代碼示例,我們把外層的環境上下文,通過將閉包傳入內層函數,這個不難理解,因為外層變量的生命週期更長,內層函數訪問時,外層變量還活著;而本節代碼所做的,是通過閉包將內層函數的環境變量傳出來給外層環境;

內層函數調用完成後就會銷毀內層環境變量,那如何做到呢? 幸好,Rust有所有權轉移。 只要能促成內層函數的環境變量向閉包進行所有權的轉移,這個操作順理成章。

正因為Rust具有所有權轉移的概念,返回閉包(同時捕獲環境變量)的機理,Rust的要比任何具有垃圾回收語言(JavaScript、Java、C#)的解釋都更簡單明了。 後者總會給人一絲不安:內部函數調用都結束了,居然局部變量還活著。

代碼中的所有權轉移,這裡使用了關鍵字move,它可以在構建閉包時,強制將要捕獲變量的所有權轉移至閉包內部的特別存儲區。 需要注意的是,使用move,並不影響閉包的trait,本例中可以看到閉包是FnMut,而不是FnOnce。