Loading…

论起闭包,我想互联网上关于这个知识的具体分析着实是铢积锱累有着相当数量的文章。无他,[ 闭包 ]这个概念对于JavaScript这门语言来说实在是太过于重要,以至于重复说一百遍都不为过,第一篇「谈谈」,我也不免俗的来谈谈闭包。

关于闭包

首先官方ECMA-262给出的定义如下

A closure is a combination of a code block and data of a context in which this code block is created.

然后是MDN Mozilla

Closures are functions that refer to independent (free) variables.

In other words, the function defined in the closure 'remembers' the environment in which it was created.

最后是计算机科学中关于闭包的解释性定义

A closure (also lexical closure or function closure) is a function or reference to a function together with a referencing environment—a table storing a reference to each of the non-local variables (also called free variables or upvalues) of that function.

事实上,由ECMA官方给出的定义虽然精短但却太过于简洁不易理解,

闭包是代码块和创建该代码块的上下文中数据的组合。

而Mozilla和Wiki上关于closure的解释则更加具体,

闭包是一种捕获了环境中自由变量的引用的函数,同时这个自由变量同函数一起存在。

换言之,使用闭包创建的函数可以「记住」它被创建时所处的环境。

closure对于JavaScript意义非凡,但是初学者通常会对起感到困惑,甚至人们把理解闭包作为是否掌握JS的指标之一。
下面则是几个闭包的典型应用:

以jQuery为首的典型的大闭包,以及IIFE的状态依赖

(function(win,undefined){
    //todo
})(window)

作为JavaScript的参数垫片使用

var array=[]  
for(var i=0;i<10;i++){  
    array[i]=function(num){
        return function(){
            return num
        }
    }(i)
}

作为没有类的基于原型的JavaScript的OO模仿的手段

var Class=function(){  
    var 
        varible1=1,
        varible2=2,
        varible3=3

    this.getSum=function(){
        return varible1+varible2+varible3
    }
}

var instance=new Class()  
instance.getSum() //return 6  

Curry化

var add=function(v1){  
    return function(v2){
        return v1+v2
    }
}

var  
    a=add(2),
    b=add(20)

var  
    sum1=a(2),//return 4
    sum2=b(2) //return 22

node.js中的异步回调方案

var  
  fs          = require("fs"),
  convertPath = 'Lambda.txt'

fs.readFile(convertPath,'utf-8',function(err,data)){  
  if(err){
      console.log(err);
  }else{
      console.log("Path is"+convertPath+", Data:"+data);
  }
})//回调(callback as 匿名函数)写多了就容易成为Node中最备受吐槽的"火箭"型代码

闭包的前世今生

这篇文章的标题是closure与lambda,我也不太愿意把闭包这个概念从函数中独立出来单独诠释,虽然我们在日常编程中时常会这样说:

"我们应该在这里写一个闭包"

事实上,在JavaScript中Lambda被得以匿名函数的形式引入,我们也从未能真正的「写出」一个闭包,跃然于IDE之上的从来都是函数。所以我更倾向于把闭包作为函数在创建时的一个可选的选项,或者一种特性来谈。由于函数在JS中一等公民的特性,匿名函数在于JS的世界中几乎是比比皆是,满地可见,而闭包更与匿名函数几如双生并蒂一般成双出现,密切不可分。

正是因为函数与闭包之间如此紧密的关系,闭包的成因之中必定有着「函数」的前世今生。

如同262中得定义

All the ECMAScript program runtime is presented as the execution context (EC) stack, where top of this stack is an active context:

所有的ECMA程序都会有序的运行在一个逻辑上的栈结构中,每运行一个可执行的函数体都会进入一个执行环境(Execution context),而具体的执行环境中会有如下的元素 也就是说,每一个被创建的函数体都会在引擎内部拥有三个隐式的对象。 而这三个对象之中,有一个Scope chain的对象尤为关键,闭包之所以拥有「记住」它被创建时所处的环境能力正是得益于此。 当某一个变量被使用时,函数体会在VO中寻找存储的具体的对象,如果没有找到,则会通过Scope reference以一种单向道的方式向父级寻找,直到global环境为止。因此,闭包之所以可以捕获环境中的自由变量,是因为它拥有一种向上(upvalue)寻找未知变量的特权。

人们通常都是在这样一种场景中第一次使用闭包,并且引以为"坑"。

var array=[]  
for(var i=0;i<10;i++){  
    array[i]=function(){
        return i
    }
}

array数组中保存的每一个函数元素返回的均是10,于是许多人纷纷说JavaScript这门动态语言果然是垃圾,不能按照静态语言的方式编程。
不过让诸君失望的是,无独有偶,在诸如C#等引入了Lambda的静态语言中,这样的情况偏偏也发生了。

    class Program
    {
        static void Main(string[] args)
        {
            List<Func<int>> list = new List<Func<int>>();
            for (int i = 0; i < 3; i++)
            {
                list.Add(() => i);
            }
            foreach (var item in list)
            {
                Console.WriteLine(item()); // always 3...
            }
        }
    }

之所以导致了以上「反直觉」的情况发生,是因为Lambda函数通过闭包的特权去寻找到的——只是自由变量的引用,而不是具体的值。只有当通过array[0]()等方法访问函数时,函数才会最终确定返回的保存的引用的值,而i的引用的值已经在循环过程中变成了10,故此,无论array[0]()还是array[9]()返回的都是循环中i的终值。

所以,我们不得不在代码中额外增加一个闭包作为垫片,以确保函数重新分配内存来规避这种情况。

var array=[]  
for(var i=0;i<10;i++){  
    array[i]=function(num){
        return function(){
            return num
        }
    }(i) //i的引用通过IIFE形式被传递到函数的VO对象中存储
}

全知的上帝在创建世界时候一样用了七天,而BE在创建JS的世界时却也不过方方用了十天,不得不说JS中有太多设计草率并且不够成熟的地方,以至于JS在随xhr重新颠覆浏览器交互之后,无数web developer前仆后继得用各种手段、技巧来抵消、弥补原生JS中不足之处。正如我上文所提,在JS的世界中函数与闭包犹如双生并蒂紧密而不可分割。甚至在一定程度上,在无法使用闭包的情况下,开发者几乎无法实现出简单优雅的代码以呈现出一门可函数式的编程语言最大的美。

闭包千般好,但,闭包毕竟不是没有缺点的。

内存与回收

众所周知,JavaScript是一门拥有垃圾回收(以下简称GC)的语言,在过去的几个版本的浏览器中JS的GC策略主要基于引用计数。那么一旦某个自由变量被闭包函数捕获并且最终使用后,由于变量的始终被函数所引用,导致引用计数总是为N+1,因此闭包一旦生成便会成为常驻内存对象而无法被回收。

并且在当我们不可避免的要面向IE6编程时,闭包的使用更要成为慎中之慎。IE6版本的引擎中曾经有一个内存泄露的Bug,实际上以下这段代码的实现便会使内存产生泄露。

dom元素与闭包的互相引用

function (element,a,b){  
    element.onclick = function(){
        console.log(a+b);
    }
}

代码中element是为一个dom元素,匿名函数的通过闭包访问到parent函数中得变量,并且因此在函数中保存了element的引用,然而element元素本身又引用了匿名函数。循环引用因此得现,最终产生了leak。

幸运的是,随着浏览器技术的改革换新,基于引用计数的GC策略已然过时,不被诸如Java或者JavaScript等VM采用了,而以我们最常见的V8引擎来说,其采用是准确式分代GC。

由于算法的适用性问题,不同生代的内存会由不同的算法进行垃圾回收的实现。 其中新生代部分的对象主要基于Scavenge算法,而老生代的部分则基于Mark-Sweep&Mark-Compact算法。

同时朴灵在《深入浅出Node.js》中作了如下的介绍:

在分代式垃圾回收的前提下,对象在经历过Scavenge回收后,并且由一定条件触发下,存活周期较长的对象会被移动至老生代内存中,也就是完成了对象晋升。

然而,在正常的JavaScript使用中,由于闭包的特殊特性,使得其原始作用域无法被释放从而得不到垃圾回收,并且在满足的条件下,闭包对象会完成上文引用中的对象晋升进入老生代内存,由Mark-Sweep&Mark-Compact策略接管。

为了能更好的写完这篇文章,在关于内存管理的部分,我特地写了E-mail向朴灵请教,朴灵老师在他的回信中告诉我:

Mark-sweep和Mark-compact策略里,判断对象是否存活的机制,主要是通过根扫描来识别对象是否能回收。

假设有如下存在循环引用的对象

var a = {};  
var b = {};  
a.b = b;  
b.a = a;  

所有的变量存储在roots中,对象存放在堆中。假设这里a和b都不被其他变量引用,也就是说a、b两个对象都在堆中,但是roots中却没有变量引用它们(尽管这两个对象互相引用)。

当进行标记时,会遍历整个roots。如果对象被roots中的变量引用,或者被roots中的变量引用的对象所引用,就会被标记为true。这个过程中,a和b两个对象都不会被标记。

那么在清除阶段,没有被标为true的对象就会被释放掉。

闭包与自由变量的引用关系亦如上文中的a、b。

然而在闭包的回收方面,再一次得益于引擎的优化,司徒正美早些年曾经在他的博客中做过一个测试,并且得到结论。

Chakra、V8和SpiderMonkey等引擎会针对不同的变量,判断是否有被使用,该判断方法是扫描返回的嵌套函数的源码来实现的,随后会将没有被嵌套函数使用的变量从Lexical Environment中解除绑定(同样的,SpiderMonkey不解除绑定,仅赋值为undefined),而剩下的变量将继续保留。

综上种种,闭包的存在固然会使得内存的回收变得复杂,但由于大量的Web Developers不断的努力,以至于我们只需了解、理解其中的机制,合理的编写代码,对闭包的特性善加利用,就可以得到兼顾着优雅与高效的程序实现。

未知的优化

我曾经在Belleve的文章中看到过一种,在Scheme、Haskell等函数式语言中广泛应用,名为Lambda Lifting的技术,由编译器对程序代码进行一种相当高级的优化,减少代码中函数的嵌套深度。

编译器智能的对如下代码分析改写。

var addfive = function(n) {  
    var x = 5
    var f = function(y) {
        return x + y
    }
    return f(n)
}

var addfive = function(n) {  
    var x = 5
    var f_2 = function(x_2, y) {
        return x_2 + y
    }
    return f_2(x, n)
}

var f_2 = function(x_2, y) {  
    return x_2 + y
}

var addfive = function(n) {  
    var x = 5
    return f_2(x, n)
}

如上改写过程的优化方法,可以有效的减少嵌套函数、闭包的出现,但一如Belleve说的,可能碍于「动态」的特征,这一优化技巧鲜于出现于动态语言之中。

尾声

在去年方学前端之际,我曾千百次摇摆于闭包的优点与缺点之间,试图在这块平衡木上找到一个理由作为立足点,还因此问过玉伯老师为什么sea.js要使用大闭包的方式来构建。玉伯老师当时的回复是这样的:

还好的,不要有内存泄露就没问题。闭包不一定就内存占用大。

这句话,我自个儿嚼了很久,这个“不一定”,我好像品出了一个意思,也不知道是不是误读:闭包的使用必须是“受控制”的。

当你清晰的了解闭包的机制,当你清楚的明白笔下的函数闭包从哪来,又会往哪去,把它们"流动"的过程一手掌控,就可以尽可能的将它们身后随之而来副作用降至最低,而它们褶褶的光芒最终得以闪耀。

诚然,JavaScript设计并不完善,有出彩,亦有败笔,但借Hax老师和Winter老师特别爱黑得Douglas Crockford一句话,我们依然能够

去其糟粕,取其精华

最后向读者(如果有读者的话....)奉献一个在知乎上看见的跨作用域访问闭包对象的方法,但不推荐使用,因为eval会阻止解释器对函数在作用域变量引用上进行优化,所有变量不进行解绑。

//接口定义
;(function(global){
    var config={x:1,y:2}
    global.main_exports={
        runWith:function(code){
            eval('void '+code+'()');
        }
    }
})(window);

//外部调用
main_exports.runWith(function(){  
    alert(config.x);
}.toString())

(写这么篇文章可真累)

Reference:
Tags

JavaScript , Lambda , closure , 「谈谈」

About the author
comments powered by Disqus