从 λ 演算看 JS 与 JAVA8 闭包
关于 λ 演算在这篇博客 λ表达式与邱奇数,JAVA lamda表达式实现 中做了一个初步的介绍,这次我们来看一些实际应用中的例子:闭包。闭包的知识点有很多,但核心概念就一个,从 λ 演算的角度看便是:自由变量的替换依赖于定义函数的上下文环境。也就是说上下文环境的改变会通过影响函数中的自由变量而直接影响函数的定义。
在 js 中闭包的使用非常多,js 的闭包基于函数作用域链,可被用来定义命名空间及进行变量逃逸,是 js 模块化的基础。但在 java 中用的相对较少,因为 java 的语法限制,从 λ 演算的角度看,java为了语言的简洁和正确性,禁止了我们对函数中自由变量的修改。这便是 java 与 js 闭包不同的核心。在 java 看来,因为本身已经有非常完备的类型支持,不需要借助闭包来定义命名空间。修改自由变量(即在lambda函数之外定义的任何对象)的Lambda函数可能会产生混淆。其他功能的副作用可能会导致不必要的错误。
我们首先来看一下自由变量,对于一个 λ 表达式:
λx.x+1
等价于:
f(x)=x+1
在上述函数中,x为入参,被 λ 绑定,因此 x 是一个绑定变量,这是相对于自由变量的一个概念。而对于如下表达式:
λx.x+y
等价于:
f(x)=x+y
其中 x 为入参,被 λ 绑定。而 y 并没有被绑定,y 便是该表达式中的自由变量。
在实际的生产中,纯粹的自由变量是不存在的,因为如果一个变量始终都不会被赋值,那么该变量对于函数的运行将毫无意义。通常一个 λ 表达式中的自由变量会被更外层的 λ 表达式进行 λ 绑定。也就是说 λx.x+y 的外层通常会有一个 λy 对该表达式进行了绑定(不是两个入参的函数的柯里化表示,而是函数嵌套调用), 或者是在函数声明时,y 便被替换为了实参。
对于第一种情况,外层再次绑定的结果便是函数的嵌套调用,即 λy.λx.x+y ,等价于:
f(x)=x;
f(y)=f(x)+y;
(柯里化的情况等价于 f(x,y)=x+y)
而对于第二种情况,便是我们所说的闭包。λx.x+y 中的自由变量 y 始终未被绑定(没有被任何一层函数调用作为入参),而是在函数声明时,被替换成了某个具体的值。下面我们看 java 中具体的例子:
public Consumer<Integer> getCosumer(){ Integer i0=1; Integer i1=2; Consumer<Integer> f=(inConsumer)->{ System.out.println(i0+inConsumer); }; f.accept(i1); return f; }
我们在函数 getCosumer() 中又定义了另一个函数 f.accept() 。
f.accept() 的 λ 表示为 λ inConsumer.i0+inConsumer 。其中 i0 是一个自由变量,依赖外层函数 getCosumer() 中的局部变量 i0 。也就是说 f.accept() 的定义是依赖于 getCosumer() 的执行的。如果 getCosumer() 的上下文不存在,则 f.accept() 是不完整的,因为 i0 始终是一个变量无法替换为有意义的实际值。
对于 java 来说,i0 的传递依赖的是匿名内部类的传参,也就是说 i0 必须是值不可变的 final 类型(代码中没有用 final 修饰 i0 是因为 java8 及之后的版本,编译器会为我们自动将向匿名内部类传递的参数声明为 final )。我们尝试改变 i0 的值,在编译期会直接报错:
而对 i1 的改变则没有限制。 i1 是入参,供函数执行时使用,但对函数的定义没有影响。而 i0 是函数中的自由变量,依赖其所处的运行环境,是函数的定义的一部分。
如果还觉着抽象我们再看一个例子,改一下上面的 getCosumer() 方法:
public static void main(String[] args){ ClosureTest c=new ClosureTest(); c.getCosumer(1); c.getCosumer(2); } public Consumer<Integer> getCosumer(Integer para){ Integer i0=para; Integer i1=2; Consumer<Integer> f=(inConsumer)->{ System.out.println(in+inConsumer); }; i0=5; f.accept(i1); return f; }
我们执行两次外层方法 getCosumer() ,获得两个上下文中的 f.accept(),对于两个 f.accept() 来说,虽然它们的入参 i1 都是2,但因为上下文不同导致了 i0 的不同。
可以这么说, 两个上下文中的 f.accept() 函数不是同一个函数,分别是 λ.inConsumer 1 + inConsumer 与 λ.inConsumer 2 + inConsumer 。自由变量的替换将直接影响函数的定义。
在这种情况下,如果修改 getCosumer() 上下文中的 i0 的值,其内部函数 f.accept() 的定义也会随着改变,所以 java 禁止我们对 i0 的值进行改变,必须对其用 final 修饰。而在向 f.accept() 方法传递 i0 的值时则是传递了一份变量的副本,而不是直接传递 i0 的引用。在 getCosumer() 执行完后, 栈中的 i0 随着栈帧被释放掉,返回的 f.accept() 中保存了一份 i0 的副本(值)。
js 的闭包的本质与上述 java 代码相同,但 js 允许对自由变量进行修改。我们来看一段代码:
var fun=function(){ var a='我是外部函数的变量a'; return function(){console.log(a);} } var result=fun(); result();
内部匿名方法做为返回值,其定义依赖外部函数 fun() 中的局部变量 a 。与 java 不同的是,在 js 中函数也是对象(而 java 中依赖实现了函数接口的匿名内部类对象来定义函数对象),内部匿名方法声明后,外部方法 fun() 的执行上下文并没有随着 fun() 的执行结束而被销毁。因为 fun() 将本次调用上下文中变量 a 的引用直接传递给了内部匿名函数(而 java 中如果传递给内部类的是方法中的局部变量,则只是将变量的副本传递给了内部类对象)。另外,js 中允许外部方法对本层提供给内部方法的自由变量进行修改,也就是本例中修改 a 的值,而这在 java 中是不被允许的。
每次外部函数的调用都会形成一个新的作用域,在此作用域中被声明的匿名内部方法因为持有该作用域中变量的引用而导致了该作用域未被垃圾回收,js 中的闭包虽然可以借此来帮助我们实现命名空间的隔离,但也会带来内存泄漏问题。
执行结果:
我们再看一段修改函数定义上下文中自由变量的例子:
var fun=function(){ var a='我是外部函数的变量a'; var getA = function(){console.log(a);} var setA = function(){a+=',我被改了';} return { getA:getA, setA:setA } } var result=fun(); result.getA(); result.setA(); result.getA();
执行结果:
可以看到 js 中对传递给内部函数的自由变量的修改没有限制。实际上,在JavaScript中,一个新函数维护一个指向它所定义的封闭范围的指针。这个基本机制允许创建闭包,这保存了自由变量的存储位置 - 这些可以由函数本身以及其他函数修改。JavaScript使用闭包作为创建“类”实例:对象的基本机制。这就是为什么在JavaScript中,类似的函数 MyCounter 称为“构造函数”。相反,Java已经有类,我们可以以更优雅的方式创建对象。
在 js 中当闭包函数调用时,它会动态开辟出自己的作用域,在它之上的是父函数的永恒作用域,在父函数作用域之上的,是window永恒的全局作用域。闭包函数调用完了,它自己的作用域关闭了,从内存中消失了,但是父函数的永恒作用域和window永恒作用域还一直在内存是打开的。闭包函数再次调用时,还能访问这两个作用域,可能还保存了它上次调用时候产生的数据。只有当闭包函数的引用被释放了,它的父作用域才会最终关闭(当然父函数可能创建了多个闭包函数,就需要多个闭包函数全部释放后,父函数作用域才会关闭)。
与之相比,java 对闭包的支持显得并不是那么完善。当然,硬来的话我们也可以用 java 模拟出类似 js 的闭包,比如:
public Consumer<Integer> getCosumer(){ StringBuilder strBuilder=new StringBuilder("原自由变量"); Consumer<Integer> f=(inConsumer)->{ System.out.println(strBuilder.toString()); }; strBuilder.append(",被外层函数修改了"); return f; }
我们不能改变 strBuilder 指向的地址,但我们可以修改该地址中对象的内容。但并没有什么必须要使用这种不怎么优雅的写法的场景,所以我们很少见到它。