闭包解析(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 的一些坑。如果你发现本文有什么地方不妥,就狠狠的砸过来把,欢迎讨论:-)。

posted @ 2012-11-23 22:54  TW-刘夏  阅读(6201)  评论(7编辑  收藏  举报