《代码的未来》读书笔记:也谈闭包
一、从函数对象到委托
松本大叔说:要理解闭包,从函数指针开始!
1.1 函数指针及其作用
原文中使用了C语言的函数对象,这里我们主要从.NET平台来说。在.NET中,委托这个概念对C++程序员来说并不陌生,因为它和C++中的函数指针非常类似,很多码农也喜欢称委托为安全的函数指针。无论这一说法是否正确,委托的的确确实现了和函数指针类似的功能,那就是提供了程序回调指定方法的机制。
下面的代码展示了委托的基本使用:
// 定义的一个委托 public delegate void TestDelegate(int i); public class Program { public static void Main(string[] args) { // 定义委托实例 TestDelegate td = new TestDelegate(PrintMessage); // 调用委托方法 td(0); td(1); Console.ReadKey(); } public static void PrintMessage(int i) { Console.WriteLine("这是第{0}个方法!", i.ToString()); } }
运行结果如下图所示:
也许很多初学者都了解委托的概念,但是却不知道为什么要有委托,委托到底有什么作用?这里我们通过数据结构里面最经典的冒泡排序来看看委托在提高程序扩展性/通用性方面的作用。
Step1.我们可以比较容易地写出下面的这段冒泡排序代码,它针对int类型数组进行升序排序:
public static void BubbleSort(int[] arr) { int i, j; int temp; bool isExchanged = true; for (j = 1; j < arr.Length && isExchanged; j++) { isExchanged = false; for (i = 0; i < arr.Length - j; i++) { if (arr[i] > arr[i + 1]) { // 核心操作:交换两个元素 temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; // 附加操作:改变标志 isExchanged = true; } } } }
Step2.但是我们不能只能只对int类型进行排序吧,难道对double类型还得重写?于是我们加入.NET中的模板—泛型:
public static void BubbleSort(T[] arr) { int i, j; T temp; bool isExchanged = true; for (j = 1; j < arr.Length && isExchanged; j++) { isExchanged = false; for (i = 0; i < arr.Length - j; i++) { if (arr[i].CompareTo(arr[i + 1]) > 0) { // 核心操作:交换两个元素 temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; // 附加操作:改变标志 isExchanged = true; } } } }
Step3.但是我们不能只进行升序排序吧,如果要降序排序是不是要还得重写排序算法,于是我们加入.NET中的函数指针—委托:
public static void BubbleSort(T[] arr, Comparison<T> comp) { int i, j; T temp; bool isExchanged = true; for (j = 1; j < arr.Length && isExchanged; j++) { isExchanged = false; for (i = 0; i < arr.Length - j; i++) { if (comp(arr[i], arr[i + 1]) > 0) { // 核心操作:交换两个元素 temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; // 附加操作:改变标志 isExchanged = true; } } } }
其中,Comparison委托是.NET中的一个预定义委托,主要用来进行元素的比较。对Comparison不熟悉的朋友,可以看看《.NET中那些所谓的新语法之预定义委托》。
这时,我们如果想要进行升序排序,只需通过以下方式调用:
int[] smallDatas = { 3, 6, 5, 9, 7, 1, 8, 2, 4 }; SortingHelper<int>.BubbleSort(smallDatas, new Comparison<int>((p1, p2) => p1 - p2));
运行结果如下:
过了一段时间,我们想要进行降序排序,只需改变委托的实现即可复用代码:
int[] smallDatas = { 3, 6, 5, 9, 7, 1, 8, 2, 4 }; SortingHelper<int>.BubbleSort(smallDatas, new Comparison<int>((p1, p2) => p2 - p1));
运行结果如下:
两次重构之后,我们的这个冒泡排序代码的通用性就提高了不少,可以看到委托在其中起到了很大的作用。
1.2 函数指针的局限
这里松本大叔举了一个例子,我这里使用C#语言来描述。对一个由各个节点构成的链表进行两种不同方式的遍历,一是通过一般的循环,二是通过函数指针(这里主要是指委托),本质的部分是从main方法开始的。
(1)节点定义
/// <summary> /// 链表节点定义 /// </summary> public class Node { public Node next; public int value; }
(2)委托定义
// 委托类型定义-函数指针类型 public delegate void funct(int num);
(3)使用委托的自定义遍历方法与符合委托定义的方法
static void Foreach(Node list, funct func) { while (list != null) { func(list.value); list = list.next; } } static void F(int num) { Console.WriteLine("Node[?]={0}", num); }
(4)主入口:main方法
const int size = 4; static void Main(string[] args) { int i = 1; Node head = new Node(); head.value = 0; head.next = null; // 创建链表 while (i < size) { Node node = new Node(); node.value = i; node.next = head; head = node; i++; } i = 0; Node list = head; // 遍历链表 while (list != null) { Console.WriteLine("Node[{0}]={1}", i++, list.value); list = list.next; } // 自定义遍历 Foreach(head, F); Console.ReadKey(); }
该程序的运行结果如下:
其中前面4行是while循环的输出结果,而后4行则是自定义Foreach循环的输出结果。可以明显看出,在while循环的输出结果中,可以显示出索引,而Foreach的结果中只能显示"?"。这是因为:与while语句不通,Foreach的循环实际上是在另一函数中执行的,因此无法从函数中访问位于外部的局部变量 i。当然,如果 i 是一个全局变量就不存在这个问题了,不过为了这个目的而使用副作用很大的全局变量也并不是一个好主意。因此,“对外部(局部)变量的访问”是函数指针(这里主要指委托)的最大弱点。
我们已经知道了函数指针的缺点,那么为了克服这个缺点,就可以开始认知这次的主题—闭包。
二、JavaScript闭包初探
谈到闭包,得使用一种支持闭包的语言,而这方面,JavaScript绝对是棒棒哒!但是松本大叔说:要理解闭包,得先了解两个术语:作用域和生存周期。
2.1 作用域(Scope)
作用域指的是变量的有效范围,也就是某个变量可以被访问的范围。在JavaScript中,保留字var所表示的变量所表示的变量声明所在的最内侧代码块就是作用域的单位,而没有进行显示声明的变量就是全局变量。
作用域是嵌套的,因此位于内侧的代码块可以访问以其自身为作用域的变量,以及以外侧代码块为作用域的变量。
下图中我们将匿名函数赋值给了一个变量(当然,如果不赋值而直接作为参数传递也是可以的),这个函数对象也有自己的作用域:
我靠,JavaScript中可以直接定义函数对象,那么,上面程序中的Foreach方法用JavaScript就可以更直接地写出来。
function foreach(list, func) { while (list) { func(list.val); list = list.next; } } var list = null; // 变量声明 for (var i = 0; i < 4; i++) { // list初始化 list = {val: i, next: list}; } var i = 0; // i初始化 // 从函数对象中访问外部变量 foreach(list, function (n) { console.log("node(" + i + ")=" + n); i++; });
在JavaScript中,完成了C#中Foreach方法无法实现的索引实现功能。因此,从函数对象中能够对外部变量进行访问(引用、更新)是闭包的构成要件之一。
2.2 生存周期(Extent)
所谓生存周期,就是变量的寿命。相对于表示程序中变量可见范围的作用域来说,生存周期这个概念指的是一个变量可以在多长的周期范围内存在并能够被访问。
下图中的一个例子是一个返回函数对象的函数extent(这个extent函数的返回值是一个函数对象)。函数对象会对extent中的一个局部变量n进行累加,并显示它的值。
function extent() { var n = 0; // 局部变量 return function () { n++; console.log("n=" + n); // 对n的访问 } } f = extent(); // 返回函数对象 f(); // n = 1 f(); // n = 2
下图是在chrome浏览器中的log信息结果:
奇了怪了,局部变量n是在extent函数中声明的,而extent函数已经执行完毕了,变量脱离了作用域之后不应该就消失了吗?但是从结果来看,即便在函数执行完毕之后,局部变量n似乎还在某个地方继续活着。
这就是生命周期,换句话说,这个从属于外部作用域中的局部变量,被函数对象给“封闭”在里面了。闭包(Closure)原本就是封闭的意思,被封闭起来的变量的寿命,与封闭它的函数对象寿命相等(当封闭这个变量的函数不再被访问,被GC回收掉时,那么这个变量也就寿终正寝了)。
在函数对象中,将局部变量这一环境封闭起来的结构被称为闭包。因此,JavaScript的函数对象才是真正的闭包。
2.3 闭包与面向对象
当函数每次被执行时,作为隐藏上下文的局部变量n就会被引用和更新。也就是说,这意味着“函数(过程)与数据结合起来了”,它是形容面向对象中的“对象”时经常使用的表达。对象是在数据中以方法的形式内含了过程,而闭包则是在过程中以环境的形式内含了数据,即对象与闭包是同一事物的正反两面。
上面的JavaScript程序如果采用面向对象来实现的话,就会变成下面的样子:
function extent() { return { val: 0, call: function () { this.val++; console.log("val="+this.val); } }; } f = extent(); // 返回函数对象 f.call(); // val = 1 f.call(); // val = 2
运行结果如下图,和闭包形式的结果一致。
三、.NET中的闭包
闭包可以体现在JavaScript中,带来的好处是对变量的封装和隐蔽,同时将变量的值保存在内存中。同样,闭包也可以发生在.NET中。
3.1 借助匿名委托实现闭包
在.NET中,函数并不是第一级成员,所以并不能像JavaScript那样通过在函数中内嵌子函数的方式实现闭包。通常而言,形成闭包有一些必要条件:
(1)嵌套定义的函数
(2)匿名函数
(3)将函数作为参数或者返回值
刚好,.NET中提供了匿名委托,可以用来形成闭包,请看下面一个例子:
delegate void MessageDelegate(); static void Main(string[] args) { string value = "Hello Closure"; MessageDelegate message = delegate() { Show(value); }; message(); Console.ReadKey(); } private static void Show(string message) { Console.WriteLine(message); }
反编译上述代码为IL代码如下:
.class private auto ansi beforefieldinit Program extends [mscorlib]System.Object { .method private hidebysig static void Main(string[] args) cil managed { // 省略 } .class auto ansi sealed nested private beforefieldinit <>c__DisplayClass1 extends [mscorlib]System.Object { .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { // 省略 } .method public hidebysig instance void <Main>b__0() cil managed { // 省略 } .field public string value } }
可以看到,通过匿名方法将自动形成一个类,自由变量value被包装在这个类中,并升级为实例成员(即使创建该变量的方法执行结束,它也不会被释放,而是在所有回调函数执行之后才被GC回收),从而形成闭包。
自由变量value的生命周期也会随之被延长,并不局限于一个局部变量。生命周期的延迟,是闭包带来的福利,但是也往往带来潜在的问题,造成更多的消耗。
3.2 闭包与函数的关系
像对象一样操作函数,是闭包发挥的最大作用,从而实现了模块化的编程方式。不过,闭包与函数并不是一回事儿:
(1)闭包是函数与其引用环境组合而成的实体。不同的引用环境和相同的函数可以组合产生不同的闭包实例。
(2)函数是一段可执行的代码体,在运行时不会由于上下文环境发生变化。
3.3 闭包的福利与问题
在.NET中,闭包有着多方面的应用,典型的体现在以下几个方面:
(1)定义控制结构,实现模块化应用
static void Main(string[] args) { List<int> values = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int result1 = 0; int result2 = 100; values.ForEach(x => result1 += x); values.ForEach(x => result2 -= x); Console.WriteLine(result1); Console.WriteLine(result2); Console.ReadKey(); }
运行结果是:
55
45
上例的ForEach方法为遍历数组元素提供了数组基础,对于加法和减法运算而言,在闭包中改变引用环境变量的值,达到最小粒度的模块控制效果。看看是不是跟松本大叔在最开始提到的函数对象及其作用保持了一致?
(2)多个函数共享相同的上下文环境,进而实现通过上下文变量达到数据交流的作用
static void Main(string[] args) { int value = 100; IList<Func<int>> funcs = new List<Func<int>>(); funcs.Add(() => value + 1); funcs.Add(() => value - 2); foreach (var f in funcs) { value = f(); Console.WriteLine(value); } Console.ReadKey(); }
运行结果为:
101
99
数据共享为不同函数的操作间传递数据带来了方便,但是它是一把双刃剑,在不需要共享数据的场合又会带来问题。还是通过上例,value变量将在两次不同的操作中()=>value+1和()=>value-1间共享数据。如果不希望两次操作间传递数据,需要注意引入中间量协调:
static void Main(string[] args) { int value = 100; IList<Func<int>> funcs = new List<Func<int>>(); funcs.Add(() => value + 1); funcs.Add(() => value - 2); foreach (var f in funcs) { int val = f(); Console.WriteLine(val); } Console.ReadKey(); }
这下结果就变为:
101
98
四、小结
闭包是优雅的,带来代码格局的函数式体验;但是,闭包也是复杂的,带来潜在的某些问题。TA就像一把双刃剑,用好闭包的关键,在于深入地理解闭包,即在于挥剑人自己。
参考资料
(1)本文全文源自Ruby之父松本行弘的《代码的未来》一书!
(2)王涛,《你必须知道的.NET》