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

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.内置归约操作
基本类型流 IntStreamLongStreamDoubleStream 定义了多种内置在 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

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

countStream 接口定义的一种方法,因此无须将其映射给 IntStream
sumaverage 方法仅用于处理基本类型流
❸ 不带 Comparatormaxmin 方法仅用于处理基本类型流上述程序的打印结果如下:

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]
Java

注意,averagemaxmin 方法返回 Optional,因为原则上可以通过应用一个筛选器来删除流中的所有元素。
count 方法相当有趣,相关讨论请参见范例获取元素数量
Stream 接口定义了 max(Comparator)min(Comparator) 方法,其中比较器用于确定最大元素和最小元素。而在 IntStream 接口中,由于比较操作采用整数的自然顺序完成,两种方法的重载形式均不需要参数。
有关 summaryStatistics 方法的讨论请参见范例汇总统计
表3-1 中列出的最后两种归约操作 collectreduce 值得进一步讨论。collect 方法的应用贯穿全书,其作用是将流转换为集合,通常与 Collectors 类定义的某种静态辅助方法配合使用(如 toListtoSet)。但是,无法在基本类型流中使用 collect 方法的三参数形式,即传入三个参数,分别是用于填充的集合、为集合添加单个元素的累加器以及为集合添加多个元素的组合器。有关这种形式的讨论请参见范例 装箱流

2.基本归约实现
在实际接触到 reduce 方法之前,这种方法看起来可能不太直观。
IntStream 接口定义了 reduce 方法的两种重载形式:

OptionalInt reduce(IntBinaryOperator op)
int         reduce(int identity, IntBinaryOperator op)
Java

第一条语句传入 IntBinaryOperator 并返回 OptionalInt,第二条语句需要提供 identityint 型)以及 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);
Java

sum 的值为 55

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

在本例中,IntBinaryOperator 由 lambda 表达式提供,它传入两个 int 型数据并返回二者之和。不难想象,如果为 IntBinaryOperator 添加一个筛选器,流是可以为空的,其结果是 OptionalInt。之后的 orElse 方法表明,如果流中没有元素,返回值应该为 0。
在 lambda 表达式中,可以将二元运算符的第一个参数视为累加器,第二个参数视为流中每个元素的值。通过逐一打印各个元素能很容易理解这一点,如例 3-16 所示。

例 3-16 打印 xy 的值

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

输出如例 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
Java

观察以上输出可知,xy 的初始值是范围内的前两个值。二元运算符返回的值在下一次迭代时变为 x(累加器)的值,而 y 依次传入流的每个值。
那么,如果我们希望先处理每个数字,然后再求和呢?例如,在求和之前将所有的数字增加一倍 2。我们可能会写出如例 3-18 所示的代码,不过代码看似正确,实则有误。

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

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

doubleSum 的值为 109(少了 1)
从 1 到 10 的各个整数之和为 55,因此增加一倍后的值应为 110,但本例的计算结果却是 109。问题出在 reduce 方法的 lambda 表达式上:xy 的初始值为 1 和 2(流的前两个值)。换言之,流的第一个值不会增加一倍。
可以采用 reduce 方法的重载形式解决这个问题,也就是为累加器传入一个初始值。正确的代码如例 3-19 所示。

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

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

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

例 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
Java

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

二元运算符的标识值
本范例中的示例将第一个参数称为累加器的初始值(initial value),不过方法签名将其称为标识值(identity value)。关键字 identity 表示应该为二元运算符提供一个值,以便与其他值结合时返回另一个值。加法操作的标识值为 0,乘法操作的标识值为 1,字符串拼接操作的标识值为空字符串。
本节讨论的求和操作并无不同,但需要注意的是,应将计划用作二元运算符的任何操作的标识值作为 reduce 方法的第一个参数,即为累加器内部的初始值。

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

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

public static int sum(int a, int b) {
    return a + b;
}
Java

那么,为什么要专门定义一种只为实现两个整数求和的方法呢?这是因为 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);
Java

可以看到,无须使用 IntStream 就能得到相同的结果。Integer 类还定义了 maxmin 方法,它们也是二元运算符,用法与 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);
Java

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

String concat(String str)
Java

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

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

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

➊ 打印 thisisalist
上述代码之所以能执行,是因为通过类名(如 String::concat)使用方法引用时,第一个参数将作为 concat 的目标,而第二个参数是 concat 的参数。由于结果返回的是 String,目标、参数与返回类型均为同一类型,可以将其视为 reduce 方法的二元运算符。
concat 方法能大大缩减代码的尺寸,浏览 API 时请谨记在心。

使用收集器
尽管 concat 方法可行,但效率很低,因为字符串拼接操作会频繁创建和销毁对象。更好的方案是采用带有 Collectorcollect 方法。
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();
Java

❶ 结果 Supplier
❷ 为结果添加一个值
❸ 合并两个结果
可以通过方法引用简化上述代码,如例 3-25 所示。

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

String s = Stream.of("this", "is", "a", "list")
       .collect(StringBuilder::new,
               StringBuilder::append,
               StringBuilder::append)
       .toString();
Java

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

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

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

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

4.reduce方法的最一般形式
reduce 方法的第三种形式如下:

<U> U reduce(U identity,
             BiFunction<U,? super T,U> accumulator,
             BinaryOperator<U> combiner)
Java

这种形式略显复杂,通常可以采用更简单的手段实现相同的目标。我们以一个示例说明这种形式的应用。
例 3-27 定义了一个 Book 类,它只有一个 ID(整数)和一个标题(字符串)。

例 3-27 简单的 Book

public class Book {
    private Integer id;
    private String title;
 
    // 构造函数、getter和setter、toString、equals、hashCode…
}
Java

假设存在一个图书列表,我们希望将列表中的图书添加到某个 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));
Java

putAll 的标识值
❷ 利用 put 将一本书添加到 Map
❸ 利用 putAll 合并多个 Map
我们从 reduce 方法的最后一个参数开始分析,这是最简单的。
第三个参数是 combiner,它必须是 BinaryOperator。在本例中,提供的 lambda 表达式传入两个映射,它将第二个映射中的所有键复制到第一个映射,再返回第一个映射。如果 putAll 方法能返回映射,lambda 表达式会更简单,可惜事实并非如此。仅当 reduce 方法并行完成时,组合器才有意义,因为我们需要将范围内每一部分产生的映射合并在一起。
第二个参数是一个函数,用于将一本书添加到 Map。类似地,如果 Mapput 方法在新条目添加完毕后能返回 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'}
Java

归约操作是函数式编程习惯用法的基础。在不少常见的用例中,Stream 接口都提供了相应的内置方法,如 sumcollect(Collectors.joining(',')。本范例也讨论了 reduce 方法的直接应用,或许能对读者编写自定义方法有所启发。
一旦掌握 Java 8 中 reduce 方法的用法,读者就能举一反三,理解如何在其他语言中使用相同的操作。即便这种操作被冠以不同的名称(如 Groovy 将其称为 injectScala 将其称为 fold),其原理并无差别。

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

Python教程

Java教程

Web教程

数据库教程

图形图像教程

大数据教程

开发工具教程

计算机教程

登录

注册