闭包-1(Closure in JS)

1. 从外部看闭包

闭包这个东西起源与函数式编程。Lisp,Haskell 等有一个特点,就是把所有的语法作尽量少的规定。把看似不太相关的东西解释成同一个东西。一个不太恰当的例子是,字符串本质上也是数组,字符串是字符组成的数组。所以在js里面数组和字符串处理的函数十分的类似。近几年发明的语言都是把字符串看作数组的,当年分开是为了把字符串当作特殊的数组便于编译器优化提高效率,随着计算机处理速度的发展,这种区分显得没有那么重要了。

这里面有一个重要的思想就是函数其实和一般的数据类型没有本质上的不同。所以一个函数就可以的返回值也可以是一个函数,也可以用函数作为参数。到此,这个和C语言里面函数的参数/返回值可以是一个函数指针还看不出区别。但是为了进一步统一(或者抽象)函数,又加上一条,函数只接受一个参数。多个参数的函数,比如两个参数的函数,其实也是接受一个参数的函数,只是它的返回值是另外一个函数! 比如加法add(2)(5) ,其实 add 只接收一个参数,然后返回了一个函数2+,再以参数 5 调用这个2+ 函数,那个这个2+函数就返回一个7。定义一个两个参数的函数就是:(不合法的js代码)

1
2
3
4
function add(a)(b)
{
    return a+b;
}

把上面那段代码修改为合法的js代码就是

1
2
3
4
5
6
7
8
9
function add(a) {
   return function (b) {
      return a+b;
   }
}
add(2)(5);
// 或者说
var add2 = add(2);
add2(5);

这样add2 = add(2),形参a(局部变量)被赋值为2。根据上面的规则的要求,add2(5)的时候能够获取这个局部变量a的值,这个现象叫做闭包。

假设我们再定义一个两个参数的函数求 2*a+b,使用的方式是doubleadd(a)(b):

1
2
3
4
5
6
7
8
// function doubleadd(a)(b) {var c=2*a; return c + b; }
// 如果能这样写该多好!
function doubleAdd(a) {
   var c = 2*a;
   return function (b) {
      return c+b;
   }
}

同样 add22 = doubleAdd(2) 然后调用add22(5)的时候必须能够读取局部变量c的值。现在我们看看闭包到底是什么?简单来说,闭包里面保存了函数执行一半时候的状态 。比如doubleAdd 其实是计算 2*a+b 的,但是如果我们使用doubledAdd(2) 这样函数只会先计算 2*a 保存到c里面,然后下次给出参数 b 的时候再计算最终的结果。 这样做的其中一个优势就是,比如要计算doubleAdd(2)(5),doubleAdd(2)(6) ,如果我们使用add22 = doubleAdd(2); add22(5);add22(6) 这样就不用重复计算 2*2 了。

在函数式编程里面,函数调用可以不必给出这个函数所需的所有参数,可以只给出部分参数,这个时候函数可以选择先处理这一部分参数,等给出其他参数的时候在继续执行给出最终的结果。无论如何,在只给出部分参数的时候函数要么需要保存这部分的参数,要么保存执行到一半之后的状态。因此从外部看来,所谓的闭包就是执行了一半的函数,闭包里面保存了这个执行到的状态,当给出其他参数的时候再继续从这个状态执行下去。无论一个函数返回了一个内部函数,还是将内部函数绑定为某个DOM的事件处理函数(后者可看做把这个值返回给一个特殊的全局变量),都是一个执行了一半的函数,等函数调用或是触发事件之后再执行另一半。

2. 闭包的内部:读懂闭包

改写一下上面的函数:

1
2
3
4
5
6
7
8
function doubleAdd(a) {
   var c = 2*a;
   function inner(b)
   {
      return c+b;
   }
   return inner;
}

这样写就可以看出所谓的 “内部的函数可以引用外部的函数的变量”:inner函数可以使用外部函数的局部变量c

继续看上面的代码,add22 = doubleadd(2);add23 = doubleadd(3);中,本身给出的第一个参数就不一样。add22需要保存doubleadd使用参数 2 调用时执行到一半的状态。add23需要保存doubleadd 使用参数 3 调用执行到一半的状态。这是两个不同的状态,所以需要两个无关的变量保存这两个状态,就是说add22add23中使用的c要求是两个不同的变量。那么我们来看看闭包是如何完成这种要求的。作用域方便的解释了如何实现上面的要求。因此我们从作用域的角度来看一下如何读懂有闭包的代码。这东西有点儿复杂,需要勤加练习。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function doubleAdd(a) {
   var c = 2*a;
   function inner(b)
   {
      return c+b;
   }
   return inner;
}
function fn(){ var d;}

fn();
fn();
var add22 = doubleadd(2);
add22(5);
var add23 = doubleadd(3);
add23(5);

函数在调用的时候才会生成一级新的作用域,而不是函数定义的时候。新创建的作用域就是为这次函数调用而存在的,被调用的函数中的函数声明和变量创建都是在这个新创建的作用域中。一个作用域的上一级作用域是谁主要看这个函数是在哪个作用域创建的。比如上面的代码,初始情况下,代码只定义了函数doubleadd fn,他们是在全局定义的。首先,明确的是inner还是不存的的。function doubleadd(){}只是定义了doubleadd,其中的代码是xxx,程序还没有执行function inner(){} 这一句。不信的话,可以试试在 doubleadd中加一句有语法错误的代码,看看解释器什么时候报错。

先看一个简单的,没有闭包的,当执行函数fn 的时候,会生成一个新的作用域,作为本次fn的执行环境,由于fn是在全局定义的,所以这个新的作用域里面定义的的上一级作用域就是全局作用域,在这个作用域中定义了一个变量d。当再次执行函数fn 的时候,又会生成一个新的作用域,他的上一级还是全局作用域(因为fn是在全局定义的),并且又会在新的作用域创建变量d(和上一个d只是重名而已,这是两个不同的变量)。

同样,第一次调用doubleadd执行的时候会生成一个作用域(假设叫做doubleadd-2),作为这次执行这个函数的执行环境,在这个作用域上定义了一个函数function inner(){},变量ac(c=2*2)。当执行 add22的时候也会生成一个新的作用域(doubleadd-25),作为执行环境,由于add22 是在doubleadd-2中定义的inner,所以其上一级作用域就是doubleadd-2。当第二次执行doubleadd的时候,又会生成一个新的作用域(doubleadd-3),其上有定义了inner,acc=2*3,与第一次生成的无关)。然后执行add23的时候之后形成一个新的作用域(doubleadd-35),其上一级是doubleadd-3(自行思考为什么)。变量的查找规则是首先在当前作用域查找该变量,如果没有就递归的去上一级作用域去查找。当add22使用c的时候,在本作用域中没有c,所以查找其上一级作用域 doubleadd-2,于是add22中使用的c就是在第一次执行doubleadd的时候定义的那个c=2*2。同样add23中使用的cdoubleadd-3中的c=2*3。他们是两个不同的变量,就是这样add22add23中可以分别表示一个各自的状态。如图所示,doubleadd-25 标识为为第一次执行add22创建,这是因为每一次函数调用都会生成新的一级作用域,如果在次调用add22,那么他使用的就不再是这个作用域了。就像doubleadd执行了两次,为其每一次执行都创建了新的作用域一样。

好复杂的逻辑,但是有简单的说法。上面的逻辑仅仅在代码上来看就是:作用域的包含关系,主要看函数定义所在的位置,而不是函数调用的位置,所以如果查找一个函数中的变量查找顺序主要是看{}(特指函数定义中的{},ES5.1里不存在块作用域)。首先看,本{}中有没有这个变量定义,没有的话就递归的去找上一级的{}中查找,一直找到全部代码(全局)中有没有定义。仔细体会一下这个中方式,闭包的代码对你来说轻而易举。

add22,add23两个函数保存了两个执行状态,浪费了内存。但是add22(5),add22(6),这样的话,函数前半部分只需要执行一次,节省了时间,而且实现了类似私有变量的东西。因此,是否使用闭包就看你想实现什么样的需求了。比如像链接数据库这种浪费时间的东西,我们更愿意只链接一次,保存链接状态。但是像计算2*a+b这样简单的东西就没有必要了。

3. 其他

调用 fn 之后创建了变量d,之后函数执行完之后访问不到了,所以解释器会释放这部分空间。或者说fn执行完毕,不需要再保存执行一半的状态了。同样,add22中的变量a也是不需要的状态,解释器也会优化掉。

至于let形成的闭包也是类似的东西,只是需要把函数定义的 {} 替换为任何{}。如果你理解了var的闭包,let也是很简单的东西。

如果只给出部分参数调用函数,只能先给出前面几个参数的。像减法substract(a)(b)这种结果与参数顺序有关的函数,有时候我们希望使用第二个参数部分调用这个函数,所以写一个能够改变函数参数顺序的函数还是必要的。思考一下如何实现这样一个函数吧。

add(a,b)变成add(a)(b)的这个过程叫做函数柯里化。

add(a,b) 这样两个参数的函数其实也可以看作一个参数的函数,在Haskell 中有中数据类型是Tuple(理解为 JS 的数组也没有问题)。他的写法是 (1,2),所以add (1,2)可以看作是 add 接受一个Tuple类型的参数。

ES2015 里函数定义可以是这样var fn = ()=>console.log("hello world!"), args=>xxx; 是一个函数,所以可以这样定义一个函数var add = x=>y=>x+y;那么add(x) 就是y=>x+y;,是不是有点儿什么启发?