Effective-Java学习笔记(六)

第七章 λ 表达式和流

42. λ 表达式优于匿名类

函数对象

在历史上,带有单个抽象方法的接口(或者抽象类,但这种情况很少)被用作函数类型。它们的实例(称为函数对象)表示函数或操作。自从 JDK 1.1 在 1997 年发布以来,创建函数对象的主要方法就是匿名类(Item-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 8 中官方化了一个概念,即具有单个抽象方法的接口是特殊的,应该得到特殊处理。这些接口现在被称为函数式接口

lambda 表达式

可以使用 lambda 表达式为函数式接口创建实例,lambda 表达式在功能上类似于匿名类,但更简洁

// Lambda expression as function object (replaces anonymous class)
Collections.sort(words,(s1, s2) -> Integer.compare(s1.length(), s2.length()));
  1. lambda 表达式的入参和返回值类型一般不写,由编译器做类型推断,类型能不写就不写。
    Item26告诉你不要用原始类型,Item29,30告诉你要用泛型和泛型方法,编译器从泛型中获得了大部分类型推断所需的类型信息
  2. lambda 表达式缺少名称和文档;如果一个算法较复杂,或者有很多行代码,不要把它放在 lambda 表达式中。 一行是理想的,三行是合理的最大值
  3. lambda 表达式仅限于函数式接口。如果想创建抽象类实例或者多个抽象方法的接口,只能用匿名类
  4. lambda 表达式编译后会成为外部类的一个私有方法,而匿名类会生成单独的class文件,所以 lambda 表达式的this是外部类实例,匿名类的this是匿名类实例
  5. lambda表达式和匿名类都不能可靠的序列化,如果想序列化函数对象,可以用私有静态嵌套类

43. 方法引用优于 λ 表达式

Java 提供了一种比 lambda 表达式更简洁的方法来生成函数对象:方法引用。下面这段代码的功能是,如果数字 1 不在映射中,则将其与键关联,如果键已经存在,则将关联值递增:

map.merge(key, 1, (count, incr) -> count + incr);

上面代码的 lambda 表达式作用是返回两个入参的和,在 Java 8 中,Integer(和所有其它基本类型的包装类)提供了一个静态方法 sum,它的作用完全相同,我们可以传入一个方法引用,并得到相同结果,同时减少视觉混乱:

map.merge(key, 1, Integer::sum);

函数对象的参数越多,方法引用就显得越简洁,但是 lambda 表达式指明了参数名,使得 lambda表达式比方法引用更容易阅读和维护,没有什么是方法引用能做而 lambda 表达式做不了的

许多方法引用引用静态方法,但是有四种方法不引用静态方法。其中两个是绑定和非绑定实例方法引用。在绑定引用中,接收对象在方法引用中指定。绑定引用在本质上与静态引用相似:函数对象接受与引用方法相同的参数。在未绑定引用中,在应用函数对象时通过方法声明参数之前的附加参数指定接收对象。在流管道中,未绑定引用通常用作映射和筛选函数(Item-45)。最后,对于类和数组,有两种构造函数引用。构造函数引用用作工厂对象。五种方法参考文献汇总如下表:

Method Ref Type Example Lambda Equivalent
Static Integer::parseInt str ->
Bound Instant.now()::isAfter Instant then =Instant.now(); t ->then.isAfter(t)
Unbound String::toLowerCase str ->str.toLowerCase()
Class Constructor TreeMap<K,V>::new () -> new TreeMap<K,V>
Array Constructor int[]::new len -> new int[len]

总之,方法引用通常为 lambda 表达式提供了一种更简洁的选择。如果方法引用更短、更清晰,则使用它们;如果没有,仍然使用 lambda 表达式。

44. 优先使用标准函数式接口

java.util.function 包提供了大量的标准函数接口。优先使用标准函数式接口而不是自己写。 通过减少 API ,使得你的 API 更容易学习,并将提供显著的互操作性优势,因为许多标准函数式接口提供了有用的默认方法

java.util.function 中有 43 个接口。不能期望你记住所有的接口,但是如果你记住了 6 个基本接口,那么你可以在需要时派生出其余的接口。基本接口操作对象引用类型。Operator 接口表示结果和参数类型相同的函数。Predicate 接口表示接受参数并返回布尔值的函数。Function 接口表示参数和返回类型不同的函数。Supplier 接口表示一个无参并返回(或「供应」)值的函数。最后,Consumer 表示一个函数,该函数接受一个参数,但不返回任何内容,本质上是使用它的参数。六个基本的函数式接口总结如下:

Interface Function Signature Example
UnaryOperator<T> T apply(T t) String::toLowerCase
BinaryOperator<T> T apply(T t1, T t2) BigInteger::add
Predicate<T> boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier<T> T get() Instant::now
Consumer<T> void accept(T t) System.out::println

还有 6 个基本接口的 3 个变体,用于操作基本类型 int、long 和 double。它们的名称是通过在基本接口前面加上基本类型前缀而派生出来的。例如,一个接受 int 的 Predicate 就是一个 IntPredicate,一个接受两个 long 值并返回一个 long 的二元操作符就是一个 LongBinaryOperator。没有变体类型是参数化的除了由返回类型参数化的 Function 变体外。例如,LongFunction<int[]> 使用 long 并返回一个 int[]。

Function 接口还有 9 个额外的变体,在结果类型为基本数据类型时使用。源类型和结果类型总是不同的,因为入参只有一个且与出参类型相同的函数本身都是 UnaryOperator。如果源类型和结果类型都是基本数据类型,则使用带有 SrcToResult 的前缀函数,例如 LongToIntFunction(六个变体)。如果源是一个基本数据类型,而结果是一个对象引用,则使用带前缀 SrcToObj 的 Function 接口,例如 DoubleToObjFunction(三个变体)。

三个基本函数式接口有两个参数版本,使用它们是有意义的:BiPredicate<T,U>BiFunction<T,U,R>BiConsumer<T,U>。也有 BiFunction 变体返回三个相关的基本类型:ToIntBiFunction<T,U>ToLongBiFunction<T,U>ToDoubleBiFunction<T,U>。Consumer 有两个参数变体,它们接受一个对象引用和一个基本类型:ObjDoubleConsumer<T>ObjIntConsumer<T>ObjLongConsumer<T>。总共有9个基本接口的双参数版本。

最后是 BooleanSupplier 接口,它是 Supplier 的一个变体,返回布尔值。这是在任何标准函数接口名称中唯一显式提到布尔类型的地方,但是通过 Predicate 及其四种变体形式支持布尔返回值。前面描述的 BooleanSupplier 接口和 42 个接口占了全部 43 个标准函数式接口。

总之,既然 Java 已经有了 lambda 表达式,你必须在设计 API 时考虑 lambda 表达式。在输入时接受函数式接口类型,在输出时返回它们。一般情况下,最好使用 java.util.function 中提供的标准函数式接口,但请注意比较少见的一些情况,在这种情况下,你最好编写自己的函数式接口。

45. 明智地使用流

Java8添加了流API,用来简化序列或并行执行批量操作,API有两个关键的抽象:流(表示有限或无限的数据元素序列)和流管道(表示对这些元素的多阶段计算)。

流中的元素可以来自任何地方。常见的源包括集合、数组、文件、正则表达式的 Pattern 匹配器、伪随机数生成器和其他流。流中的数据元素可以是对象的引用或基本数据类型。支持三种基本数据类型:int、long 和 double。

流管道

流管道由源流跟着零个或多个中间操作和一个终止操作组成。每个中间操作以某种方式转换流,例如将每个元素映射到该元素的一个函数,或者过滤掉不满足某些条件的所有元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同,也可能与输入流不同。终止操作对最后一次中间操作所产生的流进行最终计算,例如将其元素存储到集合中、返回特定元素、或打印其所有元素。

  1. 流管道的计算是惰性的:直到调用终止操作时才开始计算,并且对完成终止操作不需要的数据元素永远不会计算。这种惰性的求值机制使得处理无限流成为可能。
  2. 流 API 是流畅的:它被设计成允许使用链式调用将组成管道的所有调用写到单个表达式中。
  3. 谨慎使用流,全部都是流操作的代码可读性不高
  4. 谨慎命名 lambda 表达式的参数名以提高可读性,复杂表达式使用 helper 方法

流 VS 迭代

迭代代码使用代码块表示重复计算,流管道使用函数对象(通常是 lambda 表达式或方法引用)表示重复计算。

迭代优势:

  1. 代码块可以读取修改局部变量,lambda表达式只能读取final变量或实际上final变量(初始化后不再修改,编译器会帮我们声明为final)。
  2. 代码块可以控制迭代,包括return,break,continue,throw,但是lambda不行

流适合用在以下操作:

  1. 元素序列的一致变换
  2. 过滤元素序列
  3. 组合元素序列(例如添加它们,连接它们或计算它们的最小值)
  4. 聚合元素序列到一个集合中,例如按属性分组
  5. 在元素序列中搜索满足某些条件的元素

总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。许多任务最好通过结合这两种方法来完成。

46. 在流中使用无副作用的函数

考虑以下代码,它用于构建文本文件中单词的频率表

// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

这段代码可以得出正确答案,但它不是流代码,而是伪装成流代码的迭代代码。它比迭代代码更长,更难阅读,更难维护,问题出在:forEach修改了外部状态。正确使用的流代码如下:

// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

forEach 操作应该只用于报告流计算的结果,而不是执行计算

正确的流代码使用了 Collectors 的生成收集器的方法,将元素收集到集合中的方法有三种:toList()toSet()toCollection(collectionFactory)。它们分别返回 List、Set 和程序员指定的集合类型

Collectors其它方法大部分是将流收集到Map中。最简单的Map收集器是toMap(keyMapper, valueMapper),它接受两个函数,一个将流元素映射到键,另一个将流元素映射到值,Item34用到了这个收集器

// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum =Stream.of(values()).collect(toMap(Object::toString, e -> e));

这个收集器不能处理重复键的问题,我们可以加入第三个参数,即merge函数,处理重复键,它的参数类型是Map的值类型。我们还可以加入第四个参数用来指定特定的Map实现(如EnumMap或TreeMap)

除了toMap方法,groupingBy方法也将元素收集到Map中,键是类别,值是这个类别的所有元素的列表;groupingBy方法第二个参数是一个下游收集器,例如 counting() 作为下游收集器,最终的Map的键是类别,值是这个类别所有元素的数量;groupingBy也支持指定特定的Map实现

minBy和maxBy,它们接受一个Comparator并返回最小或最大元素;join方法将字符序列(如字符串)连接起来

总之,流管道编程的本质是无副作用的函数对象。这适用于传递给流和相关对象的所有函数对象。中间操作 forEach 只应用于报告由流执行的计算结果,而不应用于执行计算。为了正确使用流,你必须了解 collector。最重要的 collector 工厂是 toList、toSet、toMap、groupingBy 和 join。

47. 优先使用 Collection 而不是 Stream 作为返回类型

Stream 没有继承 Iterable,不能用 for-each 循环遍历。Collection 接口继承了 Iterable 而且提供了转换为流的方法,因此,Collection 或其适当的子类通常是公有返回序列的方法的最佳返回类型。

48. 谨慎使用并行流

如果流来自 Stream.iterate 或者中间操作 limit,并行化管道也不太可能提高其性能

通常,并行性带来的性能提升在 ArrayList、HashMap、HashSet 和 ConcurrentHashMap 实例上的流效果最好;int 数组和 long 数组也在其中。 这些数据结构的共同之处在于,它们都可以被精确且廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。另一个共同点是,当按顺序处理时,它们提供了极好的引用位置:顺序元素引用一起存储在内存中,这些引用的引用对象在内存中可能彼此不太接近,这减少了引用的位置。引用位置对于并行化批量操作非常重要,如果没有它,线程将花费大量时间空闲,等待数据从内存传输到处理器的缓存中。具有最佳引用位置的数据结构是基本数组,因为数据本身是连续存储在内存中的。

如果在终止操作中完成大量工作,并且该操作本质上是顺序的,那么管道的并行化效果有限。并行化的最佳终止操作是 reduce 方法或者预先写好的reduce 方法,包括min、max、count、and。anyMatch、allMatch 和 noneMatch 的短路操作也适用于并行性。流的 collect 方法执行的操作称为可变缩减,它们不是并行化的好候选,因为组合集合的开销是昂贵的。

并行化流不仅会导致糟糕的性能,包括活动失败;它会导致不正确的结果和不可预知的行为(安全故障)。

总之,不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性以及提高速度。不适当地并行化流的代价可能是程序失败或性能灾难。