Effective Java 第三版读书笔记——条款 42:lambda 表达式优于匿名类
以往,具有单一抽象方法的接口(或者很少使用的抽象类)被用作函数类型。它们的实例(称为函数对象)表示函数(functions)或行动(actions)。自从 JDK 1.1 于 1997 年发布以来,创建函数对象的主要手段就是匿名类(条款 24)。下面是一段代码片段,按照字符串长度顺序对列表进行排序,使用匿名类创建排序的比较方法(强制排序顺序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式。比较器接口表示排序的抽象策略,上面的匿名类是排序字符串的具体策略。然而,匿名类的冗长使得 Java 中的函数式编程不那么吸引人。
在 Java 8 中,语言形式化了这样的概念——即使用单个抽象方法的接口是特别的,应该得到特别的对待。这些接口现在称为函数式接口,并且 Java 语言允许你使用 lambda 表达式来创建这些接口的实例。Lambdas 在功能上与匿名类相似,但更为简洁。下面的代码使用 lambdas 替换上面的匿名类:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
请注意,代码中不存在 lambda(Comparator <String>
),也不存在其参数(s1 和 s2,都是 String 类型)及其返回值(int)的类型。编译器使用称为类型推断的过程从上下文中推导出这些类型。在某些情况下,编译器将无法确定类型,那么你必须指定它们。类型推断的规则很复杂,很少有程序员详细了解这些规则,但没关系。省略所有 lambda 参数的类型,除非它们的存在使你的程序更清晰。如果编译器生成一个错误,告诉你它不能推断出 lambda 参数的类型,那么指定它。有时你可能不得不强制转换返回值或整个 lambda 表达式,但这很少见。
关于类型推断需要注意一点。条款 26 告诉你不要使用原始类型,条款 29 告诉你偏好泛型类型,条款 30 告诉你偏好泛型方法。当使用 lambda 表达式时,这个建议是非常重要的,因为编译器获得了大部分允许它从泛型进行类型推断的类型信息。如果你没有提供这些信息,编译器将无法进行类型推断,你必须在 lambdas 中手动指定类型,这将大大增加它们的冗余度。举例来说,如果变量被声明为原始类型 List 而不是参数化类型 List <String>
,则上面的代码片段将不会编译。
顺便提一句,如果使用比较器构造方法代替 lambda,则代码中的比较器可以变得更加简洁(条款14):
Collections.sort(words, comparingInt(String::length));
实际上,通过利用添加到 Java 8 中的 List 接口的 sort 方法,可以使代码变得更简短:
words.sort(comparingInt(String::length));
将 lambdas 添加到 Java 语言中,使得使用函数对象变得非常实用。例如,考虑条款 34 中的 Operation
枚举类型。由于每个枚举都需要不同的 apply
方法,所以我们使用了特定于常量的类主体,并在每个枚举常量中重写了 apply
方法。为了唤醒你的记忆,下面是之前的代码:
// Enum type with constant-specific class bodies & data (Item 34)
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
public abstract double apply(double x, double y);
}
条款 34 说,枚举实例属性比常量特定的类主体更可取。Lambdas 可以很容易地使用前者而不是后者来实现常量特定的行为。仅仅将实现每个枚举常量行为的 lambda 传递给它的构造方法。构造方法将 lambda 存储在实例属性中,apply
方法将调用转发给 lambda。由此产生的代码比原始版本更简单,更清晰:
// Enum with function object fields & constant-specific behavior
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
请注意,我们为 lambdas 使用 DoubleBinaryOperator
接口来表示枚举常量行为。这是 java.util.function
中许多预定义的函数接口之一(条款 44)。它表示一个函数,它接受两个 double 类型参数并返回 double 类型的结果。
看看基于 lambda 的 Operation
枚举,你可能会认为常量特定的方法体已经失去了它们的用处,但事实并非如此。与方法和类不同,lambda 没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入 lambda 表达式中。一行代码对于 lambda 来说是理想的,三行代码是最大值。如果违反这一规定,可能会严重损害程序的可读性。如果一个 lambda 很长或很难阅读,要么找到一种方法来简化它,要么重构你的程序来消除它。此外,传递给枚举构造方法的参数在静态上下文中进行评估。因此,枚举构造方法中的 lambda 表达式不能访问枚举的实例成员。如果枚举类型具有难以理解的常量特定行为,无法在几行内实现,或者需要访问实例属性或方法,那么常量特定的类主体仍然是行之有效的方法。
同样,你可能会认为匿名类在 lambda 时代已经过时了。这接近事实,但有些事情你可以用匿名类来做,却不能用 lambdas 做。Lambda 仅限于函数式接口。如果你想创建一个抽象类的实例,你可以使用匿名类来实现,但不能使用 lambda。同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。最后,lambda 不能获得对自身的引用。在 lambda 中,this 关键字引用外部类,这通常是你想要的。在匿名类中,this 关键字引用匿名类实例。如果你需要从其内部访问函数对象,则必须使用匿名类。
Lambdas 与匿名类都无法可靠地序列化和反序列化。因此,应该很少(如果有的话)序列化一个 lambda(或一个匿名类实例)。如果有一个想要进行序列化的函数对象,比如一个 Comparator,那么使用一个私有静态嵌套类的实例(条款 24)。
综上所述,从 Java 8 开始,lambda 是迄今为止表示小函数对象的最佳方式。除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。另外,请记住,lambda 表达式使代表小函数对象变得如此简单,以至于它为函数式编程技术打开了一扇门,这些技术在以前的 Java 中并不实用。