#lambda #匿名函数 #闭包 #final
一直对Java中final的某些用法感到疑惑,尤其是匿名函数和lambda中对于final局部变量的强制要求不能理解。这次终于找到个自洽的解释了,属文记之。(个人理解,欢迎diss)
final的四种用法
众所周知,final在java是一个比较常用的关键字,主要有四种用法:
- 修饰类:表示类不允许继承,如String类
- 修饰方法:表示不允许重写(override),但是允许重载(overload)
- 修饰参数:表示参数不允许重新赋值
- 修饰变量:表示变量不允许重新赋值,修饰基本类型变量时,表示值不可改变;修饰引用类型变量时,表示不可变更此变量到其他对象引用
其中对于第三点用法,我觉得有些“鸡肋”。在java中,当传递基本类型参数时,实际上传递的是基本类型的值;当传递引用类型参数时,传递的是“引用”。但是java中的“引用”与其他语言例如c++中的“指针”是不同的,“引用”本身并不能修改,也就是实际上已经“final”了,而c++中的指针是可以修改的。这也就意味着,无论参数是否指明是final,实际上在方法内变更参数的引用对象并不会影响调用方的“引用”所引用的对象。这样来看,final修饰参数有些多余,只是从语意上起到一点提示的作用。
下面用几个例子来证明一下:
public class Test {
static int i = 10000000;
public static void main(String[] args) {
int j = 2000000;
change(j);
System.out.println(j);
change(i);
System.out.println(i);
}
public static void change(int p){
//p++;
p = 99999999;
System.out.println(p);
return;
}
}
输出: 99999999 2000000 99999999 10000000
改成p++
p++;
//p = 99999999;
输出: 2000001 2000000 10000001 10000000
也是一样的,都不能改变i、j的值。引用类型也是类似的,这里就不多作演示了。
这也从侧面解释了final很少修饰参数的原因,毕竟只是起个提示作用,而一般来说我们也是默认不修改参数的,所以写不写区别不大。
对于内部类和lambda传参的限制
java中对于给内部类和lambda函数传参做了特别的限制:传递给它们的局部变量必须是final的。基于前面提到的机制,java都是“值传递”,所以我觉得这是多此一举。但是看了下网上对此的解释,我觉得这种限制还是有一定意义的。当我们在方法内调用其他方法时,在方法调用之后对局部变量的变更(对于基本类型是重新赋值,对于引用类型是变更引用对象)是与被调用方法无关的;而在被调用方法内部对传递参数的变更(对于基本类型是重新赋值,对于引用类型是变更引用对象)也与调用方法无关。
比如:
int i = 10000;
callMethod(i)
i++;
直观上并不会觉得i++会影响到callMethod方法中的i的状态,也不会认为callMethod中对i的修改会影响外部的i值(绝大部分语言都是如此)。但是当我们不是调用方法,而是直接定义方法时,主观上就很容易犯错了。我们很容易会把定义方法的代码与当前方法的代码看成一体的,从而造成一些意料之外的结果。
比如
int i = 0;
List<Integer> list = Lists.newArrayList();
list.stream().forEach(e->i = i + e);//有编译错误
System.out.print(i);
是不是一眼看上去没啥毛病?实际上,根据lambda的定义与实现,lambda捕获的变量是在lambda创建时候的值,也就是无论上面循环中的lambda运行多少次,i的值始终是0,所以最终只能获得最后一个元素的size。而且实际上lambda方法调用也是正常的方法调用,根据之前的实验,方法内部对i的修改是不会影响外部的i值的,所以只能输出0。这就是int没有使用final的副作用,如果使用了final,我们就能避免这种bug。内部类也是类似的原理。
所以结论就是,这又是一个帮助避免bug的功能。根据java的特性,基本类型变量和引用类型变量都是值而已,而值是隐式“final”的,只能重新赋值而不能修改其本身。而如果你能意识到这点,不在lambda中依赖变更后的局部变量,其实不用final也没事。只要代码的行为和你的预期一致就行。但是静态语言的设计准则之一就在于能够通过静态检查帮助减少运行期的程序错误,所以强制显式final,从而帮助开发人员降低心智负担,减少程序bug。这种方法也被c++采用了,c++按值捕获也是强制const。
实际上java8以后已经允许effective final了,也就是我前面说的,只要你的代码不在lambda中依赖变更后的局部变量,可以不强制final,因为实际上已经等效,比如上面的代码稍微下:
int i = 0;
List<Integer> list = Lists.newArrayList();
list.stream().forEach(e-> System.out.println(e+i));
System.out.print(i);
但是实际上还是有一些区别,java的要求比我描述的更严格一些,不仅在lambda里不能修改,在外部方法中也不能修改局部变量。我觉得这种限制过于严格了,因为在lambda创建之后,已经捕获到final值了,在其后面的代码应该可以修改变量的值,这样也与其他非lambda方法的调用行为一致。lambda可以等价于一个参数默认加了final的函数,感觉这样更加符合实际需求。这也是c++中对lambda按值捕获的实现。
kotlin/java/c++
kotlin中用val/var修饰常量/变量,参数默认是val,所以不需要修饰。原因上面已经提到了,kotlin和java已经绝大多数oo语言都是值传递,也就是默认参数是不可变的(这种好处在下面的贴图第二点说明了),所以其实在绝大部分语言中,参数是不需要const/final之类的修饰的。c++是个例外,因为c++是引用传递,如果不加const,意味着引用/指针可以修改,这种修改会反映在传参的变量上,也就是传入的变量也会变更其引用到其他对象。
其他解释
网上关于这个问题有很多解释,但是都不能使人信服,贴一下《java 8 实战》的解释,其他解释基本都跟它大同小异。他的第一点纯属强行解释(对象仍然被lambda线程引用,怎么回收?),第二点有一些道理。网上的解释大多都是说明为什么“final”,而没有解释java传参实际上已经是“final”了,为什么还要加个“final”。