一、前言
这一节我们来看下Java8的又一新特性:流。
本节主要包括以下内容:
- 流的相关概念
- 使用流
- 收集器
二、流的相关概念
流允许你以声明性方式处理数据集合,可以将其看成遍历数据集的高级迭代器。
流可以透明地并行处理。
1. 什么是流
1.1 定义
从支持数据处理操作的源生成的元素序列
- 元素序列
就像集合一样, 流也提供了一个接口, 可以访问特定元素类型的一组有序值。
因为集合是数据结构, 所以 它的 主要 目的是以特定 的 时间/ 空间 复杂度存储和访问元素
( 如 ArrayList 与 LinkedList)。
但流的目的在于表达计算
, 比如 你 前面 见到 的 filter、 sorted 和 map。 集合讲的是数据, 流讲的是计算。 我们会在后面 几节中详细解释这个思想。
- 源
流 会使 用 一个 提供 数据 的 源, 如 集合、 数组 或 输入/ 输出 资源。 请注意, 从 有序 集合 生成 流 时会 保留 原有 的 顺序。 由 列表 生成 的 流, 其 元素 顺序 与 列表 一致。
- 数据处理操作
流的数据处理功能支持类似于数据库的操作, 以及函数式编程语言中的常用操作,
1.2 特点
流操作有两个重要的特点:
- 流水线
- 内部迭代
- 流水线
很多流操作本身会返回一个流, 这样多个操作就可以链接起来, 形成一个大的流水线。
这 让我 们 下一 章 中的 一些 优化 成为 可能, 如 延迟 和 短路。 流水线的操作可以看作对数据源进行数据库式查询(声明式查询)。
- 内部迭代
与使用迭代器显式迭代的集合不同, 流的迭代操作是在背后进行的。 我们在第1章中简要地提到了这个思想, 下一 节会再谈到它。
2. 流与集合的差异
2.1 什么时候计算
粗略地说, 集合与流之间的差异就在于 什么时候进行计算
。
集合是一个内存中的数据结构, 它包含数据结构中目前所有的值——集合中的每个元素都得先算出来才能添加到集合中。
( 你 可以 往 集合 里 加 东西 或者 删 东西, 但是 不管 什么时候, 集合 中的 每个 元素 都是 放在 内存 里 的, 元素 都得 先 算出来 才能 成为 集合 的 一部分。)
流则是在概念上固定的数据结构
( 你不能添加或删除元素),其元素则是按需计算的
。 这对编程有很大的好处。 在第 6 章中, 我们将展示构建一个质数流( 2, 3, 5, 7, 11, …) 有多简单, 尽管质数有无穷多个。 这个思想就是用户仅仅从流中提取需要的值, 而这些值—— 在用户看不见的地方—— 只会按需生成。 这是 一种生产者-消费者的关系。
从另一个 角度来说, 流就像是一个
延迟创建
的集合: 只有在消费者要求的时候才会计算值( 用管理学的话说这就是需求驱动
, 甚至是实时制造)。 与此相反, 集合则是急切创建
的(供应商驱动
: 先把仓库装满, 再开始卖, 就像 那些昙花一现的圣诞新玩意儿一样)。 以 质数 为例, 要是 想 创建 一个 包含 所有 质数 的 集合, 那 这个 程序 算起 来就 没完没了 了, 因为 总有 新的 质数 要 算, 然后 把 它 加到 集合 里面。 当然 这个 集合 是 永远 也 创建 不完 的, 消费者 这 辈子 都 见 不着 了。
2.2 只能遍历一次
和迭代器
类似, 流只能遍历一次
。 遍历完之后, 我们就说这个流已经被消费
掉了。
你可以从原始数据源那里再获得一个新的流来重新遍历一遍, 就像迭代器一样( 这里假设它是集合之类的可重复的源, 如果是 I/ O 通道就没戏了)。 例如, 以下代码会抛出一个异常, 说流已被消费掉了:
List< String> title = Arrays. asList(" Java8", "In", "Action");
Stream< String> s = title. stream();
//打印 标题 中的 每个 单词
s.forEach( System. out:: println);
//java. lang.IllegalStateException: 流 已被 操作 或 关闭
s.forEach( System. out:: println);
- 哲学中的流和集合
对于喜欢哲学的读者, 你可以
把流看作在时间中分布的一组值
。 相反,集合则是空间( 这里就是计算机内存) 中分布的一组值, 在一个时间点上全体存在
—— 你可以 使用迭代器来访问 for- each 循环中的内部成员。
2.3 外部迭代和内部迭代
使用 Collection 接口需要用户去做迭代( 比如用 for- each), 这称为外部迭代
。 相反, Streams 库使用内部迭代
—— 它帮你把迭代做了, 还把得到的流值存在了某个地方, 你只要给出一个 函数说要干什么就可以了。
(1) 外部迭代示例
//显 式 顺序 迭代 菜单 列表
List< String> names = new ArrayList<>();
for( Dish d: menu){
//提取 名称 并将 其 添加 到 累加器 }
names. add( d. getName());
}
(2) 内部迭代示例
List< String> names = menu.stream()
.map( Dish:: getName) //用 getName 方法 参数 化 map, 提取 菜 名
.collect( toList()); //开始 执行 操作 流水线; 没有 迭代!
3.流操作
//从菜单获得流
List< String> names = menu.stream()
.filter( d -> d. getCalories() > 300) //中间 操作
.map( Dish:: getName) // 中间 操作
.limit( 3) //中间 操作
.collect( toList()); // 将 Stream 转换 为 List
从上面的示例中,可以看到有两类操作:
filter、 map 和 limit 可以连成一条流水线;
collect 触发流水线执行并关闭它。
可以连接起来的流操作称为中间操作
, 关闭流的操作称为终端操作
。
3.1 中间操作
诸如 filter 或 sorted 等中间操作会返回另一个流
。 这让多个操作可以连接起来 形成一个查询。重要的是, 除非流水线上触发一个终端操作, 否则中间操作不会执行任何处理
——它们很懒。 这是因为中间操作一般都可以合并起来, 在终端操作时一次性全部处理
。
为了 搞清 楚 流水线 中 到底 发生了 什么, 我们 把 代码 改 一 改, 让 每个 Lambda 都 打印 出 当前 处理 的 菜肴( 就 像 很多 演示 和 调试 技巧 一样, 这种 编程 风格 要是 搁在 生产 代码 里 那就 吓 死人 了, 但是 学习 的 时候 却可 以 直接 看清 楚 求值 的 顺序):
List< String> names = menu. stream()
.filter( d -> {
System.out.println(" filtering" + d. getName());
return d. getCalories() > 300; }
) //打印 当前 筛选 的 菜肴
.map( d -> {
System. out. println(" mapping" + d. getName());
return d. getName(); }
)//提取 菜 名 时 打印 出来
.limit( 3)
.collect( toList());
System. out. println( names);
输出为:
filtering pork
mapping porkfiltering beef
mapping beeffiltering chicken
mapping chicken[pork, beef, chicken]
你会发现, 有好几种优化利用了流的延迟性质。 第一, 尽管很多菜的 热量都高于 300 卡路里, 但只选出了前三个! 这是因为 limit 操作和 一种称为
短路
的技巧, 我们会在下一章中解释。 第二, 尽管 filter 和 map 是两个独立的操作, 但它们合并到同一次遍历中了( 我们把这种技术叫作循环合并
)。
3.2 终端操作
终端操作会从流的流水线生成结果
。其结果是任何不是流的值, 比如 List、 Integer, 甚至 void。终端操作会消费流。
三、使用流
流的使用一般包括三件事:
一个
数据源
(如集合)来执行一个查询
一个中间操作链
,形成一条流的流水线
一个终端操作
,执行流水线并能生成结果
流的流水线背后的理念类似于构建器模式
。在构建器模式中有一个调用链用来 设置一套配置( 对流来说这就是一个中间操作链),接着是调用 built 方法( 对流来说就是终端操作)。
1.筛选和切片
1.1 filter
该操作会接受一个 谓词( 一个返回 boolean 的函数) 作为参数, 并返回一个 包括所有符合谓词的元素的流。
List< Dish> vegetarianMenu = menu.stream()
.filter( Dish:: isVegetarian) //方法引用 检查菜肴是否适合素食者
.collect( toList());
1.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);
1.3 limit
limit(n) 返回一个不超过给定长度的流。
1.4 skip
skip(n) 方法, 返回一个扔掉了前 n 个元素的流。 如果流中元素不足 n 个, 则 返回一个空流。
2. 映射
2.1 map
map方法会接受一个函数作为参数。 这个函数会被应用到每个元素上, 并将 其映射
成一个新的元素。
List< String> dishNames = menu.stream()
.map(Dish::getName)
.collect(toList());
2.2 flatMap
能将多个流合并为一个流,即扁平化为一个流。
对于一张单词表, 如何返回一张列表, 列出里面各不相同的字符呢? 例如, 给定单词列表[" Hello"," World"], 你想要返回列表[" H"," e"," l", “o”," W"," r"," d"]。
String[] arrayOfWords = {"Goodbye", "World"};
Stream<String> streamOfwords = Arrays.stream( arrayOfWords);
List< String> uniqueCharacters = words. stream()
.map(w -> w. split("")) //将每个单词转换为由其字母构成的数组
.flatMap(Arrays:: stream) //将各个生成流扁平化为单个流
.distinct()
.collect(Collectors.toList());
使用 flatMap 方法的效果是, 各个数组并不是分别映射成一个流, 而是映射成流的内容。 所有使用 map( Arrays:: stream) 时生成的单个流都被合并起来, 即扁平化为一个流
。
图 5- 6 说明了使用 flatMap 方法的效果。
3. 查找和匹配
3.1 anyMatch
检查谓词是否至少匹配一个元素
if( menu.stream().anyMatch( Dish:: isVegetarian)){
System.out.println(" The menu is (somewhat) vegetarian friendly!!");
}
3.2 allMatch
检查谓词是否匹配所有元素
boolean isHealthy = menu.stream().allMatch( d -> d.getCalories() < 1000);
3.3 noneMatch
检查谓词是否不匹配任何元素
boolean isHealthy = menu.stream().noneMatch( d -> d.getCalories() >= 1000);
anyMatch、 allMatch 和 noneMatch 这三个操作都用到了我们所谓的短路, 这就是大家熟悉的 Java 中&& 和|| 运算符短路在流中的版本。
- 短路求值:
- 有些操作不需要处理整个流就能得到结果。 例如, 假设你需要对一个用 and 连起来的大布尔表达式求值。 不管表达式有多长, 你只需找到一个表达式为 false, 就可以推断整个表达式将返回 false, 所以用不着计算整个表达式。 这就是短路。
- 对于流而言, 某些操作( 例如 allMatch、 anyMatch、 noneMatch、 findFirst 和 findAny) 不用处理整个流就能得到结果。
只要找到一个元素, 就可以有结果了
。 同样, limit 也是一个短路操作: 它只需要创建一个给定大小的 流, 而用不着处理流中所有的元素。 在碰到无限大小的流的时候, 这种操作就有用了: 它们可以把无限流变成有限流。 我们会在 5. 7 节 中介绍无限流的例子。
3.4 findAny
返回当前流中的任意元素。
Optional<Dish> dish = menu.stream()
.filter(Dish::isVegetarian)
.findAny();
3.5 findFirst
有些流有一个出现顺序(encounter order) 来指定流中项目出现的逻辑顺序(比如由 List 或排序好的数据列生成的流)。 对于这种流, 你可能想要找到第一个元素。
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 呢?
答案是
并行
。 找到第一个元素在并行上限制更多。 如果你不关心返回的元素是哪个,请使用findAny
, 因为它在使用并行流时限制较少。
4. 归约
将流中所有元素反复结合起来, 得到一个值, 比如一个 Integer。 这样的查询可以被归类为归约操作(
将流归约成一个值
)
reduce 接受 两个 参数:
- 一个初始值, 这里是 0;
- 一个 BinaryOperator< T> 来将两个元素结合起来产生一个新值, 这里我们 用的是 lambda (a, b) -> a + b。
4.1 元素求和
int sum = numbers.stream()
.reduce( 0, (a, b) -> a + b);
4.2 计算最大值
Optional<Integer> min = numbers.stream()
.reduce(Integer::min);
四、收集器
1. 预定义收集器
预定义收集器,即从 Collectors 类提供的工厂方法( 例如 groupingBy) 创建的收集器。
它们主要提供了三大功能:
- 将流元素归约和汇总为一个值
-元素分组
-元素分区
下文中,我们假定你已导入了 Collectors 类的所有静态工厂方法:
import static java. util. stream. Collectors.*;
2. 归约和汇总
但凡要把流中所有的项目合并成一个结果时就可以用收集器
使用归约汇总可以得到如下结果:
- 计数
- 最大最小值
- 总和
- 平均值
- IntSummaryStatistics(包含上面的所有结果)
2.1 计数
long howManyDishes = menu.stream().collect(Collectors. counting());
这还可以写得更为直接:
long howManyDishes = menu.stream().count();
2.2 查找流中的最大值和最小值
假设你想要找出菜单中热量最高的菜。你可以使用两个收集器,
Collectors.maxBy
和Collectors.minBy
,来计算流中的最大或最小值。这两个收集器接收一个Comparator参数来比较流中的元素。你可以创建一个Comparator来根据所含热量对菜肴进行比较,并把它传递给Collectors.maxBy:
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional< Dish> mostCalorieDish = menu.stream()
.collect(maxBy(dishCaloriesComparator));
2.3 汇总
另一个常见的返回单个值的归约操作是对流中对象的一个数值字段求和。或者你可能想要求平均数。这种操作被称为汇总操作。
(1)求和
Collectors类专门为汇总提供了一个工厂方法:
Collectors.summingInt
。它可接受一个把对象映射为求和所需int的函数,并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。举个例子来说,你可以这样求出菜单列表的总热量:
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
收集过程如下:
在遍历流时,会把每一道菜都映射为其热量,然后把这个数字累加到一个累加器(这里的初始值0)。
Collectors.summingLong
和Collectors.summingDouble
方法的作用完全一样,可以用于求和字段为long或double的情况。
(2)平均值
但汇总不仅仅是求和;还有
Collectors.averagingInt
,连同对应的averagingLong
和averagingDouble
可以计算数值的平均数:
double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));
2.4 得到所有归约信息
通过一次summarizing操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:
IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
这个收集器会把所有这些信息收集到一个叫作IntSummaryStatistics的类里,它提供了方便的取值(getter)方法来访问结果。打印menuStatistics会得到以下输出:
IntSummaryStatistics{ count= 9, sum= 4300, min= 120, average= 477. 777778, max= 800}
同样,相应的 summarizingLong 和 summarizingDouble 工厂方法有相关的 LongSummaryStatistics 和 DoubleSummaryStatistics 类型,适用于收集的属性是原始类型 long 或 double 的情况。
2.5 连接字符串
joining工厂方法返回的收集器会把对流中每一个对象应用toString方法得到的所有字符串连接成一个字符串
。
这意味着你把菜单中所有菜肴的名称连接起来,如下所示:
String shortMenu = menu.stream()
.map(Dish::getName)
.collect(joining());
产生如下结果:
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon
但该字符串的可读性并不好。
幸好,joining工厂方法有一个重载版本可以接受元素之间的分界符,这样你就可以得到一个逗号分隔的菜肴名称列表:
String shortMenu = menu.stream()
.map(Dish::getName)
.collect(joining(", "));
产生结果如下:
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon
3. 分组
(1)简单分组
一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。
假设你要把菜单中的菜按照类型进行分类,有肉的放一组,有鱼的放一组,其他的都放另一组。用Collectors.groupingBy工厂方法返回的收集器就可以轻松地完成这项任务,如下所示:
Map<Dish.Type, List<Dish>> dishesByType = menu.stream()
.collect(groupingBy(Dish::getType));
其结果是下面的Map
{FISH=[ prawns, salmon], OTHER=[ french fries, rice, season fruit, pizza], MEAT=[ pork, beef, chicken]}
这里,你给groupingBy方法传递了一个Function(以方法引用的形式),它提取了流中每一道Dish的Dish.Type。我们把这个Function叫作
分类函数
,因为它用来把流中的元素分成不同的组。如图6-4所示,分组操作的结果是一个Map,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值
。
(2)使用lambda表达式实现复杂分类逻辑
分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访问器要复杂。例如,你可能想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。由于Dish类的作者没有把这个操作写成一个方法,你无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream()
.collect( groupingBy( dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}));
3.1 多级分组
要实现多级分组,我们可以使用一个由双参数版本的Collectors.groupingBy工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受collector类型的第二个参数。
那么要进行二级分组的话,我们可以把一个内层groupingBy传递给外层groupingBy,并定义一个为流中项目分类的二级标准,
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream().
collect(groupingBy(Dish::getType, //一级分类函数
groupingBy( dish -> { //二级 分类 函数
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel. FAT;
} )
)
);
这个二级分组的结果就是像下面这样的两级Map:
{MEAT={ DIET=[ chicken], NORMAL=[ beef], FAT=[ pork]}, FISH={ DIET=[ prawns], NORMAL=[ salmon]}, OTHER={ DIET=[ rice, seasonal fruit], NORMAL=[ french fries, pizza]}}
4. 分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组,false是一组。
Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
.collect(partitioningBy(Dish:: isVegetarian));
这会返回下面的Map
{false=[ pork, beef, chicken, prawns, salmon], true=[ french fries, rice, season fruit, pizza]}
那么通过Map中键为true的值,就可以找出所有的素食菜肴了:
List<Dish> vegetarianDishes = partitionedMenu.get(true);
四、参考资料
- 本文是对《java8实战》的总结