1、浅谈闭包

要掌握 JavaScript,闭包是一个必须理解的概念。

1.1、闭包的定义

我查阅了维基百科和一些技术博客, 闭包(closure)的定义有两种说法:

  1. (可以访问函数体以外定义的自由变量)的函数
  2. (可以访问函数体以外定义的自由变量)的函数及其可以访问的自由变量组成的集合。

1.2、自由变量

上面闭包的定义中说到的“自由变量”是与“全局变量”“函数参数”和“局部变量”相对比而言的。

全局变量可以看作是所有函数都能访问的变量。函数参数则是指函数的输入参数。局部变量按字面意思理解就是作用域局限在被定义的函数中的变量。函数参数和局部变量都是在函数定义中被定义的。一般我们提到函数参数和局部变量的时候,都只在其被定义的函数中说的,或者说一般只有在定义它们的函数使用它们的时候,我们才会称它们为参数和局部变量。

例如,有一个全局变量 a,有一个函数 x,x 有一个输入参数 b 和一个局部变量 c,还有一个函数 y。在函数 x 和函数 y 中都能访问全局变量 a,函数 x 中能使用参数 b 和局部变量 c,但函数 y 通常与 b 和 c 就没有关系了。(没有闭包而且不能通过引用间接访问的话,函数 y 一定不能访问 b 和 c 的。)

自由变量则是在一个函数内被定义,但可以被其他函数访问的一种变量。也就是说,一般我们说到自由变量的时候,就隐含了“一个函数访问它以外的函数中定义的变量”这个情形。

在上述的例子中,如果函数 y 能访问 b 或者 c,那么 b / c 对于函数 y 而言就是自由变量。

1.3、JavaScript 中的闭包

1.3.1、JavaScript 中变量的作用域

一个变量的作用域是指能够使用该变量的范围。

在 JavaScript 中,不在任何函数中定义的变量是全局变量。全局变量的作用域在此不再赘述。除了全局变量以外的变量的作用域,包括函数参数,首先都被局限在定义它的函数内。

在实现 ECMAScript 版本 6 (不含)以前的 JavaScript,定义局部变量必须使用 var 关键字或者 function 关键字(函数作为局部变量),例如:

function x(a, b) {
    var c = 1; // c是函数x的一个局部变量
    function y(d, e) { // y是函数x的一个局部变量
        console.log(d + e);
    }
    y(a + b, c);
}

根据变量提升规则,JavaScript 解释器在遇到使用 var 关键字或者 function 关键字定义的局部变量,会将其视为在函数的最开头定义的。因此下面的两段代码是等价的:

function x(a, b) {
    y(a + b, c);
    if (a > b) {
        var c = 1;
        function y(d, e) {
            console.log(d + e);
        }
    }
}
function x(a, b) {
    var c; // 此时c为undefined
    function y(d, e) { // c和y的定义会被提升到函数体的最开头
        console.log(d + e);
    }
    y(a + b, c);
    if (a > b) {
        c = 1;
    }
}

由于变量提升规则允许在声明某个变量之前就使用它,可能会导致一些不符合直观的执行结果,所以在 ECMAScript 6 规范中,加入了 letconst 关键字用来定义块级作用域的变量。块级作用域的变量的作用域是定义变量的方括号内,而且没有变量提升的规则,因此块级作用域的变量都必须先定义后使用。

块级作用域的变量的例子:

function x(a, b) {
    return a + b + c; // 会报ReferenceException:c未被定义
    if (a > b) {
        let c = 1; // c的作用域被局限在这个if语句的大括号内
    }
}
function x(a, b) {
    if (a > b) {
        return a + b + c; // 会报ReferenceException:不能在c初始化前访问c
        let c = 1; // 在c的定义之前不能使用c
    }
    return a + b - c;
}

1.3.2、JavaScript 中的闭包

在一个变量的作用域内定义的函数的函数体内也可以访问该变量,或者说在一个变量的作用域内定义的函数的函数体也属于该变量的作用域,又或者说一个变量是在其作用域内定义的函数的自由变量。

还是举一个例子:

function x(a) {
    let b = 1 - a;
    if (a > b) {
        return function y(c) {
            return a - b + c; // 函数y内也可以访问x函数定义里的参数a和局部变量b
        };
    }
    return a + b + c;
}
function z() {
    // 不能访问x函数的参数a和局部变量b,因为这里已经超出了它们的作用域
}

下面再以一个常见面试题来举例说明闭包:

function x() {
    const result = [];
    let i;
    for (i = 0; i < 5; i++) {
        result[i] = function y() { // 变量i与函数y组成一个闭包
            console.log(i); // 变量i是函数y的自由变量
        };
    }
    return result;
}
const functions = x();
for (let j = 0; j < functions.length; j++) (functions[j])();

这里会输出 5 个 5,而不是 0、1、2、3、4,因为 functions 中的 5 个函数中使用的 i 其实是同一个变量,而且与函数 x 执行时 for 循环中的变量 i 是同一个变量。在函数 x 中跳出 for 循环后到执行 (functions[i])() 时,变量 i 就已经变成了 5。

最后留一个思考题,以下代码会输出什么呢?为什么会这样输出呢?

function x() {
    const result = [];
    for (let i = 0; i < 5; i++) {
        result[i] = function y() { // 变量i与函数y组成一个闭包
            console.log(i); // 变量i是函数y的自由变量
        };
    }
    return result;
}
const functions = x();
for (let j = 0; j < functions.length; j++) (functions[j])();

2、重新审视函数

在上文中,我提到闭包有两种定义,一种认为闭包是函数,另一种认为闭包是函数与自由变量的集合。我个人比较认同后一种定义。

我们经常谈论函数闭包,但是却几乎没思考过函数是什么(函数的定义)。对于只学习过 JavaScript 而没有学习过 C 语言的人来说,他们可能不理解函数与闭包的区别,因为在一个变量的作用域内定义的函数都能访问该变量,函数能访问其定义以外的变量看起来是自然而然的事情。很多人也不知道底层的 JavaScript 解释器是如何实现闭包的,不知道实现闭包其实比没有闭包、只有全局变量、函数参数和局部变量要复杂一些。

首先我们来看看编程语言中的函数是如何应运而生的。

2.1、图灵机与“古代”的编程

伟大的数学家、密码学家阿兰·图灵提出了图灵机这种计算机模型。图灵机的原始版本是:一台机器有一个读写头和可以存储有限状态的内部存储器,读写头可以移动和读写一条纸带,纸带上只能存储一个一个的二进制位,机器根据读到的纸带上的信息以及内部状态来决定如何移动或者写纸带。机器内用于存储状态的内部存储器通常叫做寄存器(register)

image

不久人们就按照这个模型制造了真正的计算机。是的,最早的计算机读写的不是磁盘或者SSD,而是纸带! 但是这个时候计算机能做的计算的种类和过程是固定的,例如只能从纸带上读入两个数然后在纸带上输出两个数的乘积,而不能动态地改变计算机执行的指令(编程)。也就是说这时候纸带的输入数据仅仅是被看作要处理的数据而不是可执行的代码

2.1.1、通用图灵机与可编程计算机

通用图灵机是指可以实现任意一个图灵机的功能的图灵机。有了通用图灵机,我们就可以用一台计算机来实现多种多样的计算功能。怎样实现一台通用图灵机呢?让计算机可编程就行了。可编程计算机不仅可将纸带上的信息看作要处理的数据,还能将其看作代码来执行。

纸带上的可执行代码就是我们常说的机器语言。机器语言的执行单位是指令,也就是说可编程计算机每次从纸带上读取一条指令来执行。一个程序的可执行代码就是由若干条指令构成。

2.1.2、指令集

接下来就产生了一个问题:纸带上的指令应该怎么设计呢?如果设计得太复杂,一来一条指令占用的纸带就会太长,太耗纸带;二来执行指令的计算机也要设计得很复杂,制造成本太高。

一台可编程计算机的指令的设计方式就是这台计算机的指令集

经过计算机科学家和工程师的研究和设计,直到今天各种处理器的指令集相对于高级编程语言(如 C++、Java、JavaScript、Python 等)来说还是很简单的,基本上只有如下几类:

  1. 从输入 / 存储设备读取一个数(通常是 1 / 2 / 4 / 8 个字节的)到某个寄存器中;
  2. 对某个寄存器中的数作一元运算,如按位取反、变换正负号;
  3. 将某个寄存器 a 中的数与另一个寄存器 b 中的数做运算(加减乘除、按位与、按位或等),结果存回寄存器 a;
  4. 将某个寄存器中的数写到输出 / 存储设备中;
  5. 跳转执行另外一个地方的代码;
  6. 根据某个寄存器中的值是否为 0,来决定是否跳转执行另一个地方的代码

大家可以看到,上面没有可以直接实现函数的指令,甚至没有可以直接实现循环结构的指令,只能实现顺序执行以及简单的条件结构。

未完待续。