写在开头
本来是很讨厌谈论闭包这个话题的,因为在这一方面我比较倾向于玉伯还有一些朋友的观点,搞懂作用域才是最重要的,单独谈论闭包,真的意义不大。
今天刚好在wiki上查其他东西的时候看到了,想了想以前也没从比较科学的角度考虑这一问题,所以写下这篇文章,也算是记录一个翻译+阅读的过程,原文中涉及很多其他概念,理解起来并不容易,本人知识水平和翻译水平都非常有限,所以如果翻译得有错误,还望各位海涵,更欢迎各位批评指正~
关于原文
原文来自wikipedia中关于闭包的解释(英文),有兴趣的同学可以阅读原文。同时还有一份本身的译文,但是个人感觉这个省略了一些东西,有兴趣也可以看看。
译文内容
阅读前的提示
因为其中嵌套夹杂着许多其他概念,而这些概念可能又引出其他概念,所有这里提前约定下,下方译文中的无序列表(即●开头的内容)为对译文中一些名词所作的解释。并且在译文中对一些概念也会直接用超链接的方式链接到我认为解释得比较好的页面。
正文开始
闭包
在程序语言中,闭包(也叫词法闭包或者函数闭包)是那些实现了词法作用域和命名绑定的把函数作为一等公民(原文这里叫做first-class functions)的语言中的一种技巧(或者说特性)。
词法作用域:词法作用域也叫静态作用域,是指作用域在词法解析阶段就已经确定了,不会改变。这也是大多数语言采取的方式,JS也是如此,函数在他创建的地方运行,而不是调用的地方。
-
动态作用域:是指作用域在运行时才能确定。参看下面的例子,引自杨志的回答
var foo=1; function static(){ alert(foo); } !function(){ var foo=2; static(); }(); 在js中,会弹出1而非2,因为static的scope在创建时,记录的foo是1。 如果js是动态作用域,那么他应该弹出2
在评论中贺师俊还提到,eval 和 with可以产生动态作用域的效果:
比如 with(o) { console.log(x) } 这个x实际有可能是 o.x 。所以这就不是静态(词法)作用域了。
var x = 0; void function(code){ eval(code); console.log(x) }('var x=1') 不过注意eval在strict模式下被限制了,不再能形成动态作用域了。
命名绑定:在程序语言中,命名绑定是一种关联,他将实体(数据或者说是代码)与标识符联系或者说对应起来。一个标识符与一个对象绑定是指他持有这个对象的引用。机器语言没有这个概念,但是程序语言实现了这一点。绑定与作用域密切相关,作用域决定了某个名称或者说是标识符到底与哪个对象相关联。
first-class functions:在计算机科学中,如果一门语言把函数当作一等公民来对待,我们称他为first-class functions。具体来说,就是函数可以作为参数传递和返回,可以将它们作为变量声明,可以将它们存储在数据结构中(比如我们常用的obj.xxx = somefunc)。同时,有些语言还支持匿名函数(看到这里你应该知道JS是属于这类的)。在把函数当作一等公民的语言中,函数名没有任何特别的地方,它们就像普通变量名一样。(这里感觉原文的重点是告诉大家不要把函数想得太过复杂,所以大家也就不要举诸如函数名有length,name这些属性来反驳了)。
实际中来说,闭包是一种记录,他将函数与他的上下文环境存储起来:它是一种将函数内使用到的自由变量与他的值或者闭包被创建时那些指向其他地方的引用关联起来的映射。注意这里的使用二字很重要(原文为variables that are used locally),我们可以看下方的代码:
function fn1() {
var a = 1;
function fn2() {
debugger;
console.log(a);
}
fn2();
}
fn1();
这里我们在fn2中使用a,可以看到右图中fn1形成了闭包。准确的说,是fn1形成了fn2的闭包。
如果我们不使用a,那么就形成不了闭包。
这里纠正一个误区,很多人认为必须要返回函数才能形成闭包,比如上面必须要在fn1中返回fn2,然后fn1()()这样调用才会形成闭包,其实通过上面的截图我们可以发现并不是这样。
同时要注意,识别闭包在词法分析阶段就已经确定了,意思是说即使我们可以肯定用不到a,fn1也会识别为fn2的闭包,因为我们"使用"了a。如下所示:
自由变量:在计算机程序中,自由变量是指在一个函数中即不是局部变量也不是函数参数的变量。他与非局部变量是同义词。
-
非局部变量:是指未定义在本作用域或者说当前作用域里的变量。也就是我们常说的外部变量。举例来说,在函数func中使用变量x,却没有在func中声明(即在其他作用域中声明的),对于func的作用域来说,x就是非局部变量。
var x = 1; function func() { console.log(x); }
应用
闭包被用作实现连续式风格,并且在这种风格中隐藏状态。因此对象(函数也是对象)和控制流能通过闭包实现。在一些语言中,闭包发生于在一个函数内定义另一个函数,在内部函数中我们引用了外部函数里的局部变量(就是上图中的例子)。在运行时,当外部函数运行的时候,一个闭包就形成了,他由内部函数的代码以及任何内部函数中指向外部函数局部变量的引用组成。
连续式风格(continuation-passing style,简称CPS):在函数式编程中,CPS是一种编程风格,在这种风格中,控制流以一种续延的形式传递。这个概念真的很难解释,包括我自己也不是太明白,所以建议搜索或者参考怎样理解 Continuation-passing style以及尾递归与Continuation以及续延(continuation)以及函数式编程中cps是什么意思以及Continuation-passing style原文
个人建议结合原文然后再去看尾递归那一篇理解效果会比较好。这个概念真的很有趣,对FP来说意义重大,有兴趣的朋友可以详细去了解
连续式风格与直接式风格相对。
-
直接式风格(direct style):也是语言中常用的风格。他是顺序程序设计中常用的,在这之中,控制流通过运行下一行被子程序调用实现显示的传递,或者通过像return, yield, await这样的结构实现。
CPS与direct style对比的Example,摘自wiki For example in Dart(例子以这种语言书写), 一个循环动画可能以下面形式书写 Continuation-passing style(CPS风格) var running = true; // Set false to stop. tick(time) { context.clearRect(0, 0, 500, 500); context.fillRect(time % 450, 20, 50, 50); if (running) window.animationFrame.then(tick); } window.animationFrame.then(tick); 在CPS中,在下一帧中异步调用window.animationFrame,然后调用回调(tick)函数。 这个回调函数需要在尾部再次调用,也就是要形成尾递归
Direct style(直接式风格) var running = true; // Set false to stop. while (running) { var time = await window.animationFrame; context.clearRect(0, 0, 500, 500); context.fillRect(time % 450, 20, 50, 50); } 在直接式风格中,异步调用window.animationFrame简单的yield控制流,然后继续执行。 一个while循环可以代替递归调用
在主流语言中,CPS常常发生在将闭包作为函数参数传递的时候,因此直接式风格更简单的意味着函数返回了一个值,而不是携带了一个函数参数。
控制流:在计算机科学中,控制流(也称作流控制)是一种顺序,在这种顺序中,个体的语句,指令,函数调用以命令式执行或者解析,它强调控制流,这是与声明式有差异的地方。可以回想一下我们常画的程序执行流程图,就是控制流的一种体现。关于命令式与声明式,可以参考命令式与声明式的区别-1,命令式与声明式的区别-2
中断和信号是低等级的改变控制流的机制(应该就是指break,continue,throw这一类),但是通常发生时被当作一种对外部刺激或者事件(也可能异步发生)的响应,而不是一个内联控制流语句的执行。在机器语言或者汇编语言层面,控制流常常通过程序计数器PC来改变。对一些CPU而言,唯一可用的控制流指令就是条件指令(类似于if)和非条件分支指令(原文为also called jumps,就是我们常说的goto)。
状态表示
闭包能被用作与函数的私有变量相关联,让外部调用呈现连续性(比如一个高阶函数实现累加)。私有变量只能被内部函数访问到,其他任何地方都访问不到这个变量。
因此,在有状态语言中,闭包能被用来实现状态表示和信息隐藏(可以理解为私有变量),因此,闭包内的局部变量的生命周期是不确定的,所以创建的一个变量在函数被下一次调用时仍然可用。这种方式的闭包不再具有引用透明性,即他不再是一个纯函数。
退出闭包
在其他词法作用域结构中,闭包是存在很多区别的。比如return,break 和 continue语句。一般来说,这些结构被认为脱离延续(原文为escape continuation,异常处理就属于这类),即脱离一个封闭的语句(如break和continue,从函数递归调用的角度讲,这些指令被认为需要循环结构才能工作)。
在一些语言中,例如ECMAScript,return指向了词法闭包语句建立的最内层的continuation,在闭包中return将控制流转移到调用它的代码。
然而,在Smalltalk语言中,对于方法调用,有一个表面上很相似的操作符^,它调用建立的escape continuation,忽略任何中间的嵌套闭包。一个特定闭包中escape continuation只能在达到闭包代码结束的时候被显式调用。下面的例子展示了这之间的区别:
"Smalltalk"
foo
| xs |
xs := #(1 2 3 4).
xs do: [:x | ^x].
^0
bar
Transcript show: (self foo printString) "prints 1"
// ECMAScript
function foo() {
var xs = [1, 2, 3, 4];
xs.forEach(function (x) { return x; });
return 0;
}
alert(foo()); // prints 0
上面的代码片段描述了在Smalltalk中的^
操作符与JS中的return
操作符的行为并不是相同的。在上面的JS中,return x
将会离开内层闭包并开始forEach
循环的下一次迭代,而在Smalltalk中,^x
将会终止循环并从foo
方法返回。
类闭包结构
一些语言的特性能够模拟出闭包的效果。包括那些面向对象的语言,如JAVA,C++,OC,C#,D,有这方面兴趣的朋友可以看看原文,这里我们选出原文中提到的关于JAVA的部分作为介绍:
Java中的局部类与lambda函数
Java中可以将类定义在方法内部。我们把这叫做局部类(包括方法内部类和匿名内部类)。当这些类没有名称时,我们把它们叫做匿名类或者匿名内部类。一个局部类中可以引用闭包类中的变量,或者闭包方法中的final变量。
class CalculationWindow extends JFrame {
private volatile int result;
...
public void calculateInSeparateThread(final URI uri) {
// "new Runnable() { ... }"是一个实现了Runnable接口的匿名内部类
new Thread(
new Runnable() {
void run() {
// 他可以访问局部的final变量
calculate(uri);
// 他可以访问闭包类的成员变量
result = result + 10;
}
}
).start();
}
}
随着JAVA8支持lambda表达式,上面的代码可以改写成如下形式:
class CalculationWindow extends JFrame {
private volatile int result;
...
public void calculateInSeparateThread(final URI uri) {
// 下面的形如 code () -> { /* code */ } 就是一个闭包
new Thread(() -> {
calculate(uri);
result = result + 10;
}).start();
}
}
局部类是内部类的一种,他们被声明在方法体中。Java也支持在闭包类中声明非静态内部类(就是我们常说的成员内部类)。他们都叫做内部类。他们在闭包类中定义,也完全能够访问闭包类的实例。由于他们与实例相绑定,一个内部类也许要使用特殊的语法才能被实例化(即必须先实例化外部类,再通过外部类实例化内部类,当然静态内部类不需要这样,这里指的是成员内部类)。
public class EnclosingClass {
/* 定义成员内部类 */
public class InnerClass {
public int incrementAndReturnCounter() {
return counter++;
}
}
private int counter;
{
counter = 0;
}
public int getCounter() {
return counter;
}
public static void main(String[] args) {
EnclosingClass enclosingClassInstance = new EnclosingClass();
/* 通过外部类的实例来实例化内部类 */
EnclosingClass.InnerClass innerClassInstance =
enclosingClassInstance.new InnerClass();
for(int i = enclosingClassInstance.getCounter(); (i =
innerClassInstance.incrementAndReturnCounter()) < 10;) {
System.out.println(i); // 在运行之后,会打印0到9。
}
}
}
从Java8起,Java也将函数作为一等公民。Lambda表达式是一种具体体现,它被当作Function<T, U>类型,其中T是输入,U是输出。表达式能被他的apply方法调用,而不是标准的call方法。
public static void main(String[] args) {
Function<String,Integer> length = s -> s.length(); // 原文这里的length没有括号,明显是错误的
System.out.println( length.apply("Hello, world!") ); // Will print 13.
}
写在结尾
在准备翻译之前,没有想到其中有很多其他概念,通过这些概念,也学习到了很多其他方面的知识,原始概念大多来自于自然科学(以数学为主,这里推荐一篇从20世纪数学危机到图灵机到命令式与FP),且大部分都在在上世纪6,70年代就已经提出。
无论发扬这些理论的先行者,还是正在学习也许会发扬下一个理论的我们,还是最初那些提出这些概念的前辈,我们都确实是在站在巨人的肩膀上。
由于水平和精力有限,也只是对其中一些概念做了简单的介绍,也希望抛砖引玉,欢迎各位补充~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。