閉包解析(Fun with closure)


我發現英文標題真的非常不給力。

這篇隨筆是對“閉包”這個東西的簡單介紹。為了輕松一些,用了Fun with closure這個標題。

有點兒像閉包的東西

我先找了幾個有點兒像閉包的東西。擺出來看看。第一個東西是C++的Functor:

 1 struct add_x {
 2     add_x(int x) : m_x(x) { }
 3     int operator() (int y) { return m_x + y; }
 4  
 5 private:
 6     int m_x;
 7 };
 8 
 9 int value = 1;
10 
11 std::transform(input, input + size, result, add_x(value));

這段代碼期望將 input 集合中的每一個元素使用 add_x 映射到 result 集合中。這里,add_x是一個 functor。為了將在函數棧空間上定義的變量value引入到functor中來,我們必須采用成員變量的方式對其進行復制(或者引用)。這樣一來,好像在棧上定義的值value被帶到了另外一個上下文中一樣。

我們再來看看一段 C# 的代碼:

 1 IEnumerable<int> Transform(
 2     IEnumerable<int> input,
 3     Func<int, int, int> transformer, 
 4     int factor) {
 5     foreach (int value in input) {
 6         yield return transformer(value, factor);
 7     }
 8 }
 9  
10 int Add(int x, int y) { return x + y; }
11  
12 void Main() {
13   int[] array = { 1, 2, 3, 4, 5 };
14   int factor = 1;
15   Transform(array, Add, factor).Dump();
16 }

這段代碼同樣也是在一個集合上應用 Add 方法。為了將在 Main 函數中定義的變量 factor 引入到Add方法中,我們將factor變量作為參數傳入了Transform函數中,進而傳入了transformer委托中。

做一個閉包

上面兩段代碼都像是“閉包”但是他們不是。我們接下來要做一個“真的”閉包,用C#吧,雖然我很想用Javascript。

第一件事情就是將“函數”看作 first-class data,或者稱之為first-class function。什么是 first-class function呢?請看維基(http://en.wikipedia.org/wiki/First-class_function),如果你不喜英文我簡要解釋:first-class function意味着在語言中,函數可以被用作參數傳遞到其他的函數中;函數可以當作返回值被其他函數返回;函數可以作為數據存儲在其他數據結構中。好的我們現在就把函數看作 first-class function:

1 Func<string, string, bool> predicator = delegate(string value, string part) {
2   return value.Contains(part);
3 };

當然我們還可以將其寫為 lambda 表達式:

1 Func<string, string, bool> predicator = (value, part) => value.Contains(part);

現在,如果我們希望知道一個字符串是否包含了 “jumps”這個字符串的時候,我們可以用如下的代碼:

string data = "A quick brown fox jumps over a lazy dog.";
predicator(data, "jumps")

但是我們不太喜歡“jumps”這個參數,我們從參數表中解放他,於是我們把他挪到了外面作為一個變量,而在函數數據體中直接使用這個變量。

1 string partVariable = "jumps";
2 Func<string, bool> predicator = (value) => value.Contains(partVariable);
3 string data = "A quick brown fox jumps over a lazy dog.";
4 predicator(data).Dump();

現在你得到了閉包!恭喜。

什么是閉包?

那么什么是閉包呢?這里有兩個定義。我們先來看睡覺前專用的定義:在計算機科學中(而不是數學中),一個閉包是一個函數或者一個函數的引用,以及他們所引用的環境信息(就像是一個表,這個表存儲了這個函數中引用的每一個沒有在函數內聲明的變量)。

也就是閉包總是要有兩個部分的,一部分是一個函數,另一個部分是被這個函數“帶走”的,但是卻不是在這個函數中聲明的變量表(稱之為 free variables 或者 outer variables)。

還有一個不是那么呆的定義:閉包允許你封裝一些行為(函數就是行為),像其他對象一樣將它傳來傳去(函數是first-class function),但是不論怎樣,它仍然保持着對原來最初上下文的訪問能力(它還能訪問到 outer variables)。

很神奇,那么他是怎么實現的呢?

我們以C#為例,但是其他語言的實現方式大同小異。這里可能C++的實現需要注意問題最多,我們會單獨的說明。C#代碼來也:

1 string key = "u";
2 var result = words.Where(word => word.Contains(key));

這是一段非常簡單的代碼,你可以編譯,然后用反編譯器反向一下就會看到編譯器幫你做的事情,我把這些事情用以下的圖表示:

編譯器為我們做了兩件事情:

(1)剛才提到閉包有兩個要素,一個是函數,另一個是函數引用的外部變量。OK,這里函數就是 word => word.Contains(key),而外部變量就是 key。編譯器將這兩個東西封裝成了一個類:ClosureHelper。
(2)將原本在函數“棧”上分配的變量 key,替換為了 closureHelper.key。此時,變量就跑到堆上去了。所以即使函數滿世界跑,他也總能夠訪問到最初的那個變量closureHelper.key。

看到了嗎?這個變量的生存期實際上延長了!

Closure的“詭異”現象

在了解了實現細節之后。我們可以來探討一下使用 Closure 可能出現的“詭異”現象。說“詭異”其實只要套用 Closure 的實現細節,他們實際上也很普通。這些詭異現象的成因基本上都是一個:outer-variable在closure中被改變了。

例子1:

假設我們有如下的初始代碼:

1 var words = new List<string> {
2     "the", "quick", "brown", "fox", "jump", 
3     "over", "a", "lazy", "dog"
4 };
5  
6 string key = "u";
7 var result = words.Where(word => word.Contains(key));

我們比較容易知道輸出是:quick和jump。但是如果這個程序變成:

1 string key = "u";
2 Func<string, bool> predicate = word => word.Contains(key);
3 key = "v";
4 
5 var result = words.Where(predicate);

那么輸出又是什么呢?考慮到key實際上是closureHelper.key那么很容易知道在predicate執行的時候,key已經變成了"v",因此輸出是:over。還想不明白的打開一個LINQPad試一下就知道了:-)。

例子2:

 1 var actionList = new List<Action>();
 2  
 3 for (int i = 0; i < 5; ++i) {
 4     actionList.Add(
 5         () => Console.WriteLine(i));
 6 }
 7  
 8 foreach (Action action in actionList) {
 9     action();
10 }

如果你面試,也許會碰到這個東西。他的輸出是:5 5 5 5 5。這個用語言解釋起來不太容易,請看下面的圖:

ClosureHelper是在 for 循環體之外創建的,也就是 outer-variable 被 capture 的時候,全局只有一個實例。因此i實際上在第一個循環之后其值是5。這樣,在action真正執行的時候只可能輸出5。

為了修正這個問題,我們不應當用 i 作為 outer variable 而是應當在循環體內定義 outer-variable:

 1 var actionList = new List<Action>();
 2  
 3 for (int i = 0; i < 5; ++i) {
 4     int outerVariable = i;
 5     actionList.Add(
 6         () => Console.WriteLine(outerVariable));
 7 }
 8  
 9 foreach (Action action in actionList) {
10     action();
11 
12 }

這樣,執行過程就變成了:

輸出為期望值:0 1 2 3 4。

事實上,如果是 java,根本不允許第一種寫法。屬於語法錯誤。

例子3

不難想到,在closure中改變outer variable同樣可以影響到其他上下文中的outer variable引用。例如:

1 int variable = 2;
2  
3 Action action = delegate { variable = 3; };
4 action();

執行之后,variable 的值是3。

你看到了,在closure中改變outer varaible的值還是不要做為好。實際上,不更改 closure 中 outer variable 的值有額外的好處:

(1)避免過度用腦導致的脫發;
(2)這類代碼更容易移植到函數式語言,例如 F# 等。因為在這些語言中 immutable 是一個基本的規則。

關於函數式語言的一些范式已經超出了本文的范圍,我建議大家看看以下的博客:

(1)http://diditwith.net/default.aspx
(2)http://blogs.msdn.com/b/dsyme/

C++ 的細節

方才提到了,由於閉包使得被 capture 的變量的生存期實際上延長了!這種處理方式對於C#,Java,F#等托管環境下的語言來說是沒有什么問題的。但是C++(Native,對不起我真的討厭用 C++ CLI 寫程序)沒有垃圾收集器。編譯器怎么處理?難道也會延長生存期?答案是,不會。你需要自己搞定這些,否則沒准兒就會出現 Access Violation。

那么我怎么搞定呢?答案是控制 Capture Style。也就是向編譯器說明,我如何引用 outer variable。我們先看看 C++ 中如何構造閉包吧。

C++中的閉包聲明可以用 lambda表達式來做,其包含三個部分:

(1)Capture Method,也就是我們關注的capture style;
(2)Parameter List,即參數表,和普通的 C/C++ 函數一樣;
(3)Expression Body:即函數的主體,和普通的 C/C++ 函數一樣;

第(2)和第(3)點都不用多說。關鍵是第一點。第一點要想說清楚真的要說不少廢話,不如列表來的清晰,這個列表來源於 http://www.cprogramming.com/c++11/c++11-lambda-closures.html

[] 什么都不捕獲
[&] 按照引用捕獲所有的outer variables
[=] 通過復制(按值)捕獲所有的outer variables
[=, &foo] 通過復制捕獲所有的outer variables,但是對於 foo 這個變量,用引用捕獲
[bar] 通過復制捕獲bar這個變量,其他的變量都不要復制;
[this] 通過復制的方式捕獲當前上下文中的this指針;

這種Capture方法的指定直接影響到了編譯器生成的Helper類型的成員變量的聲明形式(聲明為值還是引用)進而影響程序的邏輯。Helper類型將在Capture時生成,屆時將根據Capture的類型進行復制或者引用。舉一個例子。

1 {
2     outer_variable v; // [1]
3  
4     std::function<void(void)> lambda = [=] () { v.do_something(); }; // [2]
5     lambda(); // [3]
6 }

在【1】處,outer_variable創建了一個實例,outer_variable 的默認構造函數被調用。假設我們記這個實例為 v。

在【2】處比較繁:
首先,一個 closure 實例被創建,並且 v 以 value 的形式進行 capture 被 closure 實例使用,因而 outer_variable 的復制構造函數被調用。我們記這個 outer_variable 的實例為 v'。
其次,觸發 std::function::ctor(const T&),其內部會為類型T(目前這里是一個匿名的 closure 類型)進行復制構造,於是,v' 作為其中的一個按值引用的成員變量也被復制構造,因此 outer_variable 的復制構造函數被調用。我們記這個 outer_variable 的實例為 v''。

【2】完畢之后,rvalue 的 closure 實例被析構,使得 v' 被析構。

【3】實際上調用的是 v'' 的 do_something 方法;

是不是很煩?當然,在按值 capture 的方式下,顯然無法更改 outer varaible 的值。

按引用 capture 顯然不需要頻繁的復制構造 outer varaible 實例。並且,你可以在 closure 中更改 outer variable 的值以影響最初上下文中的變量。但是需要特別注意變量的生存期。

std::function<void(void)> func;
 
{
    outer_variable v; // [1]
    func = [&] () { v.do_something(); }; // [2]
} // [3]
 
func(); // undefined behavior.

【1】outer_variable 默認構造函數調用,創建實例 v。
【2】closure helper 實例構造,按引用 capture 到 v,由於是按引用因此沒有復制構造函數調用,closure helper 實例使用 std::function 的構造函數初始化 std::function 對象。rvalue closure 實例析構。
【3】由於超出了作用域,v析構。此時 func 對象的 closure helper 實例 capture 到的 v 的引用已然不存在了。

此時調用 func 會造成未定義行為。具體的參見 C++ Spec:

5.1.2 Lambda expressions [expr.prim.lambda]

22 - [ Note: If an entity is implicitly or explicitly captured by reference, invoking the function call operator of the corresponding lambda-expression after the lifetime of the entity has ended is likely to result in undefined behavior. —end note ]

結尾

好了,寫完了。希望到此你已經對 closure 有了一個了解,知道了編譯器是怎么處理他的。也知道了使用 closure 的一些坑。如果你發現本文有什么地方不妥,就狠狠的砸過來把,歡迎討論:-)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM