使用流
本章内容
- 筛选、切片和匹配
- 查找、匹配和归约
- 使用数值范围等数值流
- 从多个源创建流
- 无限流
使用流的好处
使用流处理数据的方式很有用,因为你让Stream API管理如何处理数据。这样Stream API就可 以在背后进行多种优化。此外,使用内部迭代的话,Stream API可以决定并行运行你的代码。这 要是用外部迭代的话就办不到了,因为你只能用单一线程挨个迭代。
筛选和切片
用谓词筛选(filter),筛选出各不相同的元素,忽略(skip)流中的头几个元素,或将流截短(limit)至指定长度。
用谓词筛选
Streams接口支持filter方法(你现在应该很熟悉了)。该操作会接受一个谓词(一个返回 boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流。
筛选不同的元素
流还支持一个叫作 distinct 的方法, 它会返回一个元素各异(根据流所生成元素的 hashCode和equals方法实现)的流。例如,以下代码会筛选出列表中所有的偶数,并确保没有 重复。
截短流
流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递 给limit。如果流是有序的,则最多会返回前n个元素。
跳过元素
流还支持skip(n)方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!
映射
一个非常常见的数据处理套路就是从某些对象中选择信息。比如在SQL里,你可以从表中选 择一列。Stream API也通过map和flatMap方法提供了类似的工具。
对流中的每一个元素应用函数
流支持map方法,它会接受一个函数作为参数。这个函数会被应用到每个元素上,并将其映 射成一个新的元素。
流的扁平化
让我们拓展一下:对于一张单 词表,如何返回一张列表,列出里面各不相同的字符呢?例如,给定单词列表 [“Hello”,“World”],你想要返回列表[“H”,“e”,“l”, “o”,“W”,“r”,“d”]。
你可能会认为这很容易,你可以把每个单词映射成一张字符表,然后调用distinct来过滤 重复的字符。第一个版本可能是这样的:
|
|
这个方法的问题在于,传递给map方法的Lambda为每个单词返回了一个String[](String 列 表)。 因此 , map 返回 的流 实际上 是 Stream<String[]>
类 型的 。 你真 正想 要的是 用 Stream<String>
来表示一个字符流。图5-5说明了这个问题。
幸好可以用flatMap来解决这个问题!让我们一步步看看怎么解决它。
-
尝试使用map和Arrays.stream()
首先,你需要一个字符流,而不是数组流。有一个叫作Arrays.stream()的方法可以接受 一个数组并产生一个流,例如:
1 2
String[] arrayOfWords = {"Goodbye", "World"}; Stream<String> streamOfwords = Arrays.stream(arrayOfWords);
把它用在前面的那个流水线里,看看会发生什么:
当前的解决方案仍然搞不定!这是因为, 你现在得到的是一个流的列表(更准确地说是
Stream<Stream>
)!的确,你先是把每个单词转换成一个字母数组,然后把每个数组变成了一 个独立的流。 -
使用flatMap
使用flatMap方法的效果是,各个数组并不是分别映射成一个流,而是映射成流的内容。所有使用map(Arrays::stream)时生成的单个流都被合并起来,即扁平化为一个流。图5-6说明了 使用flatMap方法的效果。把它和图5-5中map的效果比较一下。
一言以蔽之,flatmap方法让你把一个流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。
1 2 3 4 5 6 7 8 9 10
public class Demo { public static void main(String[] args) { List<String> words = Lists.newArrayList("Hello", "World"); // 把流中的每个值[String]转换成一个流[stream<char[]>],再合并成一个大的流 words.stream().flatMap(word -> Arrays.stream(word.split(""))).forEach(System.out::println); // 上面写法等于下面写法 // 把流中的每个值[char[]]转换成一个流[stream<char[]>],再合并成一个大的流 words.stream().map(word -> word.split("")).flatMap(Arrays::stream).forEach(System.out::println); } }
案例:给定两个数字列表,如何返回所有的数对呢?例如,给定列表[1, 2, 3]和列表[3, 4],应该返回[(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]。为简单起见,你可以用有两个元素的数组来代表数对。
1 2 3
List<Integer> a = Lists.newArrayList(1, 2, 3); List<Integer> b = Lists.newArrayList(3, 4); a.stream().flatMap(i -> b.stream().map(j -> new int[]{i, j})).forEach(i -> System.out.println(Arrays.toString(i)));
查找和匹配
另一个常见的数据处理套路是看看数据集中的某些元素是否匹配一个给定的属性。Stream API通过allMatch、anyMatch、noneMatch、findFirst和findAny方法提供了这样的工具。
检查谓词中是否至少匹配一个元素
anyMatch方法可以回答“流中是否有一个元素能匹配给定的谓词”。比如,你可以用它来看 看菜单里面是否有素食可选择:
|
|
anyMatch方法返回一个boolean,因此是一个终端操作。
检查谓词中是否匹配所有元素
allMatch方法的工作原理和anyMatch类似,但它会看看流中的元素是否都能匹配给定的谓词。比如,你可以用它来看看菜品是否有利健康(即所有菜的热量都低于1000卡路里):
|
|
noneMatch
和allMatch相对的是noneMatch。它可以确保流中没有任何元素与给定的谓词匹配。比如, 你可以用noneMatch重写前面的例子:
|
|
anyMatch、allMatch和noneMatch这三个操作都用到了我们所谓的短路,这就是大家熟悉 的Java中&&
和||
运算符短路在流中的版本。
短路求值
对于流而言,某些操作(例如allMatch、anyMatch、noneMatch、findFirst和findAny)不用处理整个流就能得到结果。只要找到一个元素,就可以有结果了。同样,limit也是一个短路操作:它只需要创建一个给定大小的流,而用不着处理流中所有的元素。在碰到无限大小的流的时候,这种操作就有用了:它们可以把无限流变成有限流。
查找元素
findAny方法将返回当前流中的任意元素。它可以与其他流操作结合使用。比如,你可能想 找到一道素食菜肴。你可以结合使用filter和findAny方法来实现这个查询:
|
|
流水线将在后台进行优化使其只需走一遍,并在利用短路找到结果时立即结束。
Optional简介
Optional<T>
类(java.util.Optional)是一个容器类,代表一个值存在或不存在。在 上面的代码中,findAny可能什么元素都没找到。Java 8的库设计人员引入了Optional<T>
,这 样就不用返回众所周知容易出问题的null了。我们在这里不会详细讨论Optional,因为第10章 会详细解释你的代码如何利用 Optional , 避免和 null 检查相关的bug。 不过现在, 了解一下Optional里面几种可以迫使你显式地检查值是否存在或处理值不存在的情形的方法也不错。
- isPresent()将在Optional包含值的时候返回true, 否则返回false。
ifPresent(Consumer<T> block)
会在值存在的时候执行给定的代码块。我们在第3章介绍了Consumer函数式接口;它让你传递一个接收T类型参数,并返回void的Lambda表达式。- T get()会在值存在时返回值,否则抛出一个NoSuchElement异常。
- T orElse(T other)会在值存在时返回值,否则返回一个默认值。
查找第一个元素
有些流有一个出现顺序(encounter order)来指定流中项目出现的逻辑顺序(比如由List或 排序好的数据列生成的流)。对于这种流,你可能想要找到第一个元素。为此有一个findFirst 方法,它的工作方式类似于findany。
何时使用findFirst和findAny
你可能会想,为什么会同时有findFirst和findAny呢?答案是并行。找到第一个元素在并行上限制更多。如果你不关心返回的元素是哪个,请使用findAny,因为它在使用并行流时限制较少。
归约
到目前为止, 你见到过的终端操作都是返回一个 boolean ( allMatch 之类的)、 void (forEach)或Optional对象(findAny等)。你也见过了使用collect来将流中的所有元素组 合成一个List。
在本节中,你将看到如何把一个流中的元素组合起来,使用reduce操作来表达更复杂的查 询,比如“计算菜单中的总卡路里”或“菜单中卡路里最高的菜是哪一个”。此类查询需要将流中所有元素反复结合起来,得到一个值,比如一个Integer。这样的查询可以被归类为归约操作 (将流归约成一个值)。用函数式编程语言的术语来说,这称为折叠(fold),因为你可以将这个操 作看成把一张长长的纸(你的流)反复折叠成一个小方块,而这就是折叠操作的结果。
元素求和
有初始值
|
|
没有初始值
|
|
最大值和最小值
最大值
|
|
最小值
|
|
测试:怎样用map和reduce方法数一数流中有多少个菜呢?
答案:要解决这个问题,你可以把流中每个元素都map成数字1,然后用reduce求和。这相当于按顺序数流中的元素个数。
map和reduce的连接通常称为map-reduce模式,因Google用它来进行网络搜索而出名,因为它很容易并行化。请注意,在第4章中我们也看到了内置count方法可用来计算流中元素的个数:
long count = menu.stream().count();
归约方法的优势与并行化
相比于前面写的逐步迭代求和,使用reduce的好处在于,这里的迭代被内部迭代抽象掉了,这让内部实现得以选择并行执行reduce操作。而迭代式求和例子要更新共享变量sum,这不是那么容易并行化的。如果你加入了同步,很可能会发现线程竞争抵消了并行本应带来的性能提升!这种计算的并行化需要另一种办法:将输入分块,分块求和,最后再合并起来。但这样的话代码看起来就完全不一样了。你在第7章会看到使用分支/合并框架来做是什么样子。但现在重要的是要认识到,可变的累加器模式对于并行化来说是死路一条。你需要一种新的模式,这正是reduce所提供的。你还将在第7章看到,使用流来对所有的元素并行求和时,你的代码几乎不用修改: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差不多——都是接受一个流,再生成一个流(中间操作),但有一个关键的区别。从流中排序和删除重复项时都需要知道先前的历史。例如,排序要求所有元素都放入缓冲区后才能给输出流加入一个项目,这一操作的存储要求是无界的。要是流比较大或是无限的,就可能会有问题(把质数流倒序会做什么呢?它应当返回最大的质数,但数学告诉我们它不存在)。我们把这些操作叫作有状态操作。
你现在已经看到了很多流操作,可以用来表达复杂的数据处理查询。表5-1总结了迄今讲过 的操作。你可以在下一节中通过一个练习来实践一下。
付诸实践
|
|
数值流
我们在前面看到了可以使用reduce方法计算流中元素的总和。例如,你可以像下面这样计 算菜单的热量:
|
|
这段代码的问题是,它有一个暗含的装箱成本。每个Integer都必须拆箱成一个原始类型, 再进行求和。要是可以直接像下面这样调用sum方法,岂不是更好?
|
|
但这是不可能的。问题在于map方法会生成一个Stream<T>
。虽然流中的元素是Integer类 型, 但 Streams 接口没有定义 sum 方法。 为什么没有呢?比方说, 你只有一个像 menu 那样的 Stream<Dish>
,把各种菜加起来是没有任何意义的。但不要担心,Stream API还提供了原始类 型流特化,专门支持处理数值流的方法。
原始类型特化
Java 8引入了三个原始类型特化流接口来解决这个问题: IntStream 、 DoubleStream 和LongStream,分别将流中的元素特化为int、long和double,从而避免了暗含的装箱成本。每 个接口都带来了进行常用数值归约的新方法,比如对数值流求和的sum,找到最大元素的max。 此外还有在必要时再把它们转换回对象流的方法。要记住的是,这些特化的原因并不在于流的复杂性,而是装箱造成的复杂性——即类似int和Integer之间的效率差异。
映射到数值流
将流转换为特化版本的常用方法是mapToInt、mapToDouble和mapToLong。这些方法和前 面说的map方法的工作方式一样,只是它们返回的是一个特化流,而不是Stream<T>
。例如,你 可以像下面这样用mapToInt对menu中的卡路里求和:
这里,mapToInt会从每道菜中提取热量,并返回一个IntStream (而不是一个Stream<Integer>
)。然后你就可以调用IntStream接口中定义的sum方法,对卡 路里求和了!请注意,如果流是空的,sum默认返回0。IntStream还支持其他的方便方法,如 max、min、average等。
转换回对象流
同样,一旦有了数值流,你可能会想把它转换回非特化流。例如,IntStream上的操作只能 产 生 原 始 整 数 : IntStream 的 map 操 作 接 受 的 Lambda 必 须 接 受 int 并 返 回 int ( 一 个 IntUnaryOperator)。但是你可能想要生成另一类值,比如Dish。为此,你需要访问Stream 接口中定义的那些更广义的操作。 要把原始流转换成一般流(每个 int 都会装箱成一个 Integer),可以使用boxed方法,如下所示:
你在下一节中会看到,在需要将数值范围装箱成为一个一般流时,boxed尤其有用。
默认值OptionalInt
求和的那个例子很容易,因为它有一个默认值:0。但是,如果你要计算IntStream中的最 大元素,就得换个法子了,因为0是错误的结果。如何区分没有元素的流和最大值真的是0的流呢? 前面我们介绍了 Optional 类, 这是一个可以表示值存在或不存在的容器。 Optional 可以用 Integer、String等参考类型来参数化。对于三种原始流特化,也分别有一个Optional原始类 型特化版本:OptionalInt、OptionalDouble和OptionalLong。
例如,要找到IntStream中的最大元素,可以调用max方法,它会返回一个OptionalInt:
数值范围
和数字打交道时,有一个常用的东西就是数值范围。比如,假设你想要生成1和100之间的所有数字。Java 8引入了两个可以用于IntStream和LongStream的静态方法,帮助生成这种范围: range和rangeClosed。这两个方法都是第一个参数接受起始值,第二个参数接受结束值。但 range是不包含结束值的,而rangeClosed则包含结束值。让我们来看一个例子:
|
|
这里我们用了rangeClosed方法来生成1到100之间的所有数字。它会产生一个流,然后你 可以链接filter方法,只选出偶数。到目前为止还没有进行任何计算。最后,你对生成的流调 用count。因为count是一个终端操作,所以它会处理流,并返回结果50,这正是1到100(包括 两端)中所有偶数的个数。
数值流应用:勾股数
打印100以内的勾股数(用三元组[a,b,c]表示,要求a小于b)
|
|
运行结果
构建流
由值创建流
你可以使用静态方法Stream.of,通过显式值创建一个流。它可以接受任意数量的参数。例 如,以下代码直接使用Stream.of创建了一个字符串流。然后,你可以将字符串转换为大写,再 一个个打印出来:
|
|
你可以使用empty得到一个空流,如下所示:
|
|
由数组创建流
你可以使用静态方法Arrays.stream从数组创建一个流。它接受一个数组作为参数。例如, 你可以将一个原始类型int的数组转换成一个IntStream,如下所示:
|
|
由文件生成流
Java中用于处理文件等I/O操作的NIO API(非阻塞 I/O)已更新,以便利用Stream API。 java.nio.file.Files 中的很多静态方法都会返回一个流。 例如, 一个很有用的方法是 Files.lines,它会返回一个由指定文件中的各行构成的字符串流。使用你迄今所学的内容, 你可以用这个方法看看一个文件中有多少各不相同的词:
|
|
你可以使用Files.lines得到一个流,其中的每个元素都是给定文件中的一行。然后,你 可以对line调用split方法将行拆分成单词。应该注意的是,你该如何使用flatMap产生一个扁 平的单词流,而不是给每一行生成一个单词流。最后,把distinct和count方法链接起来,数 数流中有多少各不相同的单词。
由函数生成流:创建无限流
Stream API提供了两个静态方法来从函数生成流:Stream.iterate和Stream.generate。 这两个操作可以创建所谓的无限流:不像从固定集合创建的流那样有固定大小的流。由iterate 和generate产生的流会用给定的函数按需创建值,因此可以无穷无尽地计算下去!一般来说, 应该使用limit(n)来对这种流加以限制,以避免打印无穷多个值。
打印斐波纳契元组序列中的前10个元素
迭代:Stream.iterate
iterate 方法接受一个初始值(在这里是 0 ), 还有一个依次应用在每个产生的新值上的 Lambda(UnaryOperator<t>
类型)。
|
|
我们先来看一个iterate的简单例子,然后再解释:
|
|
思路:iterate方法要接受一个UnaryOperator<T>
作为参数,而你需要一个像(0,1)这样的元组流。
|
|
运行结果
|
|
生成:Stream.generate
与iterate方法类似,generate方法也可让你按需生成一个无限流。但generate不是依次 对每个新生成的值应用函数的。它接受一个Supplier<T>
类型的Lambda提供新的值。我们先来 看一个简单的用法:
|
|
你可能想知道,generate方法还有什么用途。我们使用的供应源(指向Math.random的方 法引用)是无状态的:它不会在任何地方记录任何值,以备以后计算使用。 但供应源不一定是无状态的。你可以创建存储状态的供应源,它可以修改状态,并在为流生成下一个值时使用。举个 例子, 我们将展示如何利用 generate 创建测验用的斐波纳契数列, 这样你就可以和用 iterate方法的办法比较一下。 但很重要的一点是,在并行代码中使用有状态的供应源是不安全的。因此下面的代码仅仅是为了内容完整,应尽量避免使用!我们会在第7章中进一步讨论这个操作的问题和副作用,以及并行流。
在第3章中已经看到,Lambda允许你创建函数式接口的实例,只要直接内联提供方法的实现就可以。
|
|
前面的代码创建了一个IntSupplier的实例。此对象有可变的状态:它在两个实例变量中 记录了前一个斐波纳契项和当前的斐波纳契项。getAsInt在调用时会改变对象的状态,由此在 每次调用时产生新的值。相比之下,使用iterate的方法则是纯粹不变的:它没有修改现有状态, 但在每次迭代时会创建新的元组。你将在第7章了解到,你应该始终采用不变的方法,以便并行 处理流,并保持结果正确。请注意,因为你处理的是一个无限流,所以必须使用limit操作来显 式限制它的大小;否则,终端操作(这里是forEach)将永远计算下去。同样,你不能对无限流 做排序或归约,因为所有元素都需要处理,而这永远也完不成!
小结
这一章很长,但是很有收获!现在你可以更高效地处理集合了。事实上,流让你可以简洁地 表达复杂的数据处理查询。此外,流可以透明地并行化。以下是你应从本章中学到的关键概念。
- Streams API可以表达复杂的数据处理查询。常用的流操作总结在表5-1中。
- 你可以使用filter、distinct、skip和limit对流做筛选和切片。
- 你可以使用map和flatMap提取或转换流中的元素。
- 你 可 以 使 用 findFirst 和 findAny 方 法 查 找 流 中 的 元 素 。 你 可 以 用 allMatch 、 noneMatch和anyMatch方法让流匹配给定的谓词。 这些方法都利用了短路:找到结果就立即停止计算;没有必要处理整个流。
- 你可以利用 reduce方法将流中所有的元素迭代合并成一个结果,例如求和或查找最大 元素。
- filter和map等操作是无状态的,它们并不存储任何状态。reduce等操作要存储状态才 能计算出一个值。sorted和distinct等操作也要存储状态,因为它们需要把流中的所 有元素缓存起来才能返回一个新的流。这种操作称为有状态操作。
- 流有三种基本的原始类型特化:IntStream、DoubleStream和LongStream。它们的操 作也有相应的特化。
- 流不仅可以从集合创建,也可从值、数组、文件以及iterate与generate等特定方法 创建。
- 无限流是没有固定大小的流。