5.2 lambda表达式与效果等同于final的变量
问题
用户希望从 lambda 表达式内部访问在其外部定义的变量。
方案
必须将在 lambda 表达式内部访问的局部变量声明为 final,或使其具备等同于 final 的效果(effectively final)。可以对特性(attribute)进行访问和修改。
讨论
20 世纪 90 年代末,在 Java 初登舞台时,开发人员偶尔会使用 GUI 库 Swing1 来编写客户端 Java 应用程序。与所有 GUI 库一样,Swing 组件也是事件驱动的。换言之,组件产生事件,监听器(listener)对事件做出响应。
1一种基于 Java 的跨平台 MVC 框架,采用单线程模型,属于 JFC 的一部分。——译者注
为每个组件创建单独的监听器被视为一种良好实践,因此监听器通常作为匿名内部类实现。使用内部类不仅有助于保持程序的模块化,内部类中的代码还可以访问并修改外部类的私有特性。例如,JButton 实例生成 ActionEvent,而 ActionListener 接口包含一个名为 actionPerformed 的单一抽象方法。一旦实现被注册为监听器,就会调用该方法。相关示例如例 5-4 所示。
例 5-4 简单的 Swing GUI
- public class MyGUI extends JFrame {
- private JTextField name = new JTextField("Please enter your name");
- private JTextField response = new JTextField("Greeting");
- private JButton button = new JButton("Say Hi");
- public MyGUI() {
- // 无关的GUI设置代码
- String greeting = "Hello, %s!"; ➊
- button.addActionListener(new ActionListener() {
- @Override
- public void actionPerformed(ActionEvent e) {
- response.setText(
- String.format(greeting, name.getText()); ➋
- // greeting = "Anything else"; ➌
- }
- });
- }
- }
❶ 局部变量
❷ 访问局部变量和特性
❸ 修改局部变量(无法编译)
在本例中,greeting 字符串是在构造函数内部定义的局部变量;name 和 response 变量是类的特性;ActionListener 接口以匿名内部类的形式实现,其中一个方法为 actionPerformed。请注意,内部类中的代码:
- 可以访问特性(如
name和response) - 可以修改特性(本例没有展示)
- 可以访问局部变量(
greeting) - 无法修改局部变量
在 Java 8 之前,编译器要求 greeting 变量被声明为 final。而在 Java 8 中,变量不必采用 final 修饰,但必须具备等同于 final 的效果。换言之,任何试图修改局部变量值的代码都不会被编译。
当然,在 Java 8 中,应采用 lambda 表达式替换匿名内部类,如例 5-5 所示。
例 5-5 监听器的 lambda 表达式
String greeting = "Hello, %s!";button.addActionListener(e ->response.setText(String.format(greeting,name.getText())));
同样地,greeting 变量不必被声明为 final,但必须具备等同于 final 的效果,否则代码无法编译。
如果读者对 Swing 示例不感兴趣,我们再来讨论另外一个示例。如例 5-6 所示,我们希望对给定 List 中的所有值求和。
例 5-6 对
List中的所有值求和
- List<Integer> nums = Arrays.asList(3, 1, 4, 1, 5, 9);
- int total = 0; ➊
- for (int n : nums) { ➋
- total += n;
- }
- total = 0;
- nums.forEach(n -> total += n); ➌
- total = nums.stream() ➍
- .mapToInt(Integer::valueOf)
- .sum()
❶ 局部变量 total
❷ 传统的 for-each 循环
❸ 修改 lambda 表达式中的局部变量(无法编译)
❹ 将流转换为 IntStream 并调用 sum 方法
上述代码声明了一个名为 total 的局部变量,并采用传统的 for-each 循环对所有值求和。
Iterable 接口定义的 forEach 方法传入 Consumer 作为参数。如果 Consumer 试图修改 total 变量,则代码不会编译。
当然,解决这个问题的正确方式是将流转换为 IntStream。由于它定义了 sum 方法,不会涉及任何局部变量。
严格来说,函数以及在其环境中定义的可访问变量称为闭包(closure)。从这一定义来看,Java 对局部变量的处理并不是很明确:虽然可以访问局部变量,但无法修改。在 Java 8 中,lambda 表达式是通过值(而非变量)来关闭的,读者或许认为 lambda 表达式实际上属于闭包 2。
2那么,为什么不将 Java 8 引入的 lambda 表达式称为闭包呢?根据 Bruce Eckel 的说法,原因在于“闭包”这个术语的应用过于频繁,因而引发了争议。“当人们讨论真正的闭包时,往往意味着他们在讨论第一种语言遇到的闭包”。感兴趣的读者可以参考 Bruce 的博文“Are Java 8 Lambdas Closures?”。
另见
其他语言对闭包变量(closure variable)的处理有所不同。例如,Groovy 允许对闭包变量进行修改,但通常不认为这是一种良好实践。
