4.3 流与集合

Java现有的集合概念和新的流概念都提供了接口,来配合代表元素型有序值的数据接口。所谓有序,就是说我们一般是按顺序取值,而不是随机取用。那这两者有什么区别呢?

先来打个直观的比方吧。比如说存在DVD里的电影,这就是一个集合(也许是字节,也许是帧,这个无所谓),因为它包含了整个数据结构。现在再来想想在互联网上通过视频流看同样的电影。现在这是一个流(字节流或帧流)。流媒体视频播放器只要提前下载用户观看位置的那几帧就可以了,这样不用等到流中大部分值计算出来,你就可以显示流的开始部分了(想想观看直播足球赛)。特别要注意,视频播放器可能没有将整个流作为集合,保存所需要的内存缓冲区——而且要是非得等到最后一帧出现才能开始看,那等待的时间就太长了。出于实现的考虑,你也可以让视频播放器把流的一部分缓存在集合里,但和概念上的差异不是一回事。

粗略地说,集合与流之间的差异就在于什么时候进行计算。集合是一个内存中的数据结构,它包含数据结构中目前所有的值——集合中的每个元素都得先算出来才能添加到集合中(你可以往集合里加东西或者删东西,但是不管什么时候,集合中的每个元素都是放在内存里的,元素都得先算出来才能成为集合的一部分)。

相比之下,流则是在概念上固定的数据结构(你不能添加或删除元素),其元素是按需计算的。这对编程有很大的好处。第6章会展示构建一个质数流(2, 3, 5, 7, 11, …)有多简单,尽管质数有无穷多个。这个理念就是用户仅仅从流中提取需要的值,而这些值——在用户看不见的地方——只会按需生成。这是一种生产者–消费者的关系。从另一个角度来说,流就像是一个延迟创建的集合:只有在消费者要求的时候才会计算值(用管理学的话说这就是需求驱动,甚至是实时制造)。

与此相反,集合则是急切创建的(供应商驱动:先把仓库装满,再开始卖,就像那些昙花一现的圣诞新玩意儿一样)。以质数为例,要是想创建一个包含所有质数的集合,那这个程序算起来就没完没了了,因为总有新的质数要算,然后把它加到集合里面。当然这个集合是永远也创建不完的,消费者这辈子都见不着了。

图4-3用DVD对比在线流媒体的例子展示了流和集合之间的差异。

4.3 流与集合 - 图1

图 4-3 流与集合

另一个例子是用浏览器进行互联网搜索。假设你搜索的短语在Google或是网店里面有很多匹配项。你用不着等到所有结果和照片的集合下载完,而是得到一个流,里面有最好的10个或20个匹配项,还有一个按钮来查看下面10个或20个。当你作为消费者点击“下面10个”的时候,供应商就按需计算这些结果,然后再送回你的浏览器上显示。

4.3.1 只能遍历一次

请注意,和迭代器类似,流只能遍历一次。遍历完之后,我们就说这个流已经被消费掉了。你可以从原始数据源那里再获得一个新的流来重新遍历一遍,就像迭代器一样(这里假设它是集合之类的可重复的源,如果是I/O通道就“没戏”了)。例如,以下代码会抛出一个异常,说流已被消费掉了:

  1. List<String> title = Arrays.asList("Modern", "Java", "In", "Action");
  2. Stream<String> s = title.stream();
  3. s.forEach(System.out::println); ←---- 打印标题中的每个单词
  4. s.forEach(System.out::println); ←---- java.lang.IllegalStateException:流已被操作或关闭

所以要记得,流只能消费一次!

哲学中的流和集合

对于喜欢哲学的读者,你可以把流看作在时间中分布的一组值。相反,集合则是空间(这里就是计算机内存)中分布的一组值,在一个时间点上全体存在——你可以使用迭代器来访问for-each循环中的内部成员。

集合和流的另一个关键区别在于它们遍历数据的方式。

4.3.2 外部迭代与内部迭代

使用Collection接口需要用户去做迭代(比如用for-each),这称为外部迭代。相反,Stream库使用内部迭代——它帮你把迭代做了,还把得到的流值存在了某个地方,你只要给出一个函数说要干什么就可以了。下面的代码列表说明了这种区别。

代码清单 4-1 集合:用for-each循环外部迭代

  1. List<String> names = new ArrayList<>();
  2. for(Dish dish: menu){ ←---- 显式顺序迭代菜单列表
  3. names.add(dish.getName()); ←---- 提取名称并将其添加到累加器
  4. }

请注意,for-each还隐藏了迭代中的一些复杂性。for-each结构是一个语法糖,它背后的东西用Iterator对象表达出来会更丑陋。

代码清单 4-2 集合:用背后的迭代器做外部迭代

  1. List<String> names = new ArrayList<>();
  2. Iterator<String> iterator = menu.iterator();
  3. while(iterator.hasNext()) { ←---- 显式迭代
  4. Dish dish = iterator.next();
  5. names.add(dish.getName());
  6. }

代码清单 4-3 流:内部迭代

  1. List<String> names = menu.stream()
  2. .map(Dish::getName) ←---- getName方法参数化map,提取菜名
  3. .collect(toList()); ←---- 开始执行操作流水线;没有迭代!

让我们用一个比喻来解释内部迭代的差异和好处吧。比方说你正在和你两岁的女儿索菲亚说话,希望她能把玩具收起来。

你:“索菲亚,我们把玩具收起来吧。地上还有玩具吗?”

索菲亚:“有,有球。”

你:“好,把球放进盒子里。还有吗?”

索菲亚:“有,那是我的娃娃。”

你:“好,把娃娃放进盒子里。还有吗?”

索菲亚:“有,有我的书。”

你:“好,把书放进盒子里。还有吗?”

索菲亚:“没了,没有了。”

你:“好,我们收好啦。”

这正是你每天都要对Java集合所做的。你外部迭代一个集合,显式地取出每个项目再加以处理。如果你只需跟索菲亚说“把地上所有的玩具都放进盒子里”就好了。内部迭代比较好的原因有两个:第一,索非亚可以选择一只手拿娃娃,另一只手拿球;第二,她可以决定先拿离盒子最近的那个东西,然后再拿别的。同样的道理,内部迭代时,项目可以透明地并行处理,或者以更优化的顺序进行处理。要是用Java过去的那种外部迭代方法,这些优化都是很困难的。这似乎有点儿鸡蛋里挑骨头,但这差不多就是Java 8引入流的理由了——Streams库的内部迭代可以自动选择一种适合你硬件的数据表示和并行实现。与此相反,一旦选择了for-each这样的外部迭代,那你基本上就要自己管理所有的并行问题了(自己管理实际上意味着“某个良辰吉日我们会把它并行化”或“开始了关于任务和synchronized的漫长而艰苦的斗争”)。Java 8需要一个类似于Collection却没有迭代器的接口,于是就有了Stream!图4-4说明了流(内部迭代)与集合(外部迭代)之间的差异。

4.3 流与集合 - 图2

图 4-4 内部迭代与外部迭代

我们已经介绍了集合与流在概念上的差异,特别是流利用内部迭代自动地替你执行了迭代。但是,除非你预先定义好了能隐藏迭代的操作列表,例如filtermap,否则这一特性对你不一定有用。大多数这类操作都接受Lambda表达式作为参数,因此你可以利用前几章介绍的方法对它的行为进行参数化。Java语言的设计者为Stream API提供了大量的操作,可以表达非常复杂的数据处理查询逻辑。现在先简要地看一下这些操作,下一章中会配上例子详细讨论。为了检验你对外部迭代和内部迭代的理解,请尝试一下测验4.1。

测验4.1:外部迭代与内部迭代

基于你对代码清单4-1和代码清单4-2中外部迭代的学习,请选择一种流操作来重构下面的代码。

  1. List<String> highCaloricDishes = new ArrayList<>();
  2. Iterator<String> iterator = menu.iterator();
  3. while(iterator.hasNext()) {
  4. Dish dish = iterator.next();
  5. if(dish.getCalories() > 300) {
  6. highCaloricDishes.add(d.getName());
  7. }
  8. }

答案:应该选择使用filter模式。

  1. List<String> highCaloricDish =
  2. menu.stream()
  3. .filter(dish -> dish.getCalories() > 300)
  4. .collect(toList());

即使你现在对如何准确地编写流查询还不太熟悉也不必担心,下一章会深入探讨这部分内容。