Collectors Part 1 – Reductions and Short-Circuiting Operations





5.00/5 (2投票s)
Reductions and Short-Circuiting Operations
在前几篇文章中,我们讨论了流(streams)。我们看到,我们可以对简单的东西,比如一个国家列表,进行过滤或映射它们的名称,然后通过 `foreach` 打印它们。然后,我们研究了范围/循环和生成器,作为提供值的一种替代预定义列表的方法。
尽管我们没有明确提及,但一个流可以分为三个独立的部分:
- 源操作,例如供应商或生成器,通过 `spliterator` 将元素推入我们的流。
- 可选的中间步骤:这些可以过滤值、排序值、映射值、影响流的处理(例如并行处理)等等。
- 最后,终端操作会消耗、归约、短路或收集这些值。终端操作的短路意味着流可能在所有值都被处理之前终止。如果流是无限的,这很有用。
我们已经很好地涵盖了前两个部分,并且也使用了 `forEach` 来进行消耗,所以现在让我们来看看收集。为什么选择收集而不是消耗?有几个原因,包括:
- 由于它不返回任何内容,消耗必须涉及副作用(否则它什么也做不了),而在并行运行时,这些副作用可能不是我们期望的顺序,或者为了排序而引起不必要的同步。
- 我们希望稍后再次使用这些结果。
- 我们希望将这些值归约成一个单一的结果。
- 我们希望能够检查/返回这些值,例如用于单元测试或构建可重用性。
- 副作用会使测试变得困难,并且经常需要模拟。
- 副作用会破坏纯函数(只接受输入值,输出结果;相同的输入值产生相同的输出结果)的概念,这使得证明代码的正确性更加困难。
我们将首先研究归约。这是一种收集形式,而不是返回流中的所有结果,我们将它们精简成(通常)一个单一的结果。一个常见的例子是求和所有值。让我们使用一个 `Integer` 列表作为源,来查看内置的归约操作。
public class ListReduction
{
public static void main(String[] args)
{
List<Integer> numbersList = Arrays.asList(1, 2, 5, 4, 3);
System.out.println(numbersList.stream().count());
System.out.println(numbersList.stream().mapToInt(x -> x).sum());
System.out.println(numbersList.stream().mapToInt(x -> x).average()
.getAsDouble());
System.out.println(numbersList.stream().mapToInt(x -> x).max()
.getAsInt());
System.out.println(numbersList.stream().mapToInt(x -> x).min()
.getAsInt());
System.out.println(numbersList.stream().mapToInt(x -> x)
.summaryStatistics());
}
}
注意
- `summaryStatistics()` 操作计算所有值。
- `average()` 返回一个 `OptionalDouble` – 我们需要使用 `getAsDouble()` 来获取值。
- `max()` 和 `min()` 返回 `OptionalInt` – 我们需要使用 `getAsInt()` 来获取值。
正如在关于 `Optional` 的文章中已经讨论过的,如果 `Optional` 值碰巧是特殊的 `empty()` 值(当我们没有传递任何值或过滤掉了所有值时),如果我们尝试使用 `get` 或 `getAs<type>`,我们将得到一个 `NullPointerException` – 我们可能希望考虑使用 `getOrElse`,例如提供一个默认值来避免这种情况。
另外请注意,因为我们正在流式处理一个列表,所以我们必须使用 `mapToInt(x -> x)` 将流的形状从 `Object` 更改为 `int`,因为 `IntStream` 处理的是 `int` 而不是 `Integer`。
如果我们使用 `int` 数组代替,我们可以省略 `map`。
public class ArrayReduction
{
public static void main(String[] args)
{
int[] numbersArray = new int[] { 1, 2, 5, 4, 3 };
System.out.println(Arrays.stream(numbersArray).count());
System.out.println(Arrays.stream(numbersArray).sum());
System.out.println(Arrays.stream(numbersArray).average().getAsDouble());
System.out.println(Arrays.stream(numbersArray).min().getAsInt());
System.out.println(Arrays.stream(numbersArray).max().getAsInt());
System.out.println(Arrays.stream(numbersArray).summaryStatistics());
}
}
这样看起来更整洁了。我们无法改变每次都需要创建流的事实。如果我们尝试保存 `Arrays.stream(numbersArray)` 的引用,它只能使用一次。这就是为什么 `summaryStatistics` 会非常有用。
如果我们想自己编写归约操作怎么办?有两种方法。第一种是使用 `reduce` 操作,我们将在本文中介绍。另一种方法是使用 `collect`,我们将在下一篇文章中介绍。
要进行归约,我们需要一到两样东西:
- 一个接受两个值并返回一个单一值的二元函数。
- 我们可能还需要一个初始值(称为标识)。
让我们想象流是一个值的队列(假设流是顺序的)。如果给定了标识值,我们会先把它放进队列。然后,流中的所有值依次被加入队列。一旦有了队列,我们取出第一个值并将其赋值给累加器。只要队列中还有值,我们就从队列中取出下一个值,然后对累加器和取出的值执行二元函数。然后我们将结果赋值回累加器。这个过程会重复,直到队列为空。
很容易看出拥有标识值的好处。例如,在求和的情况下,标识是零,因此在从流中取出值之前,零被赋给了累加器。如果没有值在流中,最终结果就是零。
如果流为空,并且没有标识值怎么办?为了解决这个问题,不带标识值的 API 版本会返回一个适当的 `Optional`。现在你可以明白为什么我们在上一篇文章中绕道讨论 `Optional` 了。
让我们用显式的 `reduce` 归约来替换上面内置的操作。
public class ExplicitReductions
{
public static void main(String[] args)
{
int[] numbersArray = new int[] { 1, 2, 3, 4, 5 };
System.out.println(Arrays.stream(numbersArray).map(x -> 1)
.reduce(0, Integer::sum));
System.out.println(Arrays.stream(numbersArray)
.reduce(0, Integer::sum));
System.out.println(Arrays.stream(numbersArray)
.reduce(Integer::min).getAsInt());
System.out.println(Arrays.stream(numbersArray)
.reduce(Integer::max).getAsInt());
}
}
需要注意的一些事项。
- 要执行 `count`,我们必须将值映射为 `1`,然后进行求和。看起来使用数组的 `length` 来获取计数会容易得多,但请记住,在流中我们可能先有其他操作,例如过滤掉一些值。一个例子可能是计算有多少偶数。
- 平均值(Average)被省略了,因为它有点复杂。我们必须同时跟踪计数和总和,所以简单的 `reduce` 调用不足以实现它。
- 归约操作也称为“左折叠”(fold left),因为如果我们画一棵树,它会向左倾斜。
例如,对于 4 个值:
这归约为 `(((Val1 Op1 Val2) Op2 Val3) Op3 Val4)`。
我们可以在 `reduce` 中使用自己的函数。例如,要计算阶乘,我们只需要一个将累加器与下一个值相乘的函数。
public class Factorial
{
public static void main(String[] args)
{
int n = 6;
System.out.println(IntStream.rangeClosed(1, n)
.reduce((x, y) -> x * y).getAsInt());
}
}
最后,让我们看看短路运算符。
public class ShortCircuit
{
public static void main(String[] args)
{
List<String> countries = Arrays.asList("France", "India", "China",
"USA", "Germany");
System.out.println(countries.stream()
.filter(country -> country.contains("i"))
.findFirst().get());
System.out.println(countries.stream()
.filter(country -> country.contains("i"))
.findAny().get());
System.out.println(countries.stream()
.allMatch(country -> country.contains("i")));
System.out.println(countries.stream()
.allMatch(country -> !country.contains("z")));
System.out.println(countries.stream()
.noneMatch(country -> country.contains("z")));
System.out.println(countries.stream()
.anyMatch(country -> country.contains("i")));
System.out.println(countries.stream()
.anyMatch(country -> country.contains("z")));
}
}
如前所述,终端短路操作可能意味着我们不必处理流中的所有值。有一些内置操作可以找到第一个匹配的值(`findFirst`),找到任何一个匹配的值(`findAny`),以及判断是否所有、任何或都不匹配(`allMatch`, `anyMatch`, `noneMatch`)。
请注意,在 `findFirst` 或 `findAny` 的情况下,我们只需要第一个匹配谓词的值(尽管 `findAny` 不能保证返回第一个)。然而,如果流没有排序,我们期望 `findFirst` 的行为类似于 `findAny`。`allMatch`、`noneMatch` 和 `anyMatch` 操作可能根本不会短路 `stream`,因为可能需要评估所有值来确定运算符是 `true` 还是 `false`。因此,使用这些操作的无限 `stream` 可能不会终止。
我们还有收集器(collectors)要看,这将是下一篇文章的重点。