3.3 利用reduce方法实现归约操作

问题

用户希望通过流操作生成单一值。

方案

使用 reduce 方法对每个元素进行累加计算。

讨论

Java 的函数式范式经常采用“映射 - 筛选 - 归约”(map-filter-reduce)的过程处理数据。首先,map 操作将一种类型的流转换为另一种类型(如通过调用 length 方法将 String 流转换为 int 流)。接下来,filter 操作产生一个新的流,它仅包含所需的元素(如长度小于某个阈值的字符串)。最后,通过终止操作从流中生成单个值(如长度的总和或均值)。

  • 内置归约操作

基本类型流 IntStreamLongStreamDoubleStream 定义了多种内置在 API 中的归约操作。

例如,表 3-1 列出了 IntStream 接口定义的归约操作。

表3-1:IntStream接口定义的归约操作

方法返回类型 averageOptionalDouble countlong maxOptionalInt minOptionalInt sumint summaryStatisticsIntSummaryStatistics collect(Supplier supplier, ObjIntConsumer accumulator, BiConsumer combiner)R reduceint, OptionalInt

sumcountmaxminaverage 等归约操作的用途不言自明。有趣的是,如果流中没有元素(如经过筛选操作后),结果为空或未定义,以上提到的某些方法将返回 Optional

例 3-14 显示了处理字符串集合长度的归约操作。

例 3-14 IntStream 接口的归约操作

  1. String[] strings = "this is an array of strings".split(" ");
  2. long count = Arrays.stream(strings)
  3. .map(String::length)
  4. .count();
  5. System.out.println("There are " + count + " strings");
  6.  
  7. int totalLength = Arrays.stream(strings)
  8. .mapToInt(String::length)
  9. .sum();
  10. System.out.println("The total length is " + totalLength);
  11.  
  12. OptionalDouble ave = Arrays.stream(strings)
  13. .mapToInt(String::length)
  14. .average();
  15. System.out.println("The average length is " + ave);
  16.  
  17. OptionalInt max = Arrays.stream(strings)
  18. .mapToInt(String::length)
  19. .max();
  20.  
  21. OptionalInt min = Arrays.stream(strings)
  22. .mapToInt(String::length)
  23. .min();
  24.  
  25. System.out.println("The max and min lengths are " + max + " and " + min);

countStream 接口定义的一种方法,因此无须将其映射给 IntStream

sumaverage 方法仅用于处理基本类型流

❸ 不带 Comparatormaxmin 方法仅用于处理基本类型流上述程序的打印结果如下:

  1. There are 6 strings
  2. The total length is 22
  3. The average length is OptionalDouble[3.6666666666666665]
  4. The max and min lengths are OptionalInt[7] and OptionalInt[2]

注意,averagemaxmin 方法返回 Optional,因为原则上可以通过应用一个筛选器来删除流中的所有元素。

count 方法相当有趣,相关讨论请参见范例 3.7。

Stream 接口定义了 max(Comparator)min(Comparator) 方法,其中比较器用于确定最大元素和最小元素。而在 IntStream 接口中,由于比较操作采用整数的自然顺序完成,两种方法的重载形式均不需要参数。

有关 summaryStatistics 方法的讨论请参见范例 3.8。

表 3-1 中列出的最后两种归约操作 collectreduce 值得进一步讨论。collect 方法的应用贯穿全书,其作用是将流转换为集合,通常与 Collectors 类定义的某种静态辅助方法配合使用(如 toListtoSet)。但是,无法在基本类型流中使用 collect 方法的三参数形式,即传入三个参数,分别是用于填充的集合、为集合添加单个元素的累加器以及为集合添加多个元素的组合器。有关这种形式的讨论请参见范例 3.2。

  • 基本归约实现

在实际接触到 reduce 方法之前,这种方法看起来可能不太直观。

IntStream 接口定义了 reduce 方法的两种重载形式:

  1. OptionalInt reduce(IntBinaryOperator op)
  2. int reduce(int identity, IntBinaryOperator op)

第一条语句传入 IntBinaryOperator 并返回 OptionalInt,第二条语句需要提供 identityint 型)以及 IntBinaryOperator

读者或许还记得 java.util.function.BiFunction 接口,它传入两个参数并返回一个值,三者的类型可以不同。如果输入类型和返回类型相同,则函数为 BinaryOperator(如 Math.max)。注意,IntBinaryOperator 属于 BinaryOperator,其输入和输出类型均为 int

那么,在不使用 sum 的情况下,如何实现整数的求和呢?一种方案是利用 reduce 方法,如例 3-15 所示。

例 3-15 利用 reduce 方法求和

  1. int sum = IntStream.rangeClosed(1, 10)
  2. .reduce((x, y) -> x + y).orElse(0);

sum 的值为 55

3.3 利用reduce方法实现归约操作 - 图1 编写代码时,通常采用垂直方式安排流的流水线(stream pipeline),这是基于流畅(fluent)API 的一种方案,其中一个方法的结果将作为下一个方法的目标。在本例中,因为 reduce 方法返回的不是流,所以将 orElse 置于同一行(而非另起一行),它不属于流水线的一部分。不过这只是为了方便起见,读者可以根据需要使用任何格式。

在本例中,IntBinaryOperator 由 lambda 表达式提供,它传入两个 int 型数据并返回二者之和。不难想象,如果为 IntBinaryOperator 添加一个筛选器,流是可以为空的,其结果是 OptionalInt。之后的 orElse 方法表明,如果流中没有元素,返回值应该为 0。

在 lambda 表达式中,可以将二元运算符的第一个参数视为累加器,第二个参数视为流中每个元素的值。通过逐一打印各个元素能很容易理解这一点,如例 3-16 所示。

例 3-16 打印 xy 的值

  1. int sum = IntStream.rangeClosed(1, 10)
  2. .reduce((x, y) -> {
  3. System.out.printf("x=%d, y=%d%n", x, y);
  4. return x + y;
  5. }).orElse(0);

输出如例 3-17 所示。

例 3-17 逐一打印每个值的输出

  1. x=1, y=2
  2. x=3, y=3
  3. x=6, y=4
  4. x=10, y=5
  5. x=15, y=6
  6. x=21, y=7
  7. x=28, y=8
  8. x=36, y=9
  9. x=45, y=10
  10.  
  11. sum=55

观察以上输出可知,x 和 y 的初始值是范围内的前两个值。二元运算符返回的值在下一次迭代时变为 x(累加器)的值,而 y 依次传入流的每个值。

那么,如果我们希望先处理每个数字,然后再求和呢?例如,在求和之前将所有的数字增加一倍 2。我们可能会写出如例 3-18 所示的代码,不过代码看似正确,实则有误。

例 3-18 在求和过程中将值增加一倍(代码错误)

  1. int doubleSum = IntStream.rangeClosed(1, 10)
  2. .reduce((x, y) -> x + 2 * y).orElse(0);

doubleSum 的值为 109(少了 1)

从 1 到 10 的各个整数之和为 55,因此增加一倍后的值应为 110,但本例的计算结果却是 109。问题出在 reduce 方法的 lambda 表达式上:x 和 y 的初始值为 1 和 2(流的前两个值)。换言之,流的第一个值不会增加一倍。

可以采用 reduce 方法的重载形式解决这个问题,也就是为累加器传入一个初始值。正确的代码如例 3-19 所示。

例 3-19 在求和过程中将值倍增(代码正确)

  1. int doubleSum = IntStream.rangeClosed(1, 10)
  2. .reduce(0, (x, y) -> x + 2 * y);

doubleSum 的值为 110(这才是正确的值)

通过将累加器 x 的初始值设置为 0,y 的值被赋给流中的各个元素,从而实现所有元素增加一倍。例 3-20 显示了每次迭代时 x 和 y 的值。

例 3-20 每次迭代时 lambda 参数的值

  1. Acc=0, n=1
  2. Acc=2, n=2
  3. Acc=6, n=3
  4. Acc=12, n=4
  5. Acc=20, n=5
  6. Acc=30, n=6
  7. Acc=42, n=7
  8. Acc=56, n=8
  9. Acc=72, n=9
  10. Acc=90, n=10
  11.  
  12. sum=110

注意,当使用具有累加器初始值的 reduce 方法时,返回类型是 int 而非 OptionalInt

二元运算符的标识值

本范例中的示例将第一个参数称为累加器的初始值(initial value),不过方法签名将其称为标识值(identity value)。关键字 identity 表示应该为二元运算符提供一个值,以便与其他值结合时返回另一个值。加法操作的标识值为 0,乘法操作的标识值为 1,字符串拼接操作的标识值为空字符串。

本节讨论的求和操作并无不同,但需要注意的是,应将计划用作二元运算符的任何操作的标识值作为 reduce 方法的第一个参数,即为累加器内部的初始值。

Java 标准库提供了多种归约方法,但如果这些方法都无法直接解决开发中遇到的问题,不妨试试本节讨论的两种 reduce 方法。

  • Java标准库中的二元运算符

标准库引入的一些新方法使归约操作变得特别简单。例如,IntegerLongDouble 类都定义了 sum 方法,其作用就是对两数求和。Integer 类中 sum 方法的实现如下所示:

  1. public static int sum(int a, int b) {
  2. return a + b;
  3. }

那么,为什么要专门定义一种只为实现两个整数求和的方法呢?这是因为 sum 方法属于 BinaryOperator(更确切地说,属于 IntBinaryOperator),很容易就能用于 reduce 方法,如例 3-21 所示。

例 3-21 利用二元运算符执行归约操作

  1. int sum = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
  2. .reduce(0, Integer::sum);
  3. System.out.println(sum);

可以看到,无须使用 IntStream 就能得到相同的结果。Integer 类还定义了 maxmin 方法,它们也是二元运算符,用法与 sum 方法类似,如例 3-22 所示。

例 3-22 利用 reduce 方法查找最大值

  1. Integer max = Stream.of(3, 1, 4, 1, 5, 9)
  2. .reduce(Integer.MIN_VALUE, Integer::max);
  3. System.out.println("The max value is " + max);

max 的标识值为最小的整数

另一个有趣的例子是 String 类定义的 concat 方法,它仅传入一个参数,看起来不怎么像二元运算符。

  1. String concat(String str)

concat 方法可以用于 reduce 方法,如例 3-23 所示。

例 3-23 利用 reduce 方法拼接流中的字符串

  1. String s = Stream.of("this", "is", "a", "list")
  2. .reduce("", String::concat);
  3. System.out.println(s);

➊ 打印 thisisalist

上述代码之所以能执行,是因为通过类名(如 String::concat)使用方法引用时,第一个参数将作为 concat 的目标,而第二个参数是 concat 的参数。由于结果返回的是 String,目标、参数与返回类型均为同一类型,可以将其视为 reduce 方法的二元运算符。

concat 方法能大大缩减代码的尺寸,浏览 API 时请谨记在心。

使用收集器

尽管 concat 方法可行,但效率很低,因为字符串拼接操作会频繁创建和销毁对象。更好的方案是采用带有 Collectorcollect 方法。

Stream 接口定义了 collect 方法的一种重载形式,它传入三个参数,分别是用于创建集合的 Supplier,为集合添加单个元素的 BiConsumer 以及合并两个集合的 BiConsumer。对字符串而言,StringBuilder 是一种天然的累加器。相应的 collect 实现如例 3-24 所示。

例 3-24 利用 StringBuilder 收集字符串

  1. String s = Stream.of("this", "is", "a", "list")
  2. .collect(() -> new StringBuilder(),
  3. (sb, str) -> sb.append(str),
  4. (sb1, sb2) -> sb1.append(sb2))
  5. .toString();

❶ 结果 Supplier

❷ 为结果添加一个值

❸ 合并两个结果

可以通过方法引用简化上述代码,如例 3-25 所示。

例 3-25 利用方法引用收集字符串

  1. String s = Stream.of("this", "is", "a", "list")
  2. .collect(StringBuilder::new,
  3. StringBuilder::append,
  4. StringBuilder::append)
  5. .toString();

不过,最简单的方案是采用 Collectors 工具类定义的 joining 方法,如例 3-26 所示。

例 3-26 利用 Collectors.joining 连接字符串

  1. String s = Stream.of("this", "is", "a", "list")
  2. .collect(Collectors.joining());

joining 方法的重载形式传入字符串定界符,其简单易行无出其右。相关讨论请参见范例 4.2。

  • reduce方法的最一般形式

reduce 方法的第三种形式如下:

  1. <U> U reduce(U identity,
  2. BiFunction<U,? super T,U> accumulator,
  3. BinaryOperator<U> combiner)

这种形式略显复杂,通常可以采用更简单的手段实现相同的目标。我们以一个示例说明这种形式的应用。

例 3-27 定义了一个 Book 类,它只有一个 ID(整数)和一个标题(字符串)。

例 3-27 简单的 Book

  1. public class Book {
  2. private Integer id;
  3. private String title;
  4.  
  5. // 构造函数、getter和setter、toString、equals、hashCode…
  6. }

假设存在一个图书列表,我们希望将列表中的图书添加到某个 Map。其中键为 ID,值为图书本身。

3.3 利用reduce方法实现归约操作 - 图2 采用 Collectors.toMap 方法解决这个问题更容易,相关讨论请参见范例 4.3。之所以以此为例,是因为它比较简单,有助于读者理解相对复杂的 reduce 方法。

例 3-28 显示了一种解决方案。

例 3-28 将 Book 添加到 Map

  1. HashMap<Integer, Book> bookMap = books.stream()
  2. .reduce(new HashMap<Integer, Book>(),
  3. (map, book) -> {
  4. map.put(book.getId(), book);
  5. return map;
  6. },
  7. (map1, map2) -> {
  8. map1.putAll(map2);
  9. return map1;
  10. });
  11.  
  12. bookMap.forEach((k,v) -> System.out.println(k + ": " + v));

putAll 的标识值

❷ 利用 put 将一本书添加到 Map

❸ 利用 putAll 合并多个 Map

我们从 reduce 方法的最后一个参数开始分析,这是最简单的。

第三个参数是 combiner,它必须是 BinaryOperator。在本例中,提供的 lambda 表达式传入两个映射,它将第二个映射中的所有键复制到第一个映射,再返回第一个映射。如果 putAll 方法能返回映射,lambda 表达式会更简单,可惜事实并非如此。仅当 reduce 方法并行完成时,组合器才有意义,因为我们需要将范围内每一部分产生的映射合并在一起。

第二个参数是一个函数,用于将一本书添加到 Map。类似地,如果 Mapput 方法在新条目添加完毕后能返回 Map,函数会更简单。

第一个参数是 combiner 函数的标识值。在本例中,标识值是一个为空的 Map,因为该标识值与其他任何 Map 结合后返回的是其他 Map

例 3-28 的输出如下:

  1. 1: Book{id=1, title='Modern Java Recipes'}
  2. 2: Book{id=2, title='Making Java Groovy'}
  3. 3: Book{id=3, title='Gradle Recipes for Android'}

归约操作是函数式编程习惯用法的基础。在不少常见的用例中,Stream 接口都提供了相应的内置方法,如 sumcollect(Collectors.joining(',')。本范例也讨论了 reduce 方法的直接应用,或许能对读者编写自定义方法有所启发。

一旦掌握 Java 8 中 reduce 方法的用法,读者就能举一反三,理解如何在其他语言中使用相同的操作。即便这种操作被冠以不同的名称(如 Groovy 将其称为 inject,Scala 将其称为 fold),其原理并无差别。

2可以采用多种方式解决这个问题,包括将 sum 方法返回的值增加一倍。这里介绍的方案演示了如何使用双参数形式的 reduce 方法。

另见

有关将 POJO 列表转换为 Map 的简便方案请参见范例 4.3,有关汇总统计(summary statistics)的讨论请参见范例 3.8,有关收集器的讨论请参见第 4 章。