閉包解析(Fun with closure)

來源:互聯網
上載者:User

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

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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.