本篇要讲述的是JavaScript的局部作用域,以及与之相对的函数调用对象,还是闭包的使用。

前情回顾

前面讲到,JavaScript有全局作用域和局部作用域之分。做为一门脚步语言,JavsScript要嵌入宿主运行,用于表示宿主的对象,通常就等同于全局对象,比如浏览器中的window对象,或者NodeJS中的global对象。而局部作用域,则是函数运行时产生的临时对象,所以局部作用域也是临时作用域。

前面还讲到,JavaScript里面一切皆属性。不管是定义的变量也好,还是定义的函数也好,都是某个对象的属性。如果我们再代码最上层定义了一个var a = 0,那么全局对象中就多了一个叫做a的属性,其值为0。如果,var a = 0出现在一个函数的定义中,那么这个函数在运行时所产生的临时对象中就多了一个叫做a,值为0的属性。

函数调用对象

我们可以给函数调用时产生的临时对象取一个名字,叫做函数调用对象(execution context)。那么当一个函数被调用时,会生成一个函数调用对象,这个对象的属性集合就是这个函数运行时候的局部作用域。如果当前的函数中调用了另外一个函数,那么就会产生一个嵌套的函数调用对象,如下所示:

 函数调用  /-------------\ 函数调用  /------------\
 -------> | 函数调用对象  | ------> | 函数调用对象  |
          \-------------/         \-------------/

如上图所示,在函数中嵌套调用函数的时候,就会产生一个调用链。每个函数在调用的时候,都可以访问与之对应的函数调用对象的属性集合(局部作用域),以及全局对象的属性集合。那这边存在一个问题,函数在调用的时候,能否回溯调用链,访问先前的函数调用对象呢? 答案是不一定,要分情况考虑。如果是函数的定义是分开的,则不行,比如下面的例子:

function g() {
    console.log("g()")
}

function f() {
    g()
}

以上的例子中,函数调用链是: f() -> g(),但是g()并不能访问f()的函数调用对象的属性集合。

函数定义的嵌套

有一种情况能够使被调用的函数能够访问先前的函数调用对象,那就是把函数定义嵌套到另一函数中:

function f() {
    var a = 1;
    function g() {
        console.log(a)
    }

    g()
}

f() // 输出1

当一个函数嵌套定义在另一个函数中的时候。父函数的作用域对子函数开放。所以,上面的代码中,g()中的语句console.log(a)打印的是父函数的变量a。而且这跟嵌套的层级没有关系,如下面代码所示:

function f() {
    var a = 1;
    function g() {
        function h() {
            console.log(a)
        }
        h()
    }
    g()
}

f() // 输出1

返回嵌套的函数

因为函数体也是一种对象,我们甚至可以返回嵌套的函数体,以便稍后调用,如下所示:

function f() {
    var a = 1;
    function g() {
        console.log(a)
    }

    return g
}

h = f()
h() // 输出 1

当一个函数返回的时候,按道理它的局部作用域应该消失才对,但是被嵌套的函数所使用的那些属性却继续存在,所以上面的h()的运行结果是1,也就是f()的函数调用对象的属性a的值。

对于这种现象,当作用域销毁之后,其某些属性还可以访问,我们叫做闭包(Closure)。可以认为,那些被嵌套的子函数所访问的属性被挪到了一个新的作用域,这个作用域叫做闭包作用域。

只有从函数返回的函数才会有闭包作用域,而闭包作用域可以看作是这些嵌套函数的私密空间,可以用来存储一些私密的信息,比如计数信息:

function f() {
    var counter = 0

    function g() {
        counter += 1
        console.log(counter)
    }

    return g
}

incr = f()
incr() // 输出 1
incr() // 输出 2
incr() // 输出 3

如果不使用闭包,我们则需要把计数器信息保存到别的地方,比如根据How to use static variables in a Javascript function所示的例子,把计数信息保存到函数本身的属性中:

function foo() {

    if( typeof foo.counter == 'undefined' ) {
        foo.counter = 0;
    }
    foo.counter++;
    console.log(foo.counter)
}

foo(); // 输出 1
foo(); // 输出 2
foo(); // 输出 3

上面方法的问题在于,函数本身的属性在函数体之外也可以修改,不够私密,比如:

foo.counter = 100
foo(); // 输出 101

闭包的实现

关于闭包是如何实现的,下面两篇文章提供了一些线索:

一般来说,函数在执行的时候,局部作用域中的变量是存储在栈上(stack)的,当函数调用结束的时候,局部作用域会从栈上弹出,从而消解。但是闭包要求在函数调用结束时依然可以访问局部作用域中的变量,这就要求变量具有更长的生存期。一般的做法是把这些变量放到堆(heap)上。比如下面的代码:

function f() {
    var counter = 0

    function g() {
        counter += 1
        console.log(counter)
    }

    return g
}

JavaScript解释器发现counter这个变量在嵌套函数中被访问,于是乎就把它放在堆上,而不是栈上。当函数f()退出时,在堆上的counter依然可以被访问。那counter会不会永远存在,不会被销毁呢?不用担心,JavaScript自带有垃圾收集功能。在上面的例子中,counterg的闭包中,当没有人访问g了,g会被释放,随之对应的闭包内容也会被释放。

一个更复杂的例子

下面的例子不仅返回了函数,还返回另外一个对象:

function f1() {
        var o = {
                a: 1,
                b: 2,
        };
        var f = function () {
                console.log(o.a)
        }
        return [o, f]
}
ret = f1()
x = ret[0]
y = ret[1]
y() // 输出1
x.a += 1
y() // 输出2

Python中闭包的实现

脚本语言一般都可以嵌套函数定义,一般也都有闭包的概念,但是实现细节上会有所差异。Python最初的实现,父函数局部作用域中的变量对于嵌套函数是只读的:

def f1():
  a = 1
  def x(): a += 1; print(a)
  def y(): print(a)
  return (x,y)

x, y = f1()
x() # 出现错误 UnboundLocalError: local variable 'a' referenced before assignment
y() # 输出 1

Python3中引入了一个关键字nonlocal,可以指明局部变量对于嵌套函数并非是只读的:

def f1():
  a = 1
  def x(): nonlocal a; a += 1; print(a)
  def y(): print(a)
  return (x,y)

x, y = f1()
x() # 输出 2
y() # 输出 2

参考链接