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);

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]

注意,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)

第一条语句传入 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);  ➊

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);

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

观察以上输出可知,xy 的初始值是范围内的前两个值。二元运算符返回的值在下一次迭代时变为 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 表达式上:xy 的初始值为 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 显示了每次迭代时 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

注意,当使用具有累加器初始值的 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;
}

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

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

❶ 结果 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。类似地,如果 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'}

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

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

赞(5)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

Java 实例