Java 8实战(六)- Stream流操作

一、筛选和切片

1. 用谓词筛选

Streams接口支持filter方法。该操作会接受一个谓词(一个返回boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流。
例如,你可以像图5-1所示的这样,筛选出所有素菜,创建一张素食菜单:

List<Dish> vegetarianMenu = menu.stream()
								// 方法引用检查菜肴是否适合素食者
								.filter(Dish::isVegetarian)
								.collect(toList());

在这里插入图片描述

2. 筛选各异的元素

流还支持一个叫作distinct的方法,它会返回一个元素各异(根据流所生成元素的hashCode和equals方法实现)的流。例如,以下代码会筛选出列表中所有的偶数,并确保没有重复。

List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream()
		.filter(i -> i % 2 == 0)
		.distinct()
		.forEach(System.out::println);

在这里插入图片描述

3. 截短流

流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递给limit。如果流是有序的,则最多会返回前n个元素。比如,你可以建立一个List,选出热量超过300卡路里的头三道菜:

List<Dish> dishes = menu.stream()
						.filter(d -> d.getCalories() > 300)
						.limit(3)
						.collect(toList());

图5-3展示了filter和limit的组合。你可以看到,该方法只选出了符合谓词的头三个元素,然后就立即返回了结果。
请注意limit也可以用在无序流上,比如源是一个Set。这种情况下,limit的结果不会以任何顺序排列。

在这里插入图片描述

4. 跳过元素

流还支持skip(n)方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!例如,下面的代码将跳过超过300卡路里的头两道菜,并返回剩下的。

List<Dish> dishes = menu.stream()
						.filter(d -> d.getCalories() > 300)
						.skip(2)
						.collect(toList());

在这里插入图片描述

二、映射

一个非常常见的数据处理套路就是从某些对象中选择信息。比如在SQL里,你可以从表中选择一列。Stream API也通过map和flatMap方法提供了类似的工具。

1. 对流中每一个元素应用函数

流支持map方法,它会接受一个函数作为参数。这个函数会被应用到每个元素上,并将其映射成一个新的元素(使用映射一词,是因为它和转换类似,但其中的细微差别在于它是“创建一个新版本”而不是去“修改”)。例如,下面的代码把方法引用Dish::getName传给了map方法,来提取流中菜肴的名称:

List<String> dishNames = menu.stream()
							 .map(Dish::getName)
							 .collect(toList());

因为getName方法返回一个String,所以map方法输出的流的类型就是Stream。让我们看一个稍微不同的例子来巩固一下对map的理解。给定一个单词列表,你想要返回另一个列表,显示每个单词中有几个字母。怎么做呢?你需要对列表中的每个元素应用一个函数。这听起来正好该用map方法去做!应用的函数应该接受一个单词,并返回其长度。你可以像下面这样,给map传递一个方法引用String::length来解决这个问题:

List<String> words = Arrays.asList("Java 8", "Lambdas", "In", "Action");
List<Integer> wordLengths = words.stream()
								 .map(String::length)
								 .collect(toList());

现在让我们回到提取菜名的例子。如果你要找出每道菜的名称有多长,怎么做?你可以像下面这样,再链接上一个map:

List<Integer> dishNameLengths = menu.stream()
									.map(Dish::getName)
									.map(String::length)
									.collect(toList());

2. 流的扁平化

你已经看到如何使用map方法返回列表中每个单词的长度了。让我们拓展一下:对于一张单词表, 如何返回一张列表, 列出里面各不相同的字符呢? 例如, 给定单词列表[“Hello”,“World”],你想要返回列表[“H”,“e”,“l”, “o”,“W”,“r”,“d”]。你可能会认为这很容易,你可以把每个单词映射成一张字符表,然后调用distinct来过滤重复的字符。第一个版本可能是这样的:

words.stream()
	.map(word -> word.split(""))
	.distinct()
	.collect(toList());

这个方法的问题在于,传递给map方法的Lambda为每个单词返回了一个String[](String列表)。因此, map 返回的流实际上是Stream<String[]> 类型的。你真正想要的是用Stream来表示一个字符流。
图5-5说明了这个问题。

在这里插入图片描述

(1)尝试使用map和Arrays.stream()

首先,你需要一个字符流,而不是数组流。有一个叫作Arrays.stream()的方法可以接受一个数组并产生一个流,例如:

String[] arrayOfWords = {"Goodbye", "World"};
Stream<String> streamOfwords = Arrays.stream(arrayOfWords);

把它用在前面的那个流水线里,看看会发生什么:

words.stream()
	// 将每个单词转换为由其字母构成的数组
	.map(word -> word.split(""))
	// 让每个数组变成一个单独的流
	.map(Arrays::stream)
	.distinct()
	.collect(toList());

当前的解决方案仍然搞不定!这是因为,你现在得到的是一个流的列表(更准确地说是Stream)!的确,你先是把每个单词转换成一个字母数组,然后把每个数组变成了一个独立的流。

(2)使用flatMap

你可以像下面这样使用flatMap来解决这个问题:

List<String> uniqueCharacters = words.stream()
								// 将每个单词转换为由其字母构成的数组
								.map(w -> w.split(""))
								// 将各个生成流扁平化为单个流
								.flatMap(Arrays::stream)
								.distinct()
								.collect(Collectors.toList());

使用flatMap方法的效果是,各个数组并不是分别映射成一个流,而是映射成流的内容。所有使用map(Arrays::stream)时生成的单个流都被合并起来,即扁平化为一个流。
图5-6说明了使用flatMap方法的效果。

在这里插入图片描述

一言以蔽之,flatmap方法让你把一个流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。

测验:映射
(1) 给定一个数字列表,如何返回一个由每个数的平方构成的列表呢?例如,给定[1, 2, 3, 4,5],应该返回[1, 4, 9, 16, 25]。
答案:你可以利用map方法的Lambda,接受一个数字,并返回该数字平方的Lambda来解决这个问题。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squares = numbers.stream()
						.map(n -> n * n)
						.collect(toList());

(2) 给定两个数字列表,如何返回所有的数对呢?例如,给定列表[1, 2, 3]和列表[3, 4],应该返回[(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]。为简单起见,你可以用有两个元素的数组来代表数对。
答案:你可以使用两个map来迭代这两个列表,并生成数对。但这样会返回一个Stream<Stream<Integer[]>>。你需要让生成的流扁平化,以得到一个Stream<Integer[]>。这正是flatMap所做的:

List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);
List<int[]> pairs = numbers1.stream()
							.flatMap(i -> numbers2.stream().map(j -> new int[]{i, j}))
							.collect(toList());

(3) 如何扩展前一个例子,只返回总和能被3整除的数对呢?例如(2, 4)和(3, 3)是可以的。
答案:你在前面看到了,filter可以配合谓词使用来筛选流中的元素。因为在flatMap操作后,你有了一个代表数对的int[]流,所以你只需要一个谓词来检查总和是否能被3整除就可以了:

List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);
List<int[]> pairs = numbers1.stream()
							.flatMap(i -> numbers2.stream().filter(j -> (i + j) % 3 == 0).map(j -> new int[]{i, j}))
							.collect(toList());

其结果是[(2, 4), (3, 3)]。

三、查找和匹配

另一个常见的数据处理套路是看看数据集中的某些元素是否匹配一个给定的属性。Stream API通过allMatch、anyMatch、noneMatch、findFirst和findAny方法提供了这样的工具。

1. 检查谓词是否至少匹配一个元素

anyMatch方法可以回答“流中是否有一个元素能匹配给定的谓词”。比如,你可以用它来看看菜单里面是否有素食可选择:

if(menu.stream().anyMatch(Dish::isVegetarian)){
	System.out.println("The menu is (somewhat) vegetarian friendly!!");
}

anyMatch方法返回一个boolean,因此是一个终端操作。

2. 检查谓词是否匹配所有元素

allMatch方法的工作原理和anyMatch类似,但它会看看流中的元素是否都能匹配给定的谓词。比如,你可以用它来看看菜品是否有利健康(即所有菜的热量都低于1000卡路里):

boolean isHealthy = menu.stream().allMatch(d -> d.getCalories() < 1000);

noneMatch
和allMatch相对的是noneMatch。它可以确保流中没有任何元素与给定的谓词匹配。比如,你可以用noneMatch重写前面的例子:

boolean isHealthy = menu.stream().noneMatch(d -> d.getCalories() >= 1000);

anyMatch、allMatch和noneMatch这三个操作都用到了我们所谓的短路,这就是大家熟悉的Java中&&和||运算符短路在流中的版本。

短路求值
有些操作不需要处理整个流就能得到结果。例如,假设你需要对一个用and连起来的大布尔表达式求值。不管表达式有多长,你只需找到一个表达式为false,就可以推断整个表达式将返回false,所以用不着计算整个表达式。这就是短路。对于流而言,某些操作(例如allMatch、anyMatch、noneMatch、findFirst和findAny)不用处理整个流就能得到结果。只要找到一个元素,就可以有结果了。同样,limit也是一个短路操作:它只需要创建一个给定大小的流,而用不着处理流中所有的元素。在碰到无限大小的流的时候,这种操作就有用了:它们可以把无限流变成有限流。

3. 查找元素

findAny方法将返回当前流中的任意元素。它可以与其他流操作结合使用。比如,你可能想找到一道素食菜肴。你可以结合使用filter和findAny方法来实现这个查询:

Optional<Dish> dish = menu.stream()
						  .filter(Dish::isVegetarian)
						  .findAny();

流水线将在后台进行优化使其只需走一遍,并在利用短路找到结果时立即结束。不过慢着,代码里面的Optional是个什么玩意儿?

Optional简介
Optional< T>类(java.util.Optional)是一个容器类,代表一个值存在或不存在。在上面的代码中,findAny可能什么元素都没找到。Java 8的库设计人员引入了Optional< T>,这样就不用返回众所周知容易出问题的null了。

 isPresent()将在Optional包含值的时候返回true, 否则返回false。
 ifPresent(Consumer block)会在值存在的时候执行给定的代码块。
 T get()会在值存在时返回值,否则抛出一个NoSuchElement异常。
 T orElse(T other)会在值存在时返回值,否则返回一个默认值。
例如,在前面的代码中你需要显式地检查Optional对象中是否存在一道菜可以访问其名称:

menu.stream()
	.filter(Dish::isVegetarian)
	// 返回一个Optional<Dish>
	.findAny()
	// 如果包含一个值就打印它,否则什么都不做
	.ifPresent(d -> System.out.println(d.getName());

4. 查找第一个元素

有些流有一个出现顺序(encounter order)来指定流中项目出现的逻辑顺序(比如由List或排序好的数据列生成的流)。对于这种流,你可能想要找到第一个元素。为此有一个findFirst方法,它的工作式类似于findany。例如,给定一个数字列表,下面的代码能找出第一个平方能被3整除的数:

List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> firstSquareDivisibleByThree = someNumbers.stream()
												.map(x -> x * x)
												.filter(x -> x % 3 == 0)
												.findFirst(); // 9

何时使用findFirst和findAny
你可能会想,为什么会同时有findFirst和findAny呢?
答案是并行。找到第一个元素在并行上限制更多。如果你不关心返回的元素是哪个,请使用findAny,因为它在使用并行流时限制较少。

四、归约

到目前为止,你见到过的终端操作都是返回一个boolean(allMatch之类的)、void(forEach)或Optional对象(findAny等)。你也见过了使用collect来将流中的所有元素组合成一个List。

在本节中,你将看到如何把一个流中的元素组合起来,使用reduce操作来表达更复杂的查询,比如“计算菜单中的总卡路里”或“菜单中卡路里最高的菜是哪一个”。此类查询需要将流中所有元素反复结合起来,得到一个值,比如一个Integer。这样的查询可以被归类为归约操作(将流归约成一个值)。用函数式编程语言的术语来说,这称为折叠(fold),因为你可以将这个操作看成把一张长长的纸(你的流)反复折叠成一个小方块,而这就是折叠操作的结果。

1. 元素求和

在我们研究如何使用reduce方法之前,先来看看如何使用for-each循环来对数字列表中的元素求和:

int sum = 0;
for (int x : numbers) {
	sum += x;
}

numbers中的每个元素都用加法运算符反复迭代来得到结果。通过反复使用加法,你把一个数字列表归约成了一个数字。这段代码中有两个参数:
 总和变量的初始值,在这里是0;
 将列表中所有元素结合在一起的操作,在这里是+。

要是还能把所有的数字相乘,而不必去复制粘贴这段代码,岂不是很好?这正是reduce操作的用武之地,它对这种重复应用的模式做了抽象。你可以像下面这样对流中所有的元素求和:

int sum = numbers.stream().reduce(0, (a, b) -> a + b);

reduce接受两个参数:
 一个初始值,这里是0;
 一个BinaryOperator来将两个元素结合起来产生一个新值,这里我们用的是 lambda (a, b) -> a + b。

你也很容易把所有的元素相乘,只需要将另一个Lambda:(a, b) -> a * b 传递给reduce操作就可以了:

int product = numbers.stream().reduce(1, (a, b) -> a * b);

图5-7展示了reduce操作是如何作用于一个流的:Lambda反复结合每个元素,直到流被归约成一个值。

在这里插入图片描述

你可以使用方法引用让这段代码更简洁。在Java 8中,Integer类现在有了一个静态的sum方法来对两个数求和,这恰好是我们想要的,用不着反复用Lambda写同一段代码了:

int sum = numbers.stream().reduce(0, Integer::sum);

无初始值
reduce还有一个重载的变体,它不接受初始值,但是会返回一个Optional对象:

Optional< Integer> sum = numbers.stream().reduce((a, b) -> (a + b));

为什么它返回一个Optional< Integer>呢?考虑流中没有任何元素的情况。reduce操作无法返回其和,因为它没有初始值。这就是为什么结果被包裹在一个Optional对象里,以表明和可能不存在。

2. 最大值和最小值

原来,只要用归约就可以计算最大值和最小值了!让我们来看看如何利用刚刚学到的reduce来计算流中最大或最小的元素。正如你前面看到的,reduce接受两个参数:
 一个初始值
 一个Lambda来把两个流元素结合起来并产生一个新值

Lambda是一步步用加法运算符应用到流中每个元素上的。因此,你需要一个给定两个元素能够返回最大值的Lambda。reduce操作会考虑新值和流中下一个元素,并产生一个新的最大值,直到整个流消耗完!你可以像下面这样使用reduce来计算流中的最大值,如图5-8所示。

Optional< Integer> max = numbers.stream().reduce(Integer::max);

在这里插入图片描述

要计算最小值,你需要把Integer.min传给reduce来替换Integer.max:

Optional<Integer> min = numbers.stream().reduce(Integer::min);

测验:归约
怎样用map和reduce方法数一数流中有多少个菜呢?
答案:要解决这个问题,你可以把流中每个元素都映射成数字1,然后用reduce求和。这相当于按顺序数流中的元素个数。

int count = menu.stream()
				.map(d -> 1)
				.reduce(0, (a, b) -> a + b);

map和reduce的连接通常称为map-reduce模式,因Google用它来进行网络搜索而出名,因为它很容易并行化。请注意,在前面章节我们也看到了内置count方法可用来计算流中元素的个数:

long count = menu.stream().count();

归约方法的优势与并行化
相比于前面写的逐步迭代求和,使用reduce的好处在于,这里的迭代被内部迭代抽象掉了,这让内部实现得以选择并行执行reduce操作。而迭代式求和例子要更新共享变量sum,这不是那么容易并行化的。如果你加入了同步,很可能会发现线程竞争抵消了并行本应带来的性能提升!这种计算的并行化需要另一种办法:将输入分块,分块求和,最后再合并起来。但这样的话代码看起来就完全不一样了。
但现在重要的是要认识到,可变的累加器模式对于并行化来说是死路一条。你需要一种新的模式,这正是reduce所提供的。你还可以使用流来对所有的元素并行求和时,你的代码几乎不用修改:stream()换成了parallelStream()。

int sum = numbers.parallelStream().reduce(0, Integer::sum);

但要并行执行这段代码也要付一定代价,稍后会向你解释:传递给reduce的Lambda不能更改状态(如实例变量),而且操作必须满足结合律才可以按任意顺序执行。

流操作:无状态和有状态
你已经看到了很多的流操作。乍一看流操作简直是灵丹妙药,而且只要在从集合生成流的时候把Stream换成parallelStream就可以实现并行。
当然,对于许多应用来说确实是这样,就像前面的那些例子。你可以把一张菜单变成流,用filter选出某一类的菜肴,然后对得到的流做map来对卡路里求和,最后reduce得到菜单的总热量。这个流计算甚至可以并行进行。但这些操作的特性并不相同。它们需要操作的内部状态还是有些问题的。

诸如map或filter等操作会从输入流中获取每一个元素,并在输出流中得到0或1个结果。这些操作一般都是无状态的:它们没有内部状态(假设用户提供的Lambda或方法引用没有内部可变状态)。
但诸如reduce、sum、max等操作需要内部状态来累积结果。在上面的情况下,内部状态很小。在我们的例子里就是一个int或double。不管流中有多少元素要处理,内部状态都是有界的

相反,诸如sort或distinct等操作一开始都和filter和map差不多——都是接受一个流,再生成一个流(中间操作),但有一个关键的区别。从流中排序和删除重复项时都需要知道先前的历史。例如,排序要求所有元素都放入缓冲区后才能给输出流加入一个项目,这一操作的存储要求是无界的。要是流比较大或是无限的,就可能会有问题(把质数流倒序会做什么呢?它应当返回最大的质数,但数学告诉我们它不存在)。我们把这些操作叫作有状态操作

在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值