从JS垃圾回收机制和词源来透视闭包

808 阅读15分钟
原文链接: mp.weixin.qq.com

前言

今日早读文章来自海致前端@蔡剑涛投稿分享。

正文从这开始~

一:前言

说起闭包,可谓是老生常谈。虽说大家都在谈,面试官也喜欢问,然而我却感觉,鲜有能把这个概念说清楚的,网上很多关于闭包的介绍,包括一些官方性较强的网址如MDN,也是只说其表面,不说其里子。

我曾经也是个闭包初学者,也曾在理解这个概念时苦苦煎熬。所幸在众多的网上资源中,梳理出了两条自认为可以把闭包说清的主线。所以在本期,我想分享一下这两条主线,希望对各位同学理解闭包能够有所帮助。

二:闭包的定义

在谈我个人对闭包的理解之前,为了避免误导大家,我们首先来回顾一下“官方”对闭包的定义。我从一些较权威的网站摘抄了一些关于闭包的定义,具体如下:

2.1. MDN

A closure is the combination of a function and the lexical environment within which that function was declared.

2.2 维基百科-闭包 (计算机科学)

在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。

2.3. 百度百科—闭包

“闭包” 一词来源于以下两者的结合:要执行的代码块(由于自由变量被包含在代码块中,这些自由变量以及它们引用的对象没有被释放)和为自由变量提供绑定的计算环境(作用域)。

2.4. 百度百科-Javascript闭包

官方对闭包的解释是:一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。

上述四个定义,虽然表述不一,但大都强调了闭包通常是一个函数,它是函数自身和变量环境二者的结合体,它内部始终绑定着一些变量环境。那清楚了这几点,你是否理解了闭包为何物?在我看来,想要真正理解闭包,仅凭这四个定义提供的信息是远远不够的,接下来我将通过JS垃圾回收机制和闭包词源这两条主线,来谈一下我对闭包的理解。

三:先从JS垃圾回收机制来透视闭包

在正式开讲前,我先介绍一些前置知识:两个过程(函数解析和函数执行过程)和两个机制(JS变量查找机制和JS垃圾回收机制):

3.1. 函数解析过程

函数对象的创建

当一个函数被解析时(全局作用域也可以看成是一个函数),会相应地创建一个函数对象。

该函数对象有个scope属性,这个scope属性维护着一条作用域链(scope chain),作用域链上的每个节点,都是一个活动对象(activation object, 下面会介绍)。函数对象的scope属性的初始值,赋值自当前函数对象的父级作用域的执行上下文对象(execution context, 下面会介绍)的scope属性值。

3.2. 函数的执行过程

函数的活动对象的创建

当一个函数对象被执行时,会为该函数对象创建一个活动对象(activation object)。这个活动对象主要包含当前函数运行所需要的环境,具体内容有:

  • 形式参量

  • 函数声明

  • 传入参数

  • 局部变量

函数add(num1, num2)的活动对象结构体

特别地,在web浏览器,JS的全局函数作用域(可以看成是一个函数),其对应的活动对象为window对象,且window对象始终是作用域链(scope chain)的最后一个结点,亦被称为Global Object。

函数的执行上下文对象的创建

当一个函数对象被执行时,会为该函数对象创建一个执行上下文对象(execution context)。执行上下文对象也有一个scope属性,其初始值是该函数对象的scope属性;在执行上下文对象的scope属性初始化完毕后,接着会把该函数对象执行时创建的活动对象,采用头插法,插入到执行上下文对象scope属性维护的作用域链。正因为维护着这条作用域链,这个执行上下文对象,包含着当前函数作用域运行所需要的所有环境。

函数的执行上下文对象结构体

特别地,在web浏览器,JS全局作用域的执行上下文对象,其作用域链上仅有一个活动对象结点,即window对象。

3.3. JS变量查找机制

一个函数在执行时,当要读取某个变量,JS变量查找机制,会从当前函数对应的执行上下文对象的作用域链的头结点(索引下标为0)开始搜索,如果找到匹配的变量,则返回其值;如果未找到匹配的变量,则继续搜索scope chain中的下一个活动对象结点,直到找到为止;如果搜索完执行上下文对象的整条scope chain仍未找到匹配变量,则返回undefined。

函数add(num1, num2)的活动对象结构体

3.4. JS垃圾回收机制

虽然说编程语言的垃圾回收机制比较复杂,但是不要被吓到,在本文中,仅需了解一下引用计数法这个简单的垃圾回收实现算法就足矣:如果一个对象没有被任何变量引用,即该对象的引用计数为0,那么该对象就会被回收,反之不会。特别地,在JS中,概念中的对象包括函数对象。

特别说明:实际上,对于浏览器,从2012年起,所有现代浏览器的垃圾回收算法都使用了标记-清除法 ,而不是引用计数法;对于Javascript,JavaScript 1.1(实现在Netscape 3)的垃圾回收算法使用了引用计数法,以后的Javascript版本均使用标记-清除法。

在这里只讲引用计数法,而不具体讲标记-清除法,一是因为要理解闭包,重点在于知道垃圾回收机制这个概念,而不在于知道垃圾回收机制的具体实现;二是因为引用计数法相对于标记-清除法更加简单,更能让各位直观理解垃圾回收机制这个概念;三是因为要想把标记-清除法讲清楚,需要更大的篇幅,而实际上它并不是本文的重点,所以点到为止即可,后面有机会的话,会单独拿一期来讲JS垃圾回收机制及其具体实现。

3.5. 回到主题

了解完上述前置知识,我们就可以回到本章节的主题——从JS垃圾回收机制来透视闭包:

通过一个例子来解释:

demo.html:

demo.html

输出结果:

输出结果

结果分析:

示例代码的输出结果为2。在执行最后一行代码console.log(closureRef())时,虽然外包函数closureWrapper()已执行完毕,但是其局部变量val并没有被回收!!!仍然可被闭包函数closureRef()所访问和修改: +1变为2。

那为什么不会被回收呢?我们可以从JS垃圾回收机制来回到这个问题:

在外包函数closureWrapper()被调用时,匿名函数对象作为外包函数closureWrapper()的返回值,被外包函数closureWrapper()的父级函数作用域(对应script下的顶级函数作用域)的变量closureRef所引用,这样一来,内嵌的匿名函数对象不会被回收,此时闭包生成(对应函数closureRef)。正因为这个闭包函数对象不被回收,所以其内部scope属性维护的作用域链也不会被回收,也意味着这条作用域链上的所有活动对象都得到了保留。而这条作用域链上的头节点,正是其外包函数closureWrapper()对应的活动对象(该活动对象包含了val变量及其值)。那么在闭包函数closureRef()被执行时,函数对象的作用域链会拷贝给其对应的执行上下文对象,也就是说闭包函数可以在执行上下文对象的作用域链上找到其外包函数对应的活动对象,并从该活动对象中找到val变量。即:

内嵌函数对象不被回收(被外包函数的父级或更高级作用域变量引用,闭包生成)=》内嵌函数对象的作用域链不被回收=》作用域链上的活动对象结点不被回收=》内嵌函数对象的父级作用域及更高层作用域对应的上下文数据环境均得到保留。

所以就出现了我们常描述的闭包表象:外包函数closureWrapper()在执行完毕后,其局部变量val并没有被回收,而是被保留在闭包函数内,当在外包函数的父级作用域或更高级作用域调用闭包函数时,可通过这个闭包函数来访问和修改外包函数closureWrapper ()的局部变量。

四:再从闭包词源透视闭包

4.1. 闭包词源

闭包(closure)是一个舶来词,该词的使用最早见于14世纪。该词也是一个数学术语,而作为计算机术语,则是在1964年。

在1964年,Peter J. Landin 在它的SECD机器的计算表达式中,使用了一种特殊的lambda表达式(可理解为匿名函数):该lambda表达式包含两部分,一是自由变量,二是控制代码,Peter J. Landin把这种绑定了自由变量的lambda表达式命名为闭包。而1975年编写的Scheme语言中,亦采纳了这个闭包术语,从此该术语被广泛应用于计算机编程语言。

为了让各位更好的理解上面一段话,补充两个概念的定义:

lambda表达式

可理解为匿名函数,c++和python中的匿名函数称为lambda表达式。

自由变量

在一个函数的外部声明却在该函数内部使用的变量,称为该函数的自由变量,这些自由变量的值,可以是基本类型,也可以是引用类型。

4.2. 闭包函数与纯函数的区别

我们通常理解的函数(纯函数, plain function),它往往是用来处理指定的输入数据,然后返回处理后的输出结果, 这种纯函数一般是把具体的处理数据抽象为一个变量,是和具体数据解耦的。但是有一种特殊的函数(如柯里化后的函数,后面会介绍),它是和具体数据耦合在一起的,函数如果没有被回收,这些具体数据则会一直被绑定在函数内。像这种绑定了具体数据的特殊函数,其实就是Peter J. Landin定义的闭包。

4.3. 回到主题

所以,从闭包的词源和与纯函数的区别,我们不难得出以下结论:

  1. 闭包是一个函数;

  2. 闭包是一种特殊的函数,区别于纯函数,除了有函数自身的逻辑控制成分之外,它还维护(绑定)了一些自由变量(或者说是包含自由变量的环境),使其在闭包函数的生命周期内不会被回收。

在了解完闭包的词源和与纯函数的区别,现在再来理解一些官方对闭包的定义,可能就没有那么突兀了。

百度百科-Javascript闭包

官方定义:所谓“闭包”,指的是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。

五:闭包的实现

当一个内部包含自由变量的函数对象,其引用计数不为0时,那么意味着这个函数对象不会被回收,同时也意味着闭包的生成。所以实现了闭包的语言,大多数都是基于垃圾回收机制。

六:闭包的作用及应用

闭包的作用,除了有控制成分(逻辑处理)之外,还绑定了自由变量,维持着一些数据(或包含数据的环境)不被回收。这也是闭包的最典型特性。与其同时,因为这些自由变量被维护在闭包函数内,外部环境不可直接访问,必须通过闭包函数来访问,所以闭包函数也能给这些自由变量提供一定的私有性和访问控制,防止被外部轻易篡改。其典型应用有:

函数的柯里化(Currying)

函数的柯里化,是指将本来接收多个参数的函数,变换为接收更少参数的函数,其中减少的参数,被设为了固定值。柯里化函数的使用,可以在函数被多次调用时,用来减少相同参数的重复输入, 相当于是对原函数进行了分步传值,当且仅当函数所需的所有参数都传入了对应值时,函数才会返回最终的处理结果,否则只会返回新的函数。

下面举个函数柯里化的实现例子:

函数Currying.html

函数Currying

输出结果:

结果分析:

函数doSomething(_do, something),其一开始接收两个参数,先对其进行Currying(先给_do参数传’hello’值),得到一个新的函数newDoSomething(something),此时’hello’值作为一个数据(自由变量),被绑定在newDoSomething(something)这个函数。newDoSomething()可以看成是一个闭包函数:当要执行跟某人说hello这一特定类型的操作时,就可以直接使用newDoSomething(something),来减少了相同参数‘hello’的重复输入。由此可见,在一些应用场景,通过闭包函数来绑定一些固定数据,确实能够给我们带来不少便利。

JS单例模式的实现

JS单例模式的实现.html

JS单例模式的实现

输出结果:

结果分析:

从控制台的输出结果,可知我们利用闭包函数成功实现了一个饿汉式(非延迟实例)的JS单例模式。

在代码中,我们定义了一个立即函数,在立即函数内部定义了一个instance变量,其值是China构造函数的实例对象;在匿名函数内部,使用了instance变量,所以instance可以看作是匿名函数的自由变量;立即函数执行完毕时,其返回值匿名函数会被立即函数所在的作用域的getSingleInstance变量所引用,则意味着闭包函数的创建成功。此时,虽然立即函数已经执行完毕,但是其定义的内部变量instance(绑定在了闭包函数的作用域链上),因为闭包函数对象不被回收而得到了保留。又因为instance变量是定义在立即函数内部的私有变量,所以仅能通过闭包函数访问之,而无其它访问入口。闭包函数这种既能保留变量又能给变量提供访问控制的特性,应用到单例模式的实现,可谓是再适合不过。

七:闭包的缺点

我们可以借助闭包来绑定数据变量,使这些数据变量的内存块在闭包存活时,始终不被垃圾回收机制回收。但如果对闭包使用不当,极可能引发内存泄露。

八:思考题

最后留两道思考题,也是比较经典的闭包笔试题,希望感兴趣的同学,在看完上述理论之后,可以利用上述理论,跟别人解释清楚为什么会有这样的输出结果。

1.html

html

2.html

九:结尾语

本文分别从JS垃圾回收机制和词源这两条主线,来讲解闭包,并简单介绍了一些理解闭包的前置知识(函数的解析和执行过程、JS变量查找机制和垃圾回收机制),于此同时,列举了两个闭包的经典应用来加深各位对闭包特性的理解,不仅希望各位同学能够理解闭包,也希望各位同学能会用、用好闭包。由于本人水平和经验有限,如有纰漏或建议,欢迎私信。如果觉得不错,欢迎关注海致星图,谢谢您的阅读。

示例代码地址:

https://github.com/momopig/simplicity/tree/master/04:closure

最后,为你推荐

【第591期】4类 JavaScript 内存泄露及如何避免

【第528期】了解 JavaScript 应用程序中的内存泄漏

关于本文

作者:@蔡剑涛原文:https://mp.weixin.qq.com/s/485GgpEt2c7uS-mY1cbA3w