前言
前面知道了如何编写 Lambda 表达式,下面将详细阐述另一个重要方面:如何使用 Lambda 表达式。即使不需要编写像 Stream 这样重度使用函数式编程风格的类库,学会如何使用 Lambda 表达式也是非常重要的。即使一个最简单的应用,也可能会因为代码即数据的函数式编程风格而受益。
Java 8 中的另一个变化是引入了默认方法和接口的静态方法,它改变了人们认识类库的方式,接口中的方法也可以包含代码体了。
本文还对前面疏漏的知识点进行补充,比如,Lambda 表达式方法重载的工作原理、基本类型的使用方法等。使用 Lambda 表达式编写程序时,掌握这些知识非常重要。
一、基本类型
以上部分还没有用到基本类型。在 Java 中,有一些相伴的类型,比如 int 和 Integer——前者是基本类型,后者是装箱类型。基本类型内建在语言和运行环境中,是基本的程序构建模块;而装箱类型属于普通的 Java 类,只不过是对基本类型的一种封装。
Java 的泛型是基于对泛型参数类型的擦除——换句话说,假设它是 Object 对象的实例——因此只有装箱类型才能作为泛型参数。这就解释了为什么在 Java 中想要一个包含整型值的列表 List<int>,实际上得到的却是一个包含整型对象的列表 List<Integer>。
麻烦的是,由于装箱类型是对象,因此在内存中存在额外开销。比如,整型在内存中占用 4 字节,整型对象却要占用 16 字节。这一情况在数组上更加严重,整型数组中的每个元素只占用基本类型的内存,而整型对象数组中,每个元素都是内存中的一个指针,指向 Java堆中的某个对象。在最坏的情况下,同样大小的数组,Integer[] 要比 int[] 多占用 6 倍内存。
将基本类型转换为装箱类型,称为装箱,反之则称为拆箱,两者都需要额外的计算开销。对于需要大量数值运算的算法来说,装箱和拆箱的计算开销,以及装箱类型占用的额外内存,会明显减缓程序的运行速度。
为了减小这些性能开销,Stream 类的某些方法对基本类型和装箱类型做了区分。如下所示的高阶函数 mapToLong 和其他类似函数即为该方面的一个尝试。在 Java 8 中,仅对整型、长整型和双浮点型做了特殊处理,因为它们在数值计算中用得最多,特殊处理后的系统性能提升效果最明显。
对基本类型做特殊处理的方法在命名上有明确的规范。如果方法返回类型为基本类型,则在基本类型前加 To,如上所示的 ToLongFunction。如果参数是基本类型,则不加前缀只需类型名即可,如下所示的 LongFunction。如果高阶函数使用基本类型,则在操作后加后缀 To 再加基本类型,如 mapToLong。
这些基本类型都有与之对应的 Stream,以基本类型名为前缀,如 LongStream。事实上,mapToLong 方法返回的不是一个一般的 Stream,而是一个特殊处理的 Stream。在这个特殊的 Stream 中,map 方法的实现方式也不同,它接受一个 LongUnaryOperator 函数,将一个长整型值映射成另一个长整型值,如下所示。通过一些高阶函数装箱方法,如mapToObj,也可以从一个基本类型的 Stream 得到一个装箱后的 Stream,如 Stream<Long>
如有可能,应尽可能多地使用对基本类型做过特殊处理的方法,进而改善性能。这些特殊的 Stream 还提供额外的方法,避免重复实现一些通用的方法,让代码更能体现出数值计算的意图。
IntSummaryStatistics intSummaryStatistics = artists.stream().mapToInt(s -> s.getFrom().length()).summaryStatistics();
System.out.println(intSummaryStatistics.getMax());
无需手动计算这些信息,这里使用对基本类型进行特殊处理的方法 mapToInt,将每首曲目映射为曲目长度。因为该方法返回一个IntStream 对象,它包含一个 summaryStatistics 方法,这个方法能计算出各种各样的统计值,如 IntStream 对象内所有元素中的最小值、最大值、平均值以及数值总和。
这些统计值在所有特殊处理的 Stream,如 DoubleStream、LongStream 中都可以得出。如无需全部的统计值,也可分别调用 min、max、average 或 sum 方法获得单个的统计值,同样,三种基本类型对应的特殊 Stream 也都包含这些方法。
二、重载解析
在 Java 中可以重载方法,造成多个方法有相同的方法名,但签名确不一样。这在推断参数类型时会带来问题,因为系统可能会推断出多种类型。这时,javac 会挑出最具体的类型。
BinaryOperator 是一种特殊的 BiFunction 类型,参数的类型和返回值的类型相同。比如,两个整数相加就是一个 BinaryOperator。Lambda 表达式的类型就是对应的函数接口类型,因此,将 Lambda 表达式作为参数传递时,情况也依然如此。操作时可以重载一个方法,分别接受 BinaryOperator 和该接口的一个子类作为参数。调用这些方法时,Java 推导出的 Lambda 表达式的类型正是最具体的函数接口的类型。如下,输出的是IntegerBinaryOperator。
public interface IntegerBiFunction extends BinaryOperator<Integer> {
}
private void overloadedMethod(BinaryOperator<Integer> Lambda) {
System.out.print("BinaryOperator");
}
private void overloadedMethod(IntegerBiFunction Lambda) {
System.out.print("IntegerBinaryOperator");
}
当然,同时存在多个重载方法时,哪个是“最具体的类型”可能并不明确。
public interface IntPredicate {
}
private void overloadedMethod(Predicate<Integer> predicate) {
System.out.print("Predicate");
}
private void overloadedMethod(IntPredicate predicate) {
System.out.print("IntPredicate");
}
传入 overloadedMethod 方法的 Lambda 表达式和两个函数接口 Predicate、IntPredicate 在类型上都是匹配的。在这段代码块中,两种情况都定义了相应的重载方法,这时,javac就无法编译,在错误报告中显示 Lambda 表达式被模糊调用。IntPredicate 没有继承Predicate,因此编译器无法推断出哪个类型更具体。总而言之,Lambda 表达式作为参数时,其类型由它的目标类型推导得出,推导过程遵循如下规则
:
- 如果只有一个可能的目标类型,由相应函数接口里的参数类型推导得出;
- 如果有多个可能的目标类型,由最具体的类型推导得出;
- 如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型。
三、@FunctionalInterface
前面虽已讨论过函数接口定义的标准,但未提及 @FunctionalInterface 注释。事实上,每个用作函数接口的接口都应该添加这个注释。
这究竟是什么意思呢? Java 中有一些接口,虽然只含一个方法,但并不是为了使用Lambda 表达式来实现的。比如,有些对象内部可能保存着某种状态,使用带有一个方法的接口可能纯属巧合。java.lang.Comparable 和 java.io.Closeable 就属于这样的情况。
如果一个类是可比较的,就意味着在该类的实例之间存在某种顺序,比如字符串中的字母顺序。人们通常不会认为函数是可比较的,如果一个东西既没有属性也没有状态,拿什么比较呢?
一个可关闭的对象必须持有某种打开的资源,比如一个需要关闭的文件句柄。同样,该接口也不能是一个纯函数,因为关闭资源是更改状态的另一种形式。
和 Closeable 和 Comparable 接口不同,为了提高 Stream 对象可操作性而引入的各种新接口,都需要有 Lambda 表达式可以实现它。它们存在的意义在于将代码块作为数据打包起来。因此,它们都添加了 @FunctionalInterface 注释。
该注释会强制 javac 检查一个接口是否符合函数接口的标准。如果该注释添加给一个枚举类型、类或另一个注释,或者接口包含不止一个抽象方法,javac 就会报错。重构代码时,使用它能很容易发现问题。
四、默认方法
Collection 接口中增加了新的 stream 方法,如何能让 MyCustomList 类在不知道该方法的情况下通过编译? Java 8 通过如下方法解决该问题:Collection 接口告诉它所有的子类:“如果你没有实现 stream 方法,就使用我的吧。”接口中这样的方法叫作默认方法,在任何接口中,无论函数接口还是非函数接口,都可以使用该方法。
Iterable 接口中也新增了一个默认方法:forEach,该方法功能和 for 循环类似,但是允许
用户使用一个 Lambda 表达式作为循环体。
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
如果已经习惯了通过调用接口方法来使用 Lambda 表达式的方式,那么这个例子理解起来
就相当简单。它使用一个常规的 for 循环遍历 Iterable 对象,然后对每个值调用 accept
方法。
既然如此简单,为何还要单独提出来呢?重点就在于代码段前面的新关键字 default。这个关键字告诉 javac 用户真正需要的是为接口添加一个新方法。除了添加了一个新的关键字,默认方法在继承规则上和普通方法也略有区别。
和类不同,接口没有成员变量,因此默认方法只能通过调用子类的方法来修改子类本身,避免了对子类的实现做出各种假设。
五、Optional
reduce 方法的一个重点尚未提及:reduce 方法有两种形式,一种如前面出现的需要有一个初始值,另一种变式则不需要有初始值。没有初始值的情况下,reduce 的第一步使用Stream 中的前两个元素。有时,reduce 操作不存在有意义的初始值,这样做就是有意义的,此时,reduce 方法返回一个 Optional 对象。
Optional 是为核心类库新设计的一个数据类型,用来替换 null 值。人们对原有的 null 值有很多抱怨,甚至连发明这一概念的 Tony Hoare 也是如此,他曾说这是自己的一个“价值连城的错误”。作为一名有影响力的计算机科学家就是这样:虽然连一毛钱也见不到,却也可以犯一个“价值连城的错误”。
人们常常使用 null 值表示值不存在,Optional 对象能更好地表达这个概念。使用 null 代表值不存在的最大问题在于 NullPointerException。一旦引用一个存储 null 值的变量,程序会立即崩溃。使用 Optional 对象有两个目的:首先,Optional 对象鼓励程序员适时检查变量是否为空,以避免代码缺陷;其次,它将一个类的 API 中可能为空的值文档化,这比阅读实现代码要简单很多。
下面举例说明 Optional 对象的 API,从而切身体会一下它的使用方法。使用工厂方法 of,可以从某个值创建出一个 Optional 对象。Optional 对象相当于值的容器,而该值可以通过 get 方法提取。
Optional<String> a = Optional.of("a");
System.out.println(a.get());
Optional 对象也可能为空,因此还有一个对应的工厂方法 empty,另外一个工厂方法 ofNullable 则可将一个空值转换成 Optional 对象。同时展示了第三个方法 isPresent 的用法(该方法表示一个 Optional 对象里是否有值)
Optional emptyOptional = Optional.empty();
Optional alsoEmpty = Optional.ofNullable(null);
System.out.println(emptyOptional.isPresent());
System.out.println(alsoEmpty.isPresent());
使用 Optional 对象的方式之一是在调用 get() 方法前,先使用 isPresent 检查 Optional 对象是否有值。使用 orElse 方法则更简洁,当 Optional 对象为空时,该方法提供了一个备选值。如果计算备选值在计算上太过繁琐,即可使用 orElseGet 方法。该方法接受一个Supplier 对象,只有在 Optional 对象真正为空时才会调用。
System.out.println(emptyOptional.orElse("b"));
System.out.println(emptyOptional.orElseGet(() -> "c"));
总结
使用为基本类型定制的 Lambda 表达式和 Stream,如 IntStream 可以显著提升系统性能。使用为基本类型定制的 Lambda 表达式和 Stream,如 IntStream 可以显著提升系统性能。在一个值可能为空的建模情况下,使用 Optional 对象能替代使用 null 值。