5.7 数值流

我们在前面看到了可以使用reduce方法计算流中元素的总和。例如,你可以像下面这样计算菜单的热量:

  1. int calories = menu.stream()
  2. .map(Dish::getCalories)
  3. .reduce(0, Integer::sum);

这段代码的问题是,它有一个暗含的装箱成本。每个Integer都必须拆箱成一个原始类型,再进行求和。要是可以直接像下面这样调用sum方法,岂不是更好?

  1. int calories = menu.stream()
  2. .map(Dish::getCalories)
  3. .sum();

但这是不可能的。问题在于map方法会生成一个Stream。虽然流中的元素是Integer类型,但Stream接口没有定义sum方法。为什么没有呢?比方说,你只有一个像menu那样的Stream,把各种菜加起来是没有任何意义的。但不要担心,Stream API还提供了原始类型流特化,专门支持处理数值流的方法。

5.7.1 原始类型流特化

Java 8引入了三个原始类型特化流接口来解决这个问题:IntStreamDoubleStreamLongStream,分别将流中的元素特化为intlongdouble,从而避免了暗含的装箱成本。每个接口都带来了进行常用数值归约的新方法,比如对数值流求和的sum,找到最大元素的max。此外还有在必要时再把它们转换回对象流的方法。要记住的是,这些特化的原因并不在于流的复杂性,而是装箱造成的复杂性——即类似intInteger之间的效率差异。

  • 映射到数值流

将流转换为特化版本的常用方法是mapToIntmapToDoublemapToLong。这些方法和前面说的map方法的工作方式一样,只是它们返回的是一个特化流,而不是Stream。例如,你可以像下面这样用mapToIntmenu中的卡路里求和:

  1. int calories = menu.stream() ←---- 返回一个Stream<Dish>
  2. .mapToInt(Dish::getCalories) ←---- 返回一个IntStream
  3. .sum();

这里,mapToInt会从每道菜中提取热量(用一个Integer表示),并返回一个IntStream(而不是Stream)。然后你就可以调用IntStream接口中定义的sum方法,对卡路里求和了!请注意,如果流是空的,sum则默认返回0。IntStream还支持其他的方便方法,如maxminaverage等。

  • 转换回对象流

同样,一旦有了数值流,你可能会想把它转换回非特化流。例如,IntStream上的操作只能产生原始整数:IntStreammap操作接受的Lambda必须接受int并返回int(一个IntUnaryOperator)。但是你可能想要生成另一类值,比如Dish。为此,你需要访问Stream接口中定义的那些更广义的操作。要把原始流转换成一般流(每个int都会装箱成一个Integer),可以使用boxed方法,如下所示:

  1. IntStream intStream = menu.stream().mapToInt(Dish::getCalories); ←---- Stream转换为数值流
  2. Stream<Integer> stream = intStream.boxed(); ←---- 将数值流转换为Stream

你在下一节中会看到,在需要将数值范围装箱成为一般流时,boxed尤其有用。

  • 默认值OptionalInt

求和的那个例子很容易,因为它有一个默认值:0。但是,如果你要计算IntStream中的最大元素,就得换个法子了,因为0是错误的结果。如何区分没有元素的流和最大值真的是0的流呢?前面我们介绍了Optional类,这是一个可以表示值存在或不存在的容器。Optional可以用IntegerString等参考类型来参数化。对于三种原始流特化,也分别有一个Optional原始类型特化版本:OptionalIntOptionalDoubleOptionalLong

例如,要找到IntStream中的最大元素,可以调用max方法,它会返回一个OptionalInt

  1. OptionalInt maxCalories = menu.stream()
  2. .mapToInt(Dish::getCalories)
  3. .max();

现在,如果没有最大值的话,你就可以显式处理OptionalInt去定义一个默认值了:

  1. int max = maxCalories.orElse(1); ←---- 如果没有最大值的话,显式提供一个默认最大值

5.7.2 数值范围

和数字打交道时,有一个常用的东西就是数值范围。比如,假设你想要生成1和100之间的所有数字。Java 8引入了两个可以用于IntStreamLongStream的静态方法,帮助生成这种范围:rangerangeClosed。这两个方法都是第一个参数接受起始值,第二个参数接受结束值。但range是不包含结束值的,rangeClosed则包含结束值。来看一个例子:

  1. IntStream evenNumbers = IntStream.rangeClosed(1, 100) ←---- 表示范围[1, 100]
  2. .filter(n -> n % 2 == 0); ←---- 一个从1100的偶数流
  3. System.out.println(evenNumbers.count()); ←---- 110050个偶数

这里用了rangeClosed方法来生成1到100之间的所有数字。它会产生一个流,然后你可以链接filter方法,只选出偶数。到目前为止还没有进行任何计算。最后,你对生成的流调用count。因为count是一个终端操作,所以它会处理流,并返回结果50,这正是1到100(包括两端)中所有偶数的个数。请注意,比较一下,如果改用IntStream.range(1, 100),则结果将会是49个偶数,因为range是不包含结束值的。

5.7.3 数值流应用:勾股数

现在来看一个难一点儿的例子,让你巩固一下有关数值流以及到目前为止学过的所有流操作的知识。如果你接受这个挑战,任务就是创建一个勾股数流。

  • 勾股数

那么什么是勾股数(毕达哥拉斯三元数)呢?我们得回到从前。在一堂激动人心的数学课上,你了解到,古希腊数学家毕达哥拉斯发现了某些三元数(a,b,c)满足公式a*a+b*b=c*c,其中abc都是整数。例如,(3, 4, 5)就是一组有效的勾股数,因为3 × 3 + 4 × 4 = 5 × 5或9 + 16 = 25。这样的三元数有无限组。例如,(5, 12, 13)、(6, 8, 10)和(7, 24, 25)都是有效的勾股数。勾股数很有用,因为它们描述的正好是直角三角形的三条边长,如图5-9所示。

5.7 数值流 - 图6

图 5-9 勾股定理(毕达哥拉斯定理)

  • 表示三元数

那么,怎么入手呢?第一步是定义一个三元数。虽然更恰当的做法是定义一个新的类来表示三元数,但这里你可以使用具有三个元素的int数组,比如new int[]{3, 4, 5},来表示勾股数(3, 4, 5)。现在你就可以用数组索引访问每个元素了。

  • 筛选成立的组合

假定有人提供了三元数中的前两个数字:ab。怎么知道它是否能形成一组勾股数呢?你需要测试a*a+b*b的平方根是不是整数。这个思想在Java中可以这么表述:Math.sqrt(a*a + b*b) % 1 == 0(对于浮点数x,它的分数部分在Java中可以使用x % 1.0表示,譬如5.0这样的整数,它的分数部分是0)。我们代码的filter操作中就借助了这一思想(稍后你会了解如何用其构建有效的代码):

  1. filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)

假设环境代码为a提供了一个值,并且stream提供了b可能的值,filter就能挑选出那些可以与a组成勾股数的b

  • 生成三元组

在筛选之后,你知道ab能够组成一个正确的组合。现在需要创建一个三元组。你可以使用map操作,像下面这样把每个元素转换成一个勾股数组:

  1. stream.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
  2. .map(b -> new int[]{a, b, (int) Math.sqrt(a * a + b * b)});
  • 生成b

胜利在望!现在你需要生成b的值。前面已经看到,Stream.rangeClosed让你可以在给定区间内生成一个数值流。你可以用它来给b提供数值,这里是1到100:

  1. IntStream.rangeClosed(1, 100)
  2. .filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
  3. .boxed()
  4. .map(b -> new int[]{a, b, (int) Math.sqrt(a * a + b * b)});

请注意,你在filter之后调用boxed,从rangeClosed返回的IntStream生成一个Stream。这是因为你的map会为流中的每个元素返回一个int数组。而IntStream中的map方法只能为流中的每个元素返回另一个int,这可不是你想要的!你可以用IntStreammapToObj方法改写它,这个方法会返回一个对象值流:

  1. IntStream.rangeClosed(1, 100)
  2. .filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
  3. .mapToObj(b -> new int[]{a, b, (int) Math.sqrt(a * a + b * b)});
  • 生成值

这里有一个关键的假设:给出了a的值。现在,只要已知a的值,你就有了一个可以生成勾股数的流。如何解决这个问题呢?就像b一样,你需要为a生成数值!最终的解决方案如下所示:

  1. Stream<int[]> pythagoreanTriples =
  2. IntStream.rangeClosed(1, 100).boxed()
  3. .flatMap(a ->
  4. IntStream.rangeClosed(a, 100)
  5. .filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
  6. .mapToObj(b ->
  7. new int[]{a, b, (int)Math.sqrt(a * a + b * b)})
  8. );

好的,flatMap又是怎么回事呢?首先,创建一个从1到100的数值范围来生成a的值。对每个给定的a值,创建一个三元数流。要是把a的值映射到三元数流的话,就会得到一个由流构成的流。flatMap方法在做映射的同时,还会把所有生成的三元数流扁平化成一个流。这样你就得到了一个三元数流。还要注意,我们把b的范围改成了a到100。没有必要再从1开始了,否则就会造成重复的三元数,例如(3,4,5)和(4,3,5)。

  • 运行代码

现在你可以运行解决方案,并且可以利用前面看到的limit命令,明确限定从生成的流中要返回多少组勾股数了:

  1. pythagoreanTriples.limit(5)
  2. .forEach(t ->
  3. System.out.println(t[0] + ", " + t[1] + ", " + t[2]));

这会打印:

  1. 3, 4, 5
  2. 5, 12, 13
  3. 6, 8, 10
  4. 7, 24, 25
  5. 8, 15, 17
  • 你还能做得更好吗

目前的解决办法并不是最优的,因为你要求两次平方根。让代码更为紧凑的一种可能的方法是,先生成所有的三元数(a*a, b*b, a*a+b*b),然后再筛选符合条件的:

  1. Stream<double[]> pythagoreanTriples2 =
  2. IntStream.rangeClosed(1, 100).boxed()
  3. .flatMap(a ->
  4. IntStream.rangeClosed(a, 100)
  5. .mapToObj(
  6. b -> new double[]{a, b, Math.sqrt(a*a + b*b)}) ←---- 产生三元数
  7. .filter(t -> t[2] % 1 == 0)); ←---- 元组中的第三个元素必须是整数