有些时候,我们会使用一些十分简单的匿名类,比如只包含一个方法的接口,这样的匿名类语法会显得十分的笨重。在这种情况下,你通常是想将某个功能作为参数传递到另一个方法中,比如希望将如何比较两个对象大小的功能传入排序方法中。Lambda 表达式就是一个可传递的代码块,将功能视为方法参数,或将代码视为数据。

虽然匿名类比名命类更加简洁,但是对于只有一个方法的类来说,还是显得笨重和繁琐。Lambda 表达式使你可以更紧凑地表示单个方法类的实例。

1. Lambda 表达式应用示例

接下来通过一个应用示例来说明 Lambda 表达式的使用过程。

假设你现在开发一个网络社交应用。你希望实现一个功能,使管理员能够对满足特定条件的用户执行任何类型的操作。需要如何实现该功能?

改进 1:搜索匹配某个特征的用户

一种简单的方法就是创建多个方法,每个方法对应一种特征(比如年龄、性别),以下是打印大于指定年龄的用户:

public static void printUserOlderThan(List<User> users, int age) {
for (User u : users) {
if (u.getAge() >= age) {
System.out.println(u);
}
}
}

这种方法可能会使你的应用程序变得脆弱,假设代码使用了新的数据类型、修改了 User 类的结构、使用了不同的数据类型或算法比较年龄等等这些更新,可能会导致应用程序无法正常工作。为了适应这种变化,你必须重写大量的方法。而且,这种方法有很大的限制,假如你希望打印小于指定年龄的用户,该怎么办?

改进 2:创建更通用的方法

以下方法比 getUserOlderThan() 更通用,它能打印指定年龄范围的用户:

public static void printUserWithinAgeRange(List<User> users, int low, int high) {
for (User u : users) {
if (low <= u.getAge() && u.getAge() < high) {
System.out.println(u);
}
}
}

这种方法似乎更通用了,但是更复杂的条件仍然不能实现,比如加入性别作为条件、新增了地理位置字段,将地理位置作为条件等。而且,这种方法还是没有解决为每一种搜索创建独立的方法可能导致应用程序脆弱的问题。你应该将搜索条件的代码与实际功能分开。

改进 3:使用搜索条件接口

以下方法打印与指定的搜索条件匹配的用户:

public static void printUser(List<User> users, CheckUser tester) {
for (User u : users) {
if (tester.test(u)) {
System.out.println(u);
}
}
}

这个方法使用 tester.test() 方法来搜索指定条件的用户,如果如何条件将会返回 true。要指定条件需要实现 CheckUser 接口:

interface CheckUser {
boolean test(User u);
}

比如,搜索 18 ~ 25 岁的男性用户:

class CheckUserByAgeAndGender implements CheckUser {
public boolean test(User u) {
return u.gender == User.Sex.MALE &&
u.getAge() >= 18 &&
u.getAge() <= 25;
}
}

使用时你需要在方法中创建一个该类的实例:

printUser(users, new CheckUserByAgeAndGender())

尽管这样将搜索条件和实际功能分开,使应用程序不那么脆弱,维护时不需要重写大量的方法。但是,每一种搜索条件都需要实现一次接口。这时,你可以使用匿名类实现 CheckUser 接口,而必为每种搜索条件声明一个新类。

改进 4:使用匿名类指定搜索条件

以下使用匿名类,搜索 18 ~ 25 岁的男性用户:

printUser(users, new CheckUser(){
public boolean test(User u){
return u.gender == User.Sex.MALE &&
u.getAge() >= 18 &&
u.getAge() <= 25;
}
})

这种方法减少了很多代码量,因为你不必为每种搜索条件创建新类。然而 CheckUser 接口只包含一个方法,使用匿名类还是显得有些笨重。在这种情况下,可以使用 Lambda 表达式。

改进 5:使用 Lambda 表达式指定搜索条件

只包含一个抽象方法的接口被称为函数接口。因为函数接口只包含一个抽象方法,所以在实现时可以省略方法名。因此,可以使用 Lambda 表达式:

printUser(users, (User u) -> u.getGender() == User.Sex.MALE
&& u.getAge() >= 18
&& u.getAge() <= 25
);

你还可以使用标准函数接口来代替 CheckUser 接口,进一步减少所需的代码量。

改进 6:对 Lambda 表达式使用标准函数接口

CheckUser 是一个十分简单的接口。它只有一个抽象方法,接收一个参数并且返回一个布尔值。为一个方法定义一个接口并不值得。因此,JDK 定义了几个标准的功能接口,你可以在 java.util.function 包中找到这些接口。

比如,可以使用 Predicate<T> 接口代替 CheckUser 接口,这个接口提供了 boolean test(T t) 方法:

interface Predicate<T> {
boolean test(T t);
}

通过泛型,可以使用 Predicate<User> 接口代替 CheckUser 接口:

public static void printUserWithPredicate(List<User> users, Predicate<User> tester) {
for (User u : user) {
if (tester.test(u)) {
System.out.println(u);
}
}
}
printUserWithPredicate(users, u -> u.getGender() == User.Sex.MALE
&& u.getAge() >= 18
&& u.getAge() <= 25
);

搜索条件并不是唯一可以使用 Lambda 表达式的地方。

改进 7:进一步使用 Lambda 表达式

重新考虑 printUserWithPredicate() 方法,还有那些地方可以使用 Lambda 表达式。该方法会搜索列表中符合条件的用户,然后将用户打印出来。

如果现在不需要打印用户,而是进行其他的操作该怎么办?你可以使用 Lambda 表达式(要使用 Lambda 表达式,就需要实现函数接口)。在这种情况下,你需要一个函数接口,该接口需要接收一个 User 参数并且返回 void。JDK 提供的标准函数接口 Consumer<T> 就符合该条件。你可以使用 Consumer<User> 接口的 accept() 方法代替 System.out.println(u)

public static void processUser(List<User> users,
Predicate<User> tester, Consumer<User> block) {
for (User u : user) {
if (tester.test(u)) {
block.accept(u);
}
}
}
processUser(user,
u -> u.getGender() == User.Sex.MALE
&& u.getAge() >= 18
&& u.getAge() <= 25,
u -> System.out.println(u)
);

如果你仅仅是将数据打印出来,还有更复杂的需求怎么办?还可以加入更多的函数接口,使用 Lambda 表达式实现更复杂的需求。

该方法甚至还可以继续改进。比如在方法中使用泛型,这样方法就可以处理任何的对象而不是仅限于 User(我就不继续改进了,有兴趣可以查看 Lambda Expressions

与一开始的方法对比,Lambda 表达式使你的方法更加简洁和灵活。

2. Lambda 表达式的语法

Lambda 表达式包含以下部分:

  • 用括号分隔的参数列表。比如 CheckUser.test() 方法需要一个参数 (User u),表示 User 实例。在 Lambda 表达式中,参数的类型可以省略,如果只有一个参数可以省略括号。

  • 箭头 ->

  • 由代码块组成的方法体。如果只有一条表达式,Java 会计算该表达式然后返回:

    u -> u.getGender() == User.Sex.MALE 
    && u.getAge() >= 18
    && u.getAge() <= 25

    或者使用返回语句:

    u -> {
    return u.getGender() == User.Sex.MALE
    && u.getAge() >= 18
    && u.getAge() <= 25;
    }

    return 语句不是表达式,所以需要在 {} 中使用。

Lambda 表达式看起来就像在声明方法,可以将 Lambda 表达式视为没有名字的匿名方法:

// 与改进 5 相同
CheckUser CheckUserByAgeAndGender =
u -> u.getGender() == User.Sex.MALE && u.getAge() >= 18 && u.getAge() <= 25;

printUser(user, CheckUserByAgeAndGender);

3. 变量作用域

3.1 Lambda 表达式引用外部变量

有时,你可能会希望在 Lambda 表达式中访问外部的变量。就像下面的例子:

public static void sayMessage(String name){
String message = "Hello";
Consumer consumer = n ->{
// 访问外部变量 message
System.out.println(n + ":" + message);
}
consumer.accept(name);
}

注意,message 变量并不是在 Lambda 表达式中定义的,它是方法中的一个局部变量。Lambda 表达式是一段独立的代码块,它可能会被传到任何一个地方。那么它是如何保留 message 变量的呢?

Lambda 表达式中,除了参数、代码块之外,还会保存 ”自由变量“ 的值(这是指,非参数而且不在代码中定义的变量)。在上面的例子中,Lambda 表达式有一个自由变量 message。Lambda 表达式会将它的值存储下来,也就是字符串 “Hello”。我们会说,它被 Lambda 表达式捕获。

3.2 错误引用变量

Lambda 表达式可以捕获代码块之外的变量的值,但是要确保该值是明确的、不会被改变的。以下例子是不合法的:

public static void sayMessage(String name){
String message = "Hello";
message = "Hi";

Consumer consumer = n ->{
// 错误:无法引用更改的变量
System.out.println(n + ":" + message);
}
consumer.accept(name);
}

另外,Lambda 表达式只能引用值而不能改变值。如果在 Lambda 表达式中改变变量,并发执行多个动作时就会不安全。以下例子是不合法的:

public static void sayMessage(String name){
String message = "Hello";

Consumer consumer = n ->{
// 错误:无法改变被捕获的变量
message = "Hi";
System.out.println(n + ":" + message);
}
consumer.accept(name);
}

以上两种情况都会产生错误:“Local variable i defined in an enclosing scope must be final or effectively final”。也就是说,Lambda 表达式中捕获的变量必须是最终变量(变量初始化之后不会再为它赋新值)。

在方法中,不能有两个同名的局部变量,因此,Lambda 表达式中同样也不能有同名的局部变量。以下例子是不合法的:

String message = "Hello";
// 错误:变量 message 已经被定义
Consumer consumer = message ->{
System.out.println(message);
}

3.3 Lambda 表达式中的 this 关键字

如果在 Lambda 表达式中使用 this 关键字时,是指创建这个 Lambda 表达式的方法的 this 参数:

public class Application{
...
public void method(){
Consumer consumer = item ->{
System.out.println(this.toString());
...
}
...
}
...
}

表达式 this.toString() 调用的是 Application.toString() 方法。无论 Lambda 表达式被传到了什么位置,this 关键字的含义都不会改变。

附录

1. 常用函数式接口

函数式接口 参数类型 返回类型 抽象方法名 描述
Runnable void run 无参数、无返回值
Supplier<T> T get 提供一个 T 类型的值
Consumer<T> T void accept 处理一个 T 类型的值
BiConsumer<T, U> T, U void accept 处理 T 和 U 类型的值
Function<T, R> T R apply 有一个 T 类型参数的函数
BiFurcation<T, U, R> T, U R apply 有 T 和 U 类型参数的函数
UnaryOperator<T> T T apply 类型 T 上的一元操作
BinaryOperator<T> T, T T apply 类型 T 上的二元操作
Perdicate<T> T boolean test 布尔值函数
BiPerdicate<T, U> T, U boolean test 有两个参数的布尔值函数

参考文献:

Lambda Expressions

Java 核心技术 卷一(第十版) —— 机械工业出版社

评论