天天看点

「lambda表达式」变量作用域和lambda 表达式的处理

作者:嘟null

变量作用域

通常, 你可能希望能够在 lambda 表达式中访问外围方法或类中的变量。

public static void repeatMessage(String text, int delay){
    ActionListener listener = event ->{ 
    	System.out.println(text);
        Toolkit.getDefaultToolkitO.beep();
	}
	new Timer(delay, listener).start0;
}           

注意 lambda 表达式中的变量text并不是在这个 lambda 表达式中定义的。实际上,这是 repeatMessage 方法的一个参数变量。

想想看, 这里好像会有问题, 尽管不那么明显。lambda 表达式的代码可能会在 repeatMessage 调用返回很久以后才运行,而那时这个参数变量已经不存在了。如何保留 text 变量呢?

要了解到底会发生什么,下面来巩固我们对 lambda 表达式的理解。lambda 表达式有 3 个部分:

  1. 一个代码块;
  2. 参数;
  3. 自由变量的值, 这是指非参数而且不在代码中定义的变量。

在例子中, 这个 lambda 表达式有 1 个自由变量 text。 表示 lambda 表达式的数据结构必须存储自由变量的值,在这里就是字符串 "Hello" 。我们说它被 lambda 表达式捕获 (下面来看具体的实现细节。 例如,可以把一个 lambda 表达式转换为包含一个方法的对象,这样自由变量的值就会复制到这个对象的实例变量中。)

关于代码块以及自由变量值有一个术语: 闭包(closure) 。如果有人吹嘘他们的语言有闭包,现在你也可以自信地说 Java 也有闭包。在 Java 中, lambda 表达式就是闭包。

可以看到,lambda 表达式可以捕获外围作用域中变量的值。 在 Java 中,要确保所捕获的值是明确定义的,这里有一个重要的限制。在 lambda 表达式中, 只能引用值不会改变的变量。

之所以有这个限制是有原因的。如果在 lambda 表达式中改变变量, 并发执行多个动作时就会不安全。对于目前为止我们看到的动作不会发生这种情况,不过一般来讲,这确实是 一个严重的问题。 另外如果在 lambda 表达式中引用变量, 而这个变量可能在外部改变,这也是不合法的。

这里有一条规则:lambda 表达式中捕获的变量必须实际上是最终变量 ( effectively final)。 实际上的最终变量是指, 这个变量初始化之后就不会再为它赋新值。在这里,text 总是指示 同一个 String 对象,所以捕获这个变量是合法的。

lambda 表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则。在 lambda 表达式中声明与一个局部变量同名的参数或局部变量是不合法的。

public class Application{
	public void init(){
		ActionListener listener = event ->{
			System.out.println(this.toString());
            ...
        }
        ...
    }
}           

表达式 this.toString() 会调用 Application 对象的 toString方法, 而不是 ActionListener 实例的方法。在 lambda 表达式中, this 的使用并没有任何特殊之处。lambda 表达式的作用域嵌套在 init 方法中,与出现在这个方法中的其他位置一样, lambda 表达式中 this 的含义并没有变化。

lambda 表达式的处理

到目前为止, 你已经了解了如何生成 lambda 表达式, 以及如何把 lambda 表达式传递到 需要一个函数式接口的方法。下面来看如何编写方法处理 lambda 表达式。

使用 lambda 表达式的重点是延迟执行 ( deferred execution )。 毕竟, 如果想要立即执行代码,完全可以直接执行, 而无需把它包装在一个lambda 表达式中。之所以希望以后再执行代码, 这有很多原因, 如:

  • 在一个单独的线程中运行代码;
  • 多次运行代码;
  • 在算法的适当位置运行代码(例如,排序中的比较操作;)
  • 发生某种情况时执行代码(如,点击了一个按钮,数据到达,等等;)
  • 只在必要时才运行代码。

下面来看一个简单的例子。假设你想要重复一个动作 n 次。 将这个动作和重复次数传递到一个 repeat 方法:

repeat(10, 0 -> System.out.println("Hello, World!")) ;

要接受这个 lambda 表达式, 需要选择(偶尔可能需要提供)一个函数式接口。 表 6-1 列出了 Java API 中提供的最重要的函数式接口。在这里, 我们可以使用 Runnable 接口:

public static void repeat(int n, Runnable action) { for (int i = 0; i < n; i++) action.run(); }

需要说明的是,调用 action.run() 时会执行这个 lambda 表达式的主体。

「lambda表达式」变量作用域和lambda 表达式的处理

现在让这个例子更复杂一些。我们希望告诉这个动作它出现在哪一次迭代中。 为此,需要选择一个合适的函数式接口,其中要包含一个方法, 这个方法有一个 int 参数而且返回类型为 void。处理 int 值的标准接口如下:

public interface IntConsumer{
	void accept(int value);
}           

下面给出 repeat 方法的改进版本:

public static void repeat(int n, IntConsumer action){
	for (int i = 0; i < n; i++) action.accept(i);
}           

可以如下调用它:

repeat(10, i -> System.out.println(" Countdown: " + (9 - i)));

表 6-2 列出了基本类型 int 、long 和 double 的 34 个可能的规范。 最好使用这些特殊化规范来减少自动装箱。出于这个原因, 我在上一节的例子中使用了 IntConsumer 而不是 Consume<lnteger> 。

「lambda表达式」变量作用域和lambda 表达式的处理

最好使用表 6-1 或表 6-2 中的接口。 例如,假设要编写一个方法来处理满足某个特定条件的文件。 对此有一个遗留接口 java.io.FileFilter, 不过最好使用标准的 Predicate , 只有一种情况下可以不这么做, 那就是你已经有很多有用的方法可以生成 FileFilter 实例。

大多数标准函数式接口都提供了非抽象方法来生成或合并函数。 例如,Predicate. isEqual(a)等同于 a::equals, 不过如果 a 为 null 也能正常工作。已经提供了默认方法 and 、or 和 negate 来合并谓词。 例如, Predicate.isEqual(a).or(Predicate.isEqual(b)) 就等同于 x -> a.equals(x) || b.equals(x) 。

如果设计你自己的接口,其中只有一个抽象方法,可以用 @FunctionalInterface 注解来标记这个接口。这样做有两个优点。 如果你无意中增加了另一个非抽象方法, 编译器会产生一个错误消息。 另外 javadoc 页里会指出你的接口是一个函数式接口。

并不是必须使用注解根据定义,任何有一个抽象方法的接口都是函数式接口。不过使用 @FunctionalInterface 注解确实是一个很好的做法。