Java 利用reduce方法实现归约操作,用户希望通过流操作生成单一值,使用 reduce
方法对每个元素进行累加计算。Java 的函数式范式经常采用“映射 – 筛选 – 归约”(map-filter-reduce
)的过程处理数据。首先,map
操作将一种类型的流转换为另一种类型(如通过调用 length
方法将 String
流转换为 int
流)。
Java 利用reduce方法实现归约操作 问题描述
用户希望通过流操作生成单一值。
Java 利用reduce方法实现归约操作 解决方案
使用 reduce
方法对每个元素进行累加计算。
Java 利用reduce方法实现归约操作 具体实例
Java 的函数式范式经常采用“映射 – 筛选 – 归约”(map-filter-reduce
)的过程处理数据。首先,map
操作将一种类型的流转换为另一种类型(如通过调用 length
方法将 String
流转换为 int
流)。接下来,filter
操作产生一个新的流,它仅包含所需的元素(如长度小于某个阈值的字符串)。最后,通过终止操作从流中生成单个值(如长度的总和或均值)。
1.内置归约操作
基本类型流 IntStream
、LongStream
和 DoubleStream
定义了多种内置在 API 中的归约操作。
例如,表 3-1 列出了 IntStream
接口定义的归约操作。
表3-1:IntStream
接口定义的归约操作
方法 | 返回类型 |
---|---|
average |
OptionalDouble |
count |
long |
max |
OptionalInt |
min |
OptionalInt |
sum |
int |
summaryStatistics |
IntSummaryStatistics |
collect(Supplier supplier, ObjIntConsumer accumulator, BiConsumer combiner) |
R |
reduce |
int, OptionalInt |
sum
、count
、max
、min
、average
等归约操作的用途不言自明。有趣的是,如果流中没有元素(如经过筛选操作后),结果为空或未定义,以上提到的某些方法将返回 Optional
。
例 3-14 显示了处理字符串集合长度的归约操作。
例 3-14
IntStream
接口的归约操作
String[] strings = "this is an array of strings".split(" ");
long count = Arrays.stream(strings)
.map(String::length) ➊
.count();
System.out.println("There are " + count + " strings");
int totalLength = Arrays.stream(strings)
.mapToInt(String::length) ➋
.sum();
System.out.println("The total length is " + totalLength);
OptionalDouble ave = Arrays.stream(strings)
.mapToInt(String::length) ➋
.average();
System.out.println("The average length is " + ave);
OptionalInt max = Arrays.stream(strings)
.mapToInt(String::length) ➋
.max(); ➌
OptionalInt min = Arrays.stream(strings)
.mapToInt(String::length) ➋
.min(); ➌
System.out.println("The max and min lengths are " + max + " and " + min);
❶ count
是 Stream
接口定义的一种方法,因此无须将其映射给 IntStream
❷ sum
和 average
方法仅用于处理基本类型流
❸ 不带 Comparator
的 max
和 min
方法仅用于处理基本类型流上述程序的打印结果如下:
There are 6 strings
The total length is 22
The average length is OptionalDouble[3.6666666666666665]
The max and min lengths are OptionalInt[7] and OptionalInt[2]
注意,average
、max
与 min
方法返回 Optional
,因为原则上可以通过应用一个筛选器来删除流中的所有元素。
count
方法相当有趣,相关讨论请参见范例获取元素数量。
Stream
接口定义了 max(Comparator)
和 min(Comparator)
方法,其中比较器用于确定最大元素和最小元素。而在 IntStream
接口中,由于比较操作采用整数的自然顺序完成,两种方法的重载形式均不需要参数。
有关 summaryStatistics
方法的讨论请参见范例汇总统计。
表3-1 中列出的最后两种归约操作 collect
和 reduce
值得进一步讨论。collect
方法的应用贯穿全书,其作用是将流转换为集合,通常与 Collectors
类定义的某种静态辅助方法配合使用(如 toList
或 toSet
)。但是,无法在基本类型流中使用 collect
方法的三参数形式,即传入三个参数,分别是用于填充的集合、为集合添加单个元素的累加器以及为集合添加多个元素的组合器。有关这种形式的讨论请参见范例 装箱流。
2.基本归约实现
在实际接触到 reduce
方法之前,这种方法看起来可能不太直观。
IntStream
接口定义了 reduce
方法的两种重载形式:
OptionalInt reduce(IntBinaryOperator op)
int reduce(int identity, IntBinaryOperator op)
第一条语句传入 IntBinaryOperator
并返回 OptionalInt
,第二条语句需要提供 identity
(int
型)以及 IntBinaryOperator
。
读者或许还记得 java.util.function.BiFunction
接口,它传入两个参数并返回一个值,三者的类型可以不同。如果输入类型和返回类型相同,则函数为 BinaryOperator
(如 Math.max
)。注意,IntBinaryOperator
属于 BinaryOperator
,其输入和输出类型均为 int
。
那么,在不使用 sum
的情况下,如何实现整数的求和呢?一种方案是利用 reduce
方法,如例 3-15 所示。
例 3-15 利用
reduce
方法求和
int sum = IntStream.rangeClosed(1, 10)
.reduce((x, y) -> x + y).orElse(0); ➊
➊ sum
的值为 55
编写代码时,通常采用垂直方式安排流的流水线(stream pipeline),这是基于流畅(fluent)API 的一种方案,其中一个方法的结果将作为下一个方法的目标。在本例中,因为
reduce
方法返回的不是流,所以将orElse
置于同一行(而非另起一行),它不属于流水线的一部分。不过这只是为了方便起见,读者可以根据需要使用任何格式。
在本例中,IntBinaryOperator
由 lambda 表达式提供,它传入两个 int
型数据并返回二者之和。不难想象,如果为 IntBinaryOperator
添加一个筛选器,流是可以为空的,其结果是 OptionalInt
。之后的 orElse
方法表明,如果流中没有元素,返回值应该为 0。
在 lambda 表达式中,可以将二元运算符的第一个参数视为累加器,第二个参数视为流中每个元素的值。通过逐一打印各个元素能很容易理解这一点,如例 3-16 所示。
例 3-16 打印 x 和 y 的值
int sum = IntStream.rangeClosed(1, 10)
.reduce((x, y) -> {
System.out.printf("x=%d, y=%d%n", x, y);
return x + y;
}).orElse(0);
输出如例 3-17 所示。
例 3-17 逐一打印每个值的输出
x=1, y=2
x=3, y=3
x=6, y=4
x=10, y=5
x=15, y=6
x=21, y=7
x=28, y=8
x=36, y=9
x=45, y=10
sum=55
观察以上输出可知,x 和 y 的初始值是范围内的前两个值。二元运算符返回的值在下一次迭代时变为 x(累加器)的值,而 y 依次传入流的每个值。
那么,如果我们希望先处理每个数字,然后再求和呢?例如,在求和之前将所有的数字增加一倍 2。我们可能会写出如例 3-18 所示的代码,不过代码看似正确,实则有误。
例 3-18 在求和过程中将值增加一倍(代码错误)
int doubleSum = IntStream.rangeClosed(1, 10)
.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 在求和过程中将值倍增(代码正确)
int doubleSum = IntStream.rangeClosed(1, 10)
.reduce(0, (x, y) -> x + 2 * y); ➊
➊ doubleSum
的值为 110(这才是正确的值)
通过将累加器 x 的初始值设置为 0,y 的值被赋给流中的各个元素,从而实现所有元素增加一倍。例 3-20 显示了每次迭代时 x 和 y 的值。
例 3-20 每次迭代时 lambda 参数的值
Acc=0, n=1
Acc=2, n=2
Acc=6, n=3
Acc=12, n=4
Acc=20, n=5
Acc=30, n=6
Acc=42, n=7
Acc=56, n=8
Acc=72, n=9
Acc=90, n=10
sum=110
注意,当使用具有累加器初始值的 reduce
方法时,返回类型是 int
而非 OptionalInt
。
二元运算符的标识值
本范例中的示例将第一个参数称为累加器的初始值(initial value),不过方法签名将其称为标识值(identity value)。关键字identity
表示应该为二元运算符提供一个值,以便与其他值结合时返回另一个值。加法操作的标识值为 0,乘法操作的标识值为 1,字符串拼接操作的标识值为空字符串。
本节讨论的求和操作并无不同,但需要注意的是,应将计划用作二元运算符的任何操作的标识值作为reduce
方法的第一个参数,即为累加器内部的初始值。
Java 标准库提供了多种归约方法,但如果这些方法都无法直接解决开发中遇到的问题,不妨试试本节讨论的两种 reduce
方法。
3.Java标准库中的二元运算符
标准库引入的一些新方法使归约操作变得特别简单。例如,Integer
、Long
和 Double
类都定义了 sum
方法,其作用就是对两数求和。Integer
类中 sum
方法的实现如下所示:
public static int sum(int a, int b) {
return a + b;
}
那么,为什么要专门定义一种只为实现两个整数求和的方法呢?这是因为 sum
方法属于 BinaryOperator
(更确切地说,属于 IntBinaryOperator
),很容易就能用于 reduce
方法,如例 3-21 所示。
例 3-21 利用二元运算符执行归约操作
int sum = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
.reduce(0, Integer::sum);
System.out.println(sum);
可以看到,无须使用 IntStream
就能得到相同的结果。Integer
类还定义了 max
和 min
方法,它们也是二元运算符,用法与 sum
方法类似,如例 3-22 所示。
例 3-22 利用
reduce
方法查找最大值
Integer max = Stream.of(3, 1, 4, 1, 5, 9)
.reduce(Integer.MIN_VALUE, Integer::max); ➊
System.out.println("The max value is " + max);
➊ max
的标识值为最小的整数
另一个有趣的例子是 String
类定义的 concat
方法,它仅传入一个参数,看起来不怎么像二元运算符。
String concat(String str)
concat
方法可以用于 reduce
方法,如例 3-23 所示。
例 3-23 利用
reduce
方法拼接流中的字符串
String s = Stream.of("this", "is", "a", "list")
.reduce("", String::concat);
System.out.println(s); ➊
➊ 打印 thisisalist
上述代码之所以能执行,是因为通过类名(如 String::concat
)使用方法引用时,第一个参数将作为 concat
的目标,而第二个参数是 concat
的参数。由于结果返回的是 String
,目标、参数与返回类型均为同一类型,可以将其视为 reduce
方法的二元运算符。
concat
方法能大大缩减代码的尺寸,浏览 API 时请谨记在心。
使用收集器
尽管 concat
方法可行,但效率很低,因为字符串拼接操作会频繁创建和销毁对象。更好的方案是采用带有 Collector
的 collect
方法。
Stream
接口定义了 collect
方法的一种重载形式,它传入三个参数,分别是用于创建集合的 Supplier
,为集合添加单个元素的 BiConsumer
以及合并两个集合的 BiConsumer
。对字符串而言,StringBuilder
是一种天然的累加器。相应的 collect
实现如例 3-24 所示。
例 3-24 利用
StringBuilder
收集字符串
String s = Stream.of("this", "is", "a", "list")
.collect(() -> new StringBuilder(), ➊
(sb, str) -> sb.append(str), ➋
(sb1, sb2) -> sb1.append(sb2)) ➌
.toString();
❶ 结果 Supplier
❷ 为结果添加一个值
❸ 合并两个结果
可以通过方法引用简化上述代码,如例 3-25 所示。
例 3-25 利用方法引用收集字符串
String s = Stream.of("this", "is", "a", "list")
.collect(StringBuilder::new,
StringBuilder::append,
StringBuilder::append)
.toString();
不过,最简单的方案是采用 Collectors
工具类定义的 joining
方法,如例 3-26 所示。
例 3-26 利用
Collectors.joining
连接字符串
String s = Stream.of("this", "is", "a", "list")
.collect(Collectors.joining());
joining
方法的重载形式传入字符串定界符,其简单易行无出其右。相关讨论请参见范例将流转换为集合。
4.reduce
方法的最一般形式
reduce
方法的第三种形式如下:
<U> U reduce(U identity,
BiFunction<U,? super T,U> accumulator,
BinaryOperator<U> combiner)
这种形式略显复杂,通常可以采用更简单的手段实现相同的目标。我们以一个示例说明这种形式的应用。
例 3-27 定义了一个 Book
类,它只有一个 ID
(整数)和一个标题(字符串)。
例 3-27 简单的
Book
类
public class Book {
private Integer id;
private String title;
// 构造函数、getter和setter、toString、equals、hashCode…
}
假设存在一个图书列表,我们希望将列表中的图书添加到某个 Map
。其中键为 ID
,值为图书本身。
采用
Collectors.toMap
方法解决这个问题更容易,相关讨论请参见范例将线性集合添加到映射 。之所以以此为例,是因为它比较简单,有助于读者理解相对复杂的reduce
方法。
例 3-28 显示了一种解决方案。
例 3-28 将
Book
添加到Map
HashMap<Integer, Book> bookMap = books.stream()
.reduce(new HashMap<Integer, Book>(), ➊
(map, book) -> { ➋
map.put(book.getId(), book);
return map;
},
(map1, map2) -> { ➌
map1.putAll(map2);
return map1;
});
bookMap.forEach((k,v) -> System.out.println(k + ": " + v));
❶ putAll
的标识值
❷ 利用 put
将一本书添加到 Map
❸ 利用 putAll
合并多个 Map
我们从 reduce
方法的最后一个参数开始分析,这是最简单的。
第三个参数是 combiner
,它必须是 BinaryOperator
。在本例中,提供的 lambda 表达式传入两个映射,它将第二个映射中的所有键复制到第一个映射,再返回第一个映射。如果 putAll
方法能返回映射,lambda 表达式会更简单,可惜事实并非如此。仅当 reduce
方法并行完成时,组合器才有意义,因为我们需要将范围内每一部分产生的映射合并在一起。
第二个参数是一个函数,用于将一本书添加到 Map
。类似地,如果 Map
的 put
方法在新条目添加完毕后能返回 Map
,函数会更简单。
第一个参数是 combiner
函数的标识值。在本例中,标识值是一个为空的 Map
,因为该标识值与其他任何 Map
结合后返回的是其他 Map
。
例 3-28 的输出如下:
1: Book{id=1, title='Modern Java Recipes'}
2: Book{id=2, title='Making Java Groovy'}
3: Book{id=3, title='Gradle Recipes for Android'}
归约操作是函数式编程习惯用法的基础。在不少常见的用例中,Stream
接口都提供了相应的内置方法,如 sum
或 collect(Collectors.joining(',')
。本范例也讨论了 reduce
方法的直接应用,或许能对读者编写自定义方法有所启发。
一旦掌握 Java 8 中 reduce
方法的用法,读者就能举一反三,理解如何在其他语言中使用相同的操作。即便这种操作被冠以不同的名称(如 Groovy 将其称为 inject
,Scala 将其称为 fold
),其原理并无差别。
2可以采用多种方式解决这个问题,包括将 sum
方法返回的值增加一倍。这里介绍的方案演示了如何使用双参数形式的 reduce
方法。