effectively final
当我们写以下代码的时候,Java编译器会报错
Integer a= 0;
if(true){
a=1;
}
Supplier r = () -> a;
r.get();
原因是a变量进行了二次赋值,而Java编译器禁止这种行为。即使你这么写
Integer a= 0;
Supplier r = () -> a;
r.get();
if(true){
a=1;
}
对不起,编译器依然禁止这种行为。 这就很挠头了,我在进入Supplier之前,a已经不可能发生变化了。不管是第一种写法还是第二种写法。在Lambda中为什么不能理解是对于这个代码片段是不可变了?
不可变性
为什么FP编程强调不可变?从根源上来说,FP编程源自Lambda演算法,本质是在用数学的方式描述去描述计算。比如函数
Integer a= 0;
// 中间省去一堆,但是a不能被重新赋值
Function<Integer, Integer> add = a -> a+1;
System.out.println(add.apply(1));
本质是在描述 +1 这个变换关系。这是确定的,所以1对应2,2对应3。这个关系不能发生变动。你肯定不希望输入2的到的结果是4。指令式的变动则不同,它通过修改对象的状态,来描述这一计算:
Integer a= 0;
// 中间省去一堆,但是a可以被重新赋值
a=a+1;
System.out.println(a);
但是数学是数学,工程实践可能就是另外一回事了。Js和Python就允许。所以就必须说下保持不可变可以带来的好处是什么了。 (注意,下面开始add函数将只是一个函数,它内部不一定只是一个+1操作,可能是+1 -1 +1,也可能是其它什么,唯一不变的是它是稳定的,一个稳定的输入,对应一个稳定的输出)
- 记忆化
好现在我继续以上文的add函数来看:
所以,你也可以认为add函数就是一个有限的元组集合:输入1->输出2; 输入2->输出3; ... 输入2147483647->输出-2147483648;实际数学上也可以把数据的函数,视作有序对。 既然输入1就固定输出2,这就意味着,第一次计算之后,我们就可以把这个映射缓存起来。后续我就不再需要进行任何计算,这就是记忆化了。 如果函数内部存在可以被改变的状态,这个基础就崩塌了。你需要为这个记忆化引入更多的概念,脏读,缓存更新,锁等等等。Pair.of(1,2); Pair.of(2,3); ... Pair.of(2147483647,-2147483648); - 天然的并发
不论我用多少个进程来对add函数进行求值。
你不需要为了稳定的输出,而去进行加锁这种操作。它和求值的时间无关。如果你以命令式的代码去进行并发,那么锁就是你必须考虑的了,它引入了更多的复杂度。什么排他锁,共享锁,你要为了可变值,在并发中引入一堆概念。输入1->输出2; 输入2->输出3; ... 输入2147483647->输出-2147483648;
如果你支持Js这类允许值可变,可以。但是代价是,你要么接受计算的结果不稳定,要么你要引入更多的工具来对抗不稳定。
延迟计算
好,明白了不可变的好处,回到一开始挠头的问题:进入Supplier之前,a已经不可能发生变化了。为什么Java编译器不允许。
从工程实践的角度来看,大家可以知道Java通过invokedynamic + LambdaMetafactory来实现的。这就意味着,从代码编写的角度看a赋值和r的运行是在同一个代码段内,有先后顺序的。但在编译完的实现角度来看,r实际上留着代码中的,只有引用了。编译后的代码实际是不知道,什么时候会执行r的。而a是确认进行了两次赋值的。站在编译器的角度来看。变量a不稳定。但是它也带来了第二个好处,延迟计算,只有当确定要计算它的时候,它才会被计算(当然我们的例子是确定会被计算的,但是其它场景就不一定了比如异步,比如Optional的orElseGet)。
从FP编程的角度来看,这也是第一期我们讲的那个概念的一个很好的体现,OOP抽象数据,FP抽象行为。既然行为被抽象了,自然是可以在被需要的时候才进行调用,不需要的时候就无人访问。
总结
我们从effectively final出发,引出了如何看待不可变性性和延迟计算。回落到Java的工程实践选择,可以清晰的回答一开始挠头的问题了。那么扩散出去,effectively final也并非不可绕过,不可变性也并非必然遵守,Java中你也可以通过List,AtomicInteger这种包装,让编译器无从判断值是否变化(保持引用不变)。但是知道这深层的逻辑,你要知道,绕过与否,反应的是你对代码的取舍理解,而不是为了不让编译器报错。不论是Java,或Js其它的什么。
待续。。。