Java 8实战(八)- 数值流与构建流

一、数值流

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

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

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

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

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

1. 原始类型流特化

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

(1)映射到数值流

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

int calories = menu.stream()
					.mapToInt(Dish::getCalories)
					.sum();

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

(2)转换回对象流

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

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

在需要将数值范围装箱成为一个一般流时,boxed尤其有用。

(3)默认值OptionalInt

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

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

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

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

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

2. 数值范围

假设你想要生成1和100之间的所有数字。Java 8引入了两个可以用于IntStream和LongStream的静态方法,帮助生成这种范围:range和rangeClosed。这两个方法都是第一个参数接受起始值,第二个参数接受结束值。但range是不包含结束值的,而rangeClosed则包含结束值。让我们来看一个例子:

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

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

3. 数值流应用:勾股数

1. 勾股数
古希腊数学家毕达哥拉斯发现了某些三元数(a, b, c)满足公式a * a + b * b =c * c,其中a、b、c都是整数。例如,(3, 4, 5)就是一组有效的勾股数,因为3 * 3 + 4 * 4 = 5 * 5或9 + 16 = 25。这样的三元数有无限组。例如,(5, 12, 13)、(6, 8, 10)和(7, 24, 25)都是有效的勾股数

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

3. 筛选成立的组合
假定有人为你提供了三元数中的前两个数字:a和b。怎么知道它是否能形成一组勾股数呢?你需要测试a * a + b * b的平方根是不是整数,也就是说它没有小数部分——在Java里可以使用expr % 1表示。如果它不是整数,那就是说c不是整数。你可以用filter操作表达这个要求(你稍后会了解到如何将其连接起来成为有效代码):

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

假设周围的代码给a提供了一个值,并且stream提供了b可能出现的值,filter将只选出那些可以与a组成勾股数的b。你可能在想Math.sqrt(a * a + b * b) % 1 == 0这一行是怎么回事。简单来说,这是一种测试Math.sqrt(a * a + b * b)返回的结果是不是整数的方法。如果平方根的结果带了小数,如9.1,这个条件就不成立(9.0是可以的)。

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

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

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

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

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

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

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

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

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

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

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

这会打印:
3, 4, 5
5, 12, 13
6, 8, 10
7, 24, 25
8, 15, 17

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

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

二、构建流

1. 由值创建流

你可以使用静态方法Stream.of,通过显式值创建一个流。它可以接受任意数量的参数。例如,以下代码直接使用Stream.of创建了一个字符串流。然后,你可以将字符串转换为大写,再一个个打印出来:

Stream<String> stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);

你可以使用empty得到一个空流,如下所示:

Stream<String> emptyStream = Stream.empty();

2. 由数组创建流

你可以使用静态方法Arrays.stream从数组创建一个流。它接受一个数组作为参数。例如,你可以将一个原始类型int的数组转换成一个IntStream,如下所示:

int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum();

3. 由文件生成流

Java中用于处理文件等I/O操作的NIO API(非阻塞 I/O)已更新,以便利用Stream API。java.nio.file.Files中的很多静态方法都会返回一个流。例如,一个很有用的方法是Files.lines,它会返回一个由指定文件中的各行构成的字符串流。使用你迄今所学的内容,你可以用这个方法看看一个文件中有多少各不相同的词:

long uniqueWords = 0;
try(Stream<String> lines = Files.lines(Paths.get("data.txt"), Charset.defaultCharset())){
		uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
		.distinct()
		.count();
} catch(IOException e){

}

你可以使用Files.lines得到一个流,其中的每个元素都是给定文件中的一行。然后,你可以对line调用split方法将行拆分成单词。应该注意的是,你该如何使用flatMap产生一个扁平的单词流,而不是给每一行生成一个单词流。最后,把distinct和count方法链接起来,数数流中有多少各不相同的单词。

4. 由函数生成流:创建无限流

Stream API提供了两个静态方法来从函数生成流:Stream.iterate和Stream.generate。这两个操作可以创建所谓的无限流:不像从固定集合创建的流那样有固定大小的流。由iterate和generate产生的流会用给定的函数按需创建值,因此可以无穷无尽地计算下去!一般来说,应该使用limit(n)来对这种流加以限制,以避免打印无穷多个值。

1. 迭代
我们先来看一个iterate的简单例子,然后再解释:

Stream.iterate(0, n -> n + 2)
		.limit(10)
		.forEach(System.out::println);

iterate方法接受一个初始值(在这里是0),还有一个依次应用在每个产生的新值上的Lambda(UnaryOperator< t>类型)。这里,我们使用Lambda n -> n + 2,返回的是前一个元素加上2。因此,iterate方法生成了一个所有正偶数的流:流的第一个元素是初始值0。然后加上2来生成新的值2,再加上2来得到新的值4,以此类推。这种iterate操作基本上是顺序的,因为结果取决于前一次应用。请注意,此操作将生成一个无限流——这个流没有结尾,因为值是按需计算的,可以永远计算下去。我们说这个流是无界的。正如我们前面所讨论的,这是流和集合之间的一个关键区别。我们使用limit方法来显式限制流的大小。这里只选择了前10个偶数。然后可以调用forEach终端操作来消费流,并分别打印每个元素。

一般来说,在需要依次生成一系列值的时候应该使用iterate,比如一系列日期:1月31日,2月1日,依此类推。

测验:斐波纳契元组序列
斐波纳契数列是著名的经典编程练习。下面这个数列就是斐波纳契数列的一部分:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55…数列中开始的两个数字是0和1,后续的每个数字都是前两个数字之和。
斐波纳契元组序列与此类似,是数列中数字和其后续数字组成的元组构成的序列:(0, 1), (1, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 13), (13, 21) …
你的任务是用iterate方法生成斐波纳契元组序列中的前20个元素。
第一个问题是,iterate方法要接受一个UnaryOperator< t>作为参数,而你需要一个像(0,1)这样的元组流。你还是可以(这次又是比较草率地)使用一个数组的两个元素来代表元组。例如,new int[]{0,1}就代表了斐波纳契序列(0, 1)中的第一个元素。这就是iterate方法的初始值:

Stream.iterate(new int[]{0, 1}, ???)
		.limit(20)
		.forEach(t -> System.out.println("(" + t[0] + "," + t[1] +")"));

在这个测验中,你需要搞清楚???代表的代码是什么。请记住,iterate会按顺序应用给定的Lambda。答案:

Stream.iterate(new int[]{0, 1}, t -> new int[]{t[1], t[0]+t[1]})
		.limit(20)
		.forEach(t -> System.out.println("(" + t[0] + "," + t[1] +")"));

它是如何工作的呢?iterate需要一个Lambda来确定后续的元素。对于元组(3, 5),其后续元素是(5, 3+5) = (5, 8)。下一个是(8, 5+8)。看到这个模式了吗?给定一个元组,其后续的元素是(t[1], t[0] + t[1])。这可以用这个Lambda来计算:t->new int[]{t[1], t[0]+t[1]}。运行这段代码,你就得到了序列(0, 1), (1, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 13), (13, 21)…请注意,如果你只想打印正常的斐波纳契数列,可以使用map提取每个元组中的第一个元素:

Stream.iterate(new int[]{0, 1}, t -> new int[]{t[1],t[0] + t[1]})
		.limit(10)
		.map(t -> t[0])
		.forEach(System.out::println);

这段代码将生成斐波纳契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34…

2. 生成
与iterate方法类似,generate方法也可让你按需生成一个无限流。但generate不是依次对每个新生成的值应用函数的。它接受一个Supplier< T>类型的Lambda提供新的值。我们先来看一个简单的用法:

Stream.generate(Math::random)
		.limit(5)
		.forEach(System.out::println);

这段代码将生成一个流,其中有五个0到1之间的随机双精度数。例如,运行一次得到了下面的结果:
0.9410810294106129
0.6586270755634592
0.9592859117266873
0.13743396659487006
0.3942776037651241

Math.Random静态方法被用作新值生成器。同样,你可以用limit方法显式限制流的大小,否则流将会无限长。

你可能想知道,generate方法还有什么用途。我们使用的供应源(指向Math.random的方法引用)是无状态的:它不会在任何地方记录任何值,以备以后计算使用但供应源不一定是无状态的。你可以创建存储状态的供应源,它可以修改状态,并在为流生成下一个值时使用。
举个例子,我们将展示如何利用generate创建斐波纳契数列,这样你就可以和用iterate方法比较一下。但很重要的一点是,在并行代码中使用有状态的供应源是不安全的。因此下面的代码仅仅是为了内容完整,应尽量避免使用!

我们在这个例子中会使用IntStream说明避免装箱操作的代码。IntStream的generate方法会接受一个IntSupplier,而不是Supplier< t>。例如,可以这样来生成一个全是1的无限流:

IntStream ones = IntStream.generate(() -> 1);

你也可以像下面这样,通过实现IntSupplier接口中定义的getAsInt方法显式传递一个对象(虽然这看起来是无缘无故地绕圈子,也请你耐心看):

IntStream twos = IntStream.generate(new IntSupplier(){
	public int getAsInt(){
		return 2;
	}
});

generate方法将使用给定的供应源,并反复调用getAsInt方法,而这个方法总是返回2。但这里使用的匿名类和Lambda的区别在于,匿名类可以通过字段定义状态,而状态又可以用getAsInt方法来修改。这是一个副作用的例子你迄今见过的所有Lambda都是没有副作用的;它们没有改变任何状态

回到斐波纳契数列的任务上,你现在需要做的是建立一个IntSupplier,它要把前一项的值保存在状态中,以便getAsInt用它来计算下一项。此外,在下一次调用它的时候,还要更新IntSupplier的状态。下面的代码就是如何创建一个在调用时返回下一个斐波纳契项的IntSupplier:

IntSupplier fib = new IntSupplier(){
		private int previous = 0;
		private int current = 1;
		public int getAsInt(){
			int oldPrevious = this.previous;
			int nextValue = this.previous + this.current;
			this.previous = this.current;
			this.current = nextValue;
			return oldPrevious;
		}
};

IntStream.generate(fib).limit(10).forEach(System.out::println);

前面的代码创建了一个IntSupplier的实例。此对象有可变的状态:它在两个实例变量中记录了前一个斐波纳契项和当前的斐波纳契项。getAsInt在调用时会改变对象的状态,由此在每次调用时产生新的值。相比之下,使用iterate的方法则是纯粹不变的:它没有修改现有状态,但在每次迭代时会创建新的元组你应该始终采用不变的方法,以便并行处理流,并保持结果正确
请注意,因为你处理的是一个无限流,所以必须使用limit操作来显式限制它的大小;否则,终端操作(这里是forEach)将永远计算下去。同样,你不能对无限流做排序或归约,因为所有元素都需要处理,而这永远也完不成!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值