重新认识 JS 中的闭包

759 阅读6分钟

本文在个人站点同步:ethanlv.cn/article/19

我只是个知识的搬运工~

浅约束与深约束

作用域确定了程序里一个语句的引用环境,语言的作用域规则可以大致分为两类,静态作用域规则和动态作用域规则。现代的语言大多是静态作用域的,当然也少有语言是绝对的静态作用域,这就和少有语言是存粹的面向对象式或少有语言是纯粹的解释式一样,语言的边界本就是模糊的。

关于静态作用域和动态作用域我相信大家都很清楚了,不多做解释。

  • 静态作用域规则 也称为词法作用域,是指引用环境依赖于可以出现名字声明的程序块的词法嵌套关系。

  • 动态作用域规则 是指引用环境依赖于运行时遇到各种声明的顺序。

但是,上面的规则没有考虑到一个特殊的场景:在那些允许创建子程序引用,例如把 子程序当作参数传递的语言里,何时把作用域规则应用于这种子程序?是在创建这种引用时,还是在子程序被调用时

对于动态作用域来说,这一问题就显得格外重要,当然静态作用域也是需要考虑到的。

要讲清楚这个问题,首先,让我们来把 JS 当作一门动态作用域的语言

现在,我们有个函数 isErCiYuan,它接收两个参数,第一个参数是人物特征,第二个参数是一个子程序参数,我们期望它接收一个打印函数 print,print依据 comment 值的不同而输出不同的结果。自然的,我们会在 isErCiYuan 这个函数中建立一个临时变量 comment 依据函数的第一个参数而为 comment 赋不同的值。

// 当然这是段无法运行的代码
function print(){
    console.log(`我${comment}二次元`)
}
function isErCiYuan(characteristic,print){
    let {gender,hobby} = characteristic
    let comment = ''
    //1 -> boy,0-> girl
    if(gender === 1 && hobby === '喜欢穿女装'){
        comment = '是'
    }else{
        comment = '不是'
    }
    print()
}

为了让上面的代码以我们期待的方式正常工作,理所当然的我们要在 print 函数被实际调用时再去建立变量 comment 的引用关系。

而这种让作为参数传递的子程序推迟建立引用环境约束的方式称为 浅约束,通常情况下,采用动态作用域规则的语言都将这种约束方式作为默认方式,与之对应的当然就是深约束了,即 在子程序作为参数传递时就做好环境约束。同样拿上面那段代码来考虑,comment 此时就应该是空字符串了。

你没有猜错,静态作用域规则的语言采用的方式基本都是深约束

等等,为什么静态作用域还需要考虑这一问题,我们知道,在静态作用域规则下,名字的意义本来就依赖于其词法嵌套关系/位置,而不是实际的执行流呀。

看下面这段代码:

function A(num,fn){
    function B(){
        console.log(num)
    }
    if(num > 0){
        fn()
    }else{
        A(1,B)
    }
}
function doNothing(){}
A(0,doNothing) 

在上面这种情况下,我们看到函数 A 递归执行了,这就导致 num 实际上是存在多个实例的,那么最终输出的到底是 0,还是 1 呢。

如果是 0,那就是深约束,因为它在子程序 B 作为参数传递时就抓住了当前实例,这一行为没有被推迟,此时 Num 是 0。JS 中打印出来的的结果就是 0,你也可以自己试下。

如何实现深约束——闭包

要想实现深约束,就需要创建一种能显式地表达引用环境的东西。我们一般将某个函数(一般是入口函数)以及这种相关联的引用环境(理解为一个符号表)称作闭包。闭包的特点是它捕获了自由变量(在函数外部定义但在函数内被引用)。这一行为可以用于解释我们常说的 “闭包解决了父函数执行后上下文销毁导致子函数不能获取到父函数中变量的问题。”

现在,我们给上面的代码加点东西,直观的看下闭包是什么:

function A(num,fn){
    function B(){
        console.log(num)
    }
    if(num > 0){
        fn()
    }else{
        A(1,B)
        console.log(B.prototype) //加在这了
    }
}
function doNothing(){}
A(0,doNothing) 

B.prototype.constructor 下的 [[Scopes]] 中有一个 Closure(A),里面保存着变量 num,其值为 0。

上面的这种闭包不太容易看出来,我们来看个更普遍的例子。

function fn1(){
    let a = 0;
    function fn2(){
        let b = 1;
        return function fn3(){
            console.log(a,b)
        }
    }
    return fn2()
}
let fn4 = fn1()

打印出 fn4 的 prototype 看看:

可以看到这里存在两个闭包,以由内到外的顺序排列,看到这,是不是作用域链的概念也更清晰了呢。

最后,彼得·兰丁(Peter Landin)在1964年将术语“闭包”定义为一种包含环境成分和控制成分的实体,而闭包的概念首次在1970年于 PAL 编程语言中完全实现,用来支持词法作用域的 头等函数,也就是前文所阐述的当子函数能作为参数传递时如何实现深约束的问题。我们可以将闭包简单理解为捕获了特定自由变量的函数,借助闭包的特点,我们可以实现私有变量的持久性和信息隐藏,这在很多情况下非常有用,闭包也因此而广为流传。

JS 中闭包的实现

function fn1(){
    let a = 0;
    function fn2(){
        let b = 1;
        return function fn3(){
            console.log(a,b)
        }
    }
    return fn2()
}
let fn4 = fn1()

闭包的实现在思路上是比较简单的,以上面的代码为例, JS 引擎在预编译阶段通过对 fn2 内部函数的词法扫描,找出是否存在内部函数引用外部函数变量的情况,如果存在就打入对应的 Closure,最后放到 [[Scopes]] 中。要注意的是,这个过程是静态分析的,那 eval 怎么办呢。

function test(){
    const a = 1;
    const b = 2;
    return function(){
        const c = 3
        eval('console.log(a,c)')
    }
}

对于上面这段代码

你会发现,eval 把变量都包进去了,即使是实际上并没有使用的。这种降级策略也许就是 eval 执行效率低的原因之一吧,而 如果使用 new Function,因为其语法让我们得以显式的指定变量名,自然就可以在静态分析时保证不打包多余变量到 Closure 中。

function test(){
    const a = 1;
    const b = 2;
    return function(){
        const c = 3
        new Function(a,'console.log(a,c)')
    }
}

参考