简介
兰姆达表达式可以使用兰姆达表达式作用域中的变量,但只有当它们是final或有效final的时候。这样做的原因是什么呢?为什么是这样呢?这是一个有趣的问题,因为答案并不明显,而且众说纷纭。
不过,最终的答案只有一个:因为这就是《Java语言规范》所说的。但这么说很无聊。没错,但很无聊。我更喜欢这样的答案:lambdas只能使用final和有效final局部变量,因为lambdas不是闭包。
在下文中,我将讨论final和effective final的含义,闭包和lambdas的区别,以及最后,我们如何使用lambda表达式在Java中创建闭包。我并不提倡在Java中创建基于lambda表达式的闭包,也不提倡放弃这个想法。
final 和有效的final
声明时,如果我们使用final 关键字,那么一个局部变量就是最终的。编译器也会要求该变量只获得一次值。这个赋值可能发生在声明的位置,但也可以晚一点。可以有多行为最终变量赋值,只要每个方法的调用只能执行其中一行即可。典型的情况是,你声明了一个最终变量而没有给它赋值,然后你有一个if 语句,在 "then "和 "else "分支中给出不同的值。
不用说,在创建lambda表达式之前,该变量必须被初始化。
一个变量如果不是最终的,那么它实际上就是最终的,但它可以是最终的。它在声明时得到一个赋值,或者只能得到一次给定值。
兰姆达的生命
lambda表达式是一种匿名的类。JVM对它的处理方式不同,它比匿名类更有效率,更不用说它的可读性了。然而,从我们的角度来看,我们可以把它看作是一个内部类:
public class Anon {
public static Function<Integer, Integer> incrementer(final int step) {
return (Integer i) -> i + step;
}
public static Function<Integer, Integer> anonIncrementer(final int step) {
return new Function<Integer, Integer>() {
@Override
public Integer apply(Integer i) {
return i + step;
}
};
}
}
当lambda表达式被创建时,JVM会制造一个实现了Function 接口的lambda类的实例:
var inc = Anon.incrementer(5);
assertThat(inc.getClass().getName()).startsWith("javax0.blog.lambdas.Anon$$Lambda$");
assertThat(inc.getClass().getSuperclass().getName()).isEqualTo("java.lang.Object");
assertThat(inc.getClass().getInterfaces()).hasSize(1);
assertThat(inc.getClass().getInterfaces()[0]).isEqualTo(java.util.function.Function.class);
JVM会把这个对象放在堆上。在某些情况下,编译器可能会意识到这个对象不能走出方法的范围,在这种情况下,它可能会将其存储在堆栈中。这就是所谓的局部变量逃逸分析,它可以直接把任何对象放在堆栈中,这些对象无法从方法中逃逸,可能会和方法的返回一起死亡。然而,对于我们的讨论,我们可以忘记Java环境的这个高级特性。
lambda是在方法中创建并存储在堆中的。只要有一个对这个对象的硬引用,它就一直活着,并且没有被收集。如果一个lambda表达式可以引用并使用一个住在堆栈中的局部变量,那么它就需要在方法返回后访问一些已经消失的东西。这是不可能的。
有两种解决方案可以克服这种差异。一种是Java的做法,即创建一个变量值的副本。另一个则是创建一个闭包。
闭包和Groovy
在谈论闭包的时候,我们将看一下Groovy的例子。选择Groovy的原因是,它与Java非常接近。我们将看一些Groovy的例子,为了便于演示,我们将尽可能地使用Java风格。Groovy或多或少与Java兼容;任何Java代码都可以被编译为Groovy的源代码。不过,实际的语义可能略有不同。
Groovy解决了局部变量的可访问性问题,创建了闭包。闭包将功能和环境封闭为一个单一的对象。例如,下面的Groovy代码:
class MyClosure {
static incrementer() {
Integer z = 0
return { Integer x -> z++; x + z }
}
}
创建了一个闭包,类似于我们的lambda表达式,但它也使用了局部变量z 。这个局部变量不是最终变量,也不是有效的最终变量。这里发生的情况是,编译器创建了一个新的类,该类包含了闭包中使用的每个局部变量的字段。一个新的局部变量引用了这个新类的一个实例,并且局部变量使用了对这个对象及其字段的所有引用。这个对象和 "lambda表达式 "代码一起,就是闭包。
由于该对象在堆上,只要有一个硬引用,它就会一直存在。持有所述函数的对象有一个,所以只要闭包还活着,这个对象就可以使用:
def inc = MyClosure.incrementer();
assert inc(1) == 2
assert inc(1) == 3
assert inc(1) == 4
在测试执行中可以清楚地看到,闭包在每次执行时都会增加z 。
闭包是具有状态的lambdas。
Java中的Lambda
Java以不同的方式处理这个问题。它没有创建一个新的合成对象来保存引用的局部变量,而是简单地使用变量的值。Lambdas似乎使用了这些变量,但它们并没有。它们只使用复制了变量值的常量。
在设计lambdas时,有两种选择。我不是做决定的团队的一员,所以我在这里写的只是我的观点,猜测,但它可能会帮助你理解为什么会做出这样的决定。一种选择是在创建lambda时复制变量的值,而不关心本地变量以后的值变化。这能行吗?不可避免的。它可以被阅读吗?在很多情况下,这是不可能的。如果这个变量后来发生了变化呢?lambda会使用改变后的值吗?不会,它将使用复制的、冻结的值。这与变量通常的工作方式不同。
Java要求变量必须是final或有效final来解决这个差异。在使用lambda时有不同的变量值,这种令人不安的情况被避免了。
在设计语言元素时,总是会有一些折衷。一方面,一些结构体为开发者提供了巨大的权力。然而,巨大的权力需要巨大的责任。大多数的开发者还没有成熟到可以承担起这个责任。
在天平的另一端是提供较少功能的简单构造。它可能不能那么优雅地解决一些问题,但你也不能那么容易地创造出不可读的代码。Java通常是这样走的。几乎从C语言开始,就有一场混淆的C竞赛。谁能用这种编程语言写出更少的可读代码?从那时起,几乎所有的语言都开始了这个比赛,除了两种。Java和Perl。如果是Java,比赛将是枯燥的,因为你不能在Java中写出混淆的代码。就Perl而言,这个比赛是毫无意义的。
Java中的闭包
如果你想在Java中拥有一个闭包,你可以自己创建一个。好的方法是使用匿名的,或者说是普通的类。另一种是模仿Groovy编译器的行为,创建一个封装闭包数据的类。
Groovy编译器为你创建类来封装局部变量,但如果你想在Java中这样做,没有什么可以阻止你手动制作。你必须做同样的事情。将闭包使用的每一个局部变量作为实例字段移入一个类中:
public static Function<Integer, Integer> incrementer() {
AtomicInteger z = new AtomicInteger(0);
return x -> {
z.set(z.get() + 1);
return x + z.get();
};
}
在我们的例子中,我们只有一个局部变量,int z 。我们需要一个可以容纳int的类。这个类是AtomicInteger 。它还能做很多其他的事情,而且通常在并发执行是一个问题时使用。正因为如此,一些开销可能会稍微影响到性能,我现在卑鄙地忽略了这一点。
如果有一个以上的局部变量,我们需要为它们制作一个类:
public static Function<Integer, Integer> incrementer() {
class DataHolder{int z; int m;}
final var dh = new DataHolder();
return x -> {
dh.z++;
dh.m++;
return x + dh.z*dh.m;
};
}
正如你在这个例子中看到的,我们甚至可以在方法内部声明一个类,对于代码的凝聚力来说,这是一个正确的地方。最终,我们很容易看到这种方法是有效的。
final var inc = LambdaComplexClosure.incrementer();
assertThat(inc.apply(1)).isEqualTo(2);
assertThat(inc.apply(1)).isEqualTo(5);
assertThat(inc.apply(1)).isEqualTo(10);
然而,如果你想使用这种方法,是值得怀疑的。Lambdas一般应该是无状态的。当你需要一个lambda使用的状态时,换句话说,当你需要一个语言不直接支持的闭包时,你应该使用一个类。
总结
- 本文讨论了为什么lambda表达式只能访问final和有效final局部变量。
- 我们还讨论了原因以及不同语言如何处理这个问题。
- 最后,我们看了一个Groovy的例子以及Java如何模仿这个例子。