在Java8中,增加了函数接口、Lambda和方法引用(method reference),使得创建函数对象变得很容易。与此同时,还增加了Stream API,为处理数据元素的序列提供了类库级别的支持。在本章中,将讨论如果最佳的利用这些机制。
根据以往的经验,是用带有单个抽象方法的接口(或者,几乎都不是抽象类)作为函数类型。表示函数或者要采取的动作片。自从1997年发布JDK1.1以来,创建函数对象的主要方式是通过匿名类(详见第24条)。下面是一个按照字符串的长度对字符串列表进行排序的代码片段,它用一个匿名类创建了排序的比较函数(加强排列顺序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
} });
匿名类满足了传统的面向对象的设计模式对函数对象的需求,最著名的有策略模式。Comparator接口代表一种排序的抽象策略;上述的匿名类则是为字符串排序的一种具体策略。但是,匿名类的烦琐使得在Java中进行函数编程的前景变得十分黯淡。
在Java8中,形成了“带有单个抽象方法的接口是特殊的,值得特殊对待”的观念。这些接口现在被称作函数接口(functional interface),Java允许利用Lambda表示式创建这些接口的实例。Lambda类似于匿名类的函数,但是比它简洁得多。以下是上述代码用Lambda代替匿名类之后的样子。样板代码没有了,其行为也十分明确:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
注意,Lambda的类型(Comparator删除所有Lambda参数的类型吧,除非它们的存在能够使程序变得更加清晰
。如果编译器产生一条错误消息,告诉你无法推导出Lambda参数的类型,那么你就指定类型。有时候还需要转换返回值或者整个Lambda表达式,但是着这种情况很少见。
关于类型推导应该增加一条警告。第26条告诉你不要使用原生态类型,第29条说过要支持泛型类型,第30条说过要支持泛型方法。在Lambda时,这条建议确实非常重要,因为编译器是从泛型获取到的以执行类型推导的大部分类型信息的。如果你没有提供这些信息,编译器就无法进行类型推导,你就必须在Lambda中手工指定类型,这样极大的增加了它们的烦琐程度。如果上述代码片段中的变量words声明为原生态类型List,而不是参数化的类型List
当然,如果用比较器构造方法代替 Lambda,有时这个代码片段中的比较器还会更加简练:
Collections.sort(words, comparingInt(String::length));
事实上,如果利用Java8在List接口中添加的sort方法,这个代码片段还可以更加简短一点:
words.sort(comparingInt(String::length));
Java8增加了Lambda之后,使得之前不能使用函数对象的地方现在也能使用了。例如,以第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条可知,枚举实例域优先于特定于常量的类主体。Lambda使得利用前者实现特定于常量的行为变得比用后者来的更加容易了。只要给每个枚举常量的构造器传递一个实现其行为的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);}
}
注意,这里给Lambda使用了DoubleBinaryOperator接口,代表枚举常量的行为。这是在java.util.function(详见第44条)中预定义的众多函数接口之一。它表示一个带有两个double参数的函数,并返回一个double结果。
看看基于Lambda的Operation枚举,你可能会想,特定于常量的方法主体已经形同虚设了,但是实际并非如此。与方法和类不同的是,Lambda没有名称和文档;如果一个计算本身不是自描述的,或者超出了几行,那就不要把它放在一个Lambda中
。对于Lambda而言,一行是最理想的,三行是合理的最大极限。如果违背了这个规则,可能对程序的可读性造成严重的危害。如果Lambda很长或者难以阅读,要么找一种方法将它简化,要么重构程序来消除它。而且,传入枚举构造器的参数是在静态的环境中计算的。因而,枚举构造器中的Lambda无法访问枚举的实例成员。如果枚举类型带有难以理解的特定于常量的行为,或者无法在几行之内实现,又或者需要访问实例域方法,那么特定于常量的类主体仍然是首选。
同样的,你可能会认为,在Lambda时代,匿名类已经过时了。这种想法比较接近事实,但是仍有一些工作用Lambda无法完成,只能用匿名类才能完成,Lambda限于函数接口,如果想创建抽象类的实例,可以用匿名类来完成,而不是用Lambda。同样的,可以用匿名类为带有多个抽象方法的接口创建实例。最后一点,Lambda无法获得对自身的引用。在Lambda中,关键字this是指外围实例,这个通常正是你想要的。在匿名类中,关键字this是指匿名类的实例。如果需要从函数对象的主体内部访问它,就必须使用匿名类。
Lambda与匿名类共享你无法可靠的通过实现来序列化和反序列化的属性。由此,``尽可能不要(除非迫不得已)序列化一个Lambda(或者匿名类实例)。如果想要可序列化的函数对象,如Comparator,就使用私有静态嵌套类(详见第24条)的实例。
总而言之,从Java8开始,Lambda就成了表示小函数对象的最佳方式。千万不要给函数对象使用匿名类,除非必须创建非函数接口的类型和实例
。同时,还要记住,Lambda使得表示小函数对象变得如此轻松,因此打开了之前从未实践过的在Java中进行函数编程的大门。