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