我發現英文標題真的非常不給力。
這篇隨筆是對“閉包”這個東西的簡單介紹。為了輕松一些,用了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 的一些坑。如果你發現本文有什么地方不妥,就狠狠的砸過來把,歡迎討論:-)。