Java函数式编程学习笔记(二)
前言
上次我们谈到,在Java代码中使用Lambda
可以显著减少代码量,提高开发效率。那么在Java项目开发过程中,还有一个非常好用的接口:Stream Api
,我们叫它Stream
流。
注意:这个
Stream
不同于java.io
的InputStream
和OutputStream
,它代表的是若干个任意Java对象元素的序列,这一点特性和Collection
有点相似,但是Stream
并不会真正存储这些元素,而是根据需要来实时计算和存储,真正的计算通常发生在最终结果的输出,是一种惰性计算
。
Stream
使用一种类似用SQL
语句从数据库查询数据的直观方式来提高Java集合运算逻辑的编码效率,让我们能够写出高效率、干净、简洁的代码。在使用它的时候,我感受到了前所未有的便利。
Stream流
什么是Stream?
Stream
是一个来自数据源的元素队列并支持转换与聚合操作。
- 元素是特定类型的对象,它们形成一个队列。Java中的
Stream
并不会存储元素,而是按需计算。 - 数据源是流的来源。可以是
集合
,数组
,I/O channel
,产生器generator
等。 - 转换操作与聚合操作是类似SQL语句一样的操作,可以使用
map
,filter
,reduce
,find
,match
,sorted
等方法将一个Stream
转换成另一个Stream
。
Stream
还有两个区别于Collection
的基本特征:
- Pipelining: 中间操作都会返回一个流对象,而不是最终的集合等结构。这样多个操作可以串联成一个管道,如同流式风格(
fluent style
)。这样做可以对操作进行优化,比如延迟执行(laziness
)和短路(short-circuiting
)。 - 内部迭代: 以前我们对集合遍历都是通过
Iterator
或者For-Each
代码块的方式, 显式的在集合外部进行迭代,这叫做外部迭代。Stream
提供了内部迭代的方式,通过上面介绍的转换与聚合操作实现。
创建Stream
我们可以通过许多方式将常见的结构转换成Stream
来使用。
Stream.of()
创建Stream
最简单的方式是直接用Stream.of()
静态方法,传入可变参数即创建了一个能输出确定元素的Stream
:
1 | public class Main { |
虽然这种方式基本上没啥实质性用途,但测试的时候很方便。
基于数组或Collection
第二种创建Stream
的方法是基于一个数组或者Collection
,这样该Stream
输出的元素就是数组或者Collection持有的元素:
1 | public class Main { |
事实上,所有Collection
都可以轻松地转换成Stream
,只需调用stream()
方法即可。
1 | public class Main { |
上述两种创建Stream
的方法都是把一个现有的序列变为Stream
,它的元素是固定的。
基于Supplier
创建Stream
还可以通过Stream.generate()
方法,它需要传入一个Supplier
对象:
Stream<String> s = Stream.generate(Supplier<String> sp); |
基于Supplier
创建的Stream
会不断调用Supplier.get()
方法来不断产生下一个元素,这种Stream
保存的不是元素,而是算法,它可以用来表示无限序列。
例如,我们编写一个能不断生成自然数的Supplier
,它的代码非常简单,每次调用get()
方法,就生成下一个自然数:
1 | public class Main { |
因为Java的范型不支持基本类型,所以我们无法用
Stream<int>
这样的类型,会发生编译错误。为了保存int
,只能使用Stream<Integer>
,但这样会产生频繁的装箱、拆箱操作。为了提高效率,Java标准库提供了IntStream
、LongStream
和DoubleStream
这三种使用基本类型的Stream
,它们的使用方法和范型Stream
没有大的区别,设计这三个Stream
的目的是提高运行效率。
操作Stream
前面提到,我们可以通过一些转换与聚合操作对Stream
进行一些处理,来达到处理数据的目的。我们通常把Stream
的操作写成链式操作,这样显得代码更简洁。
使用map
map
方法是最常用的转换操作。它能够把一种操作运算,映射到一个序列的每一个元素上,可以将一种元素类型转换成另一种元素类型。
例如,对x
计算它的平方,可以使用函数f(x) = x * x
。我们把这个函数映射到一个序列1,2,3,4,5
上,就得到了另一个序列1,4,9,16,25
:
1 | Stream<Integer> s1 = Stream.of(1, 2, 3, 4, 5); |
利用map()
,不但能完成数学计算,对于字符串操作,以及任何Java对象都是非常有用的。例如:
1 | public class Main { |
使用filter
filter()
是另一种转换操作,它能够对一个Stream
的每个元素进行判断,不满足条件的就被过滤掉了,剩下的满足条件的元素就构成了一个新的Stream
。
例如,我们对1,2,3,4,5
这个Stream
调用filter()
,传入的测试函数f(x) = x % 2 != 0
用来判断元素是否是奇数,这样就过滤掉偶数,只剩下奇数,因此我们得到了另一个序列1,3,5
:
1 | public class Main { |
filter()
除了常用于数值外,也可应用于任何Java对象。例如,从一组给定的LocalDate
中过滤掉工作日,以得到休息日:
1 | public class Main { |
使用reduce
reduce()
是一种聚合操作,它可以把一个Stream
的所有元素按照聚合函数聚合成一个结果。我们以一个简单的求和运算为例:
1 | public class Main { |
可见,reduce()
方法有两个参数,第一个参数是一个初始值,第二个参数是聚合函数。reduce()
操作首先初始化结果为指定值(这里是0
),紧接着,reduce()
对每个元素依次调用(acc, n) -> acc + n
,其中,acc
是上次计算的结果。
我们还可以把求和改成求积,代码也十分简单:
1 | public class Main { |
注意:计算求积时,初始值必须设置为
1
。
除了可以对数值进行累积计算外,灵活运用reduce()
也可以对Java对象进行操作。下面的代码演示了如何将配置文件的每一行配置通过map()
和reduce()
操作聚合成一个Map<String, String>
:
1 | public class Main { |
其他操作
除了前面介绍的常用操作外,Stream
还提供了一系列非常有用的方法。
排序
对Stream
的元素进行排序十分简单,只需调用sorted()
方法:
1 | public class Main { |
此方法要求Stream
的每个元素必须实现Comparable
接口。如果要自定义排序,传入指定的Comparator
即可:
1 | List<String> list = List.of("Orange", "apple", "Banana") |
去重
对一个Stream
的元素进行去重,可以直接用distinct()
:
1 | List.of("A", "B", "A", "C", "B", "D") |
截取
截取操作常用于把一个无限的Stream
转换成有限的Stream
,skip()
用于跳过当前Stream
的前N
个元素,limit()
用于截取当前Stream
最多前N
个元素:
1 | List.of("A", "B", "C", "D", "E", "F") |
合并
将两个Stream
合并为一个Stream
可以使用Stream
的静态方法concat()
:
1 | Stream<String> s1 = List.of("A", "B", "C").stream(); |
flatMap
如果Stream
的元素是集合:
1 | Stream<List<Integer>> s = Stream.of( |
由上面三个List
组成的Stream
形成了一个二维数组的形式,而我们希望把上述Stream
转换为Stream<Integer>
,就可以使用flatMap()
:
Stream<Integer> i = s.flatMap(list -> list.stream()); |
因此,所谓flatMap()
,是指把Stream
的每个元素(这里是List
)映射为Stream
,然后合并成一个新的Stream
,即把二维数组转成一维的。
并行
通常情况下,对Stream
的元素进行处理是单线程的,即一个一个元素进行处理。但是很多时候,我们希望可以并行处理Stream
的元素,因为在元素数量非常大的情况,并行处理可以大大加快处理速度。
把一个普通Stream
转换为可以并行处理的Stream
非常简单,只需要用parallel()
进行转换:
1 | Stream<String> s = ... |
或者直接在创建Stream
时使用parallelStream()
方法,为集合创建并行流:
1 | List list = ... |
其他聚合方法
除了reduce()
和collect()
外,Stream
还有一些常用的聚合方法:
count()
:用于返回元素个数;max(Comparator<? super T> cp)
:找出最大元素;min(Comparator<? super T> cp)
:找出最小元素。
针对IntStream
、LongStream
和DoubleStream
,还额外提供了以下聚合方法:
sum()
:对所有元素求和;average()
:对所有元素求平均数。
还有一些方法,用来测试Stream
的元素是否满足以下条件:
boolean allMatch(Predicate<? super T>)
:测试是否所有元素均满足测试条件;boolean anyMatch(Predicate<? super T>)
:测试是否至少有一个元素满足测试条件。
最后一个常用的方法是forEach()
,它可以循环处理Stream
的每个元素,我们经常传入System.out::println
来打印Stream
的元素:
1 | Stream<String> s = ... |
输出Stream
我们使用Stream
对数据进行了处理,最后就需要输出Stream
里的元素了。
在此之前,我们先对之前介绍的操作分个类:一类是转换操作,即把一个Stream
转换为另一个Stream
,例如map()
和filter()
,另一类是聚合操作,即对Stream
的每个元素进行计算,得到一个确定的结果,例如reduce()
。
大家有没有注意到,在介绍reduce()
方法时,我们用reduce()
编写了求和与求积运算,它们返回的并不是另一个Stream
,而是一个值sum
。因此,上面两种操作的区别就是:转换操作并不会触发任何计算;而聚合操作会立刻促使Stream
输出它的每一个元素,并依次纳入计算,以获得最终结果。所以,我们可以使用聚合操作输出Stream
。
输出为List
reduce()
只是一种聚合操作,如果我们希望把Stream
的元素保存到集合,例如List
,因为List
的元素是确定的Java对象,因此,把Stream
变为List
不是一个转换操作,而是一个聚合操作,它会强制Stream
输出每个元素。
下面的代码演示了如何将一组String
先过滤掉空字符串,然后把非空字符串保存到List
中:
1 | public class Main { |
把Stream的每个元素收集到List
的方法是调用collect()
并传入Collectors.toList()
对象,它实际上是一个Collector
实例,通过类似reduce()
的操作,把每个元素添加到一个收集器中,这里实际上是ArrayList
。
类似的,collect(Collectors.toSet())
可以把Stream
的每个元素收集到Set
中。
输出为数组
把Stream的元素输出为数组和输出为List
类似,我们只需要调用toArray()
方法,并传入数组的构造方法:
1 | List<String> list = List.of("Apple", "Banana", "Orange"); |
注意到传入的构造方法是String[]::new
,它的签名实际上是IntFunction<String[]>
定义的String[] apply(int)
,即传入int
参数,获得String[]
数组的返回值。
输出为Map
如果我们要把Stream
的元素收集到Map
中,就稍微麻烦一点。因为对于每个元素,添加到Map
时都需要key
和value
,因此,我们要指定两个映射函数,分别把元素映射为key
和value
:
1 | public class Main { |
分组输出
Stream
还有一个强大的分组功能,可以按组输出。我们看下面的例子:
1 | public class Main { |
分组输出使用Collectors.groupingBy()
,它需要提供两个函数:一个是分组的key
,这里使用s -> s.substring(0, 1)
,表示只要首字母相同的String
分到一组,第二个是分组的value
,这里直接使用Collectors.toList()
,表示输出为List
,上述代码运行结果如下:
A=[Apple, Avocado, Apricots],
B=[Banana, Blackberry],
C=[Coconut, Cherry]
小结
Stream
提供的常用操作有:
类型 | 方法 |
---|---|
转换操作 | map() ,filter() ,sorted() ,distinct() |
合并操作 | concat() ,flatMap() |
并行处理 | parallel() |
聚合操作 | reduce() ,collect() ,count() ,max() ,min() ,sum() ,average() |
其他操作 | allMatch() ,anyMatch() ,forEach() |
应用
需求规格
在项目中,需要编写这样的业务逻辑:根据一串id
在数据库中查询并返回与id
匹配的数据;或者再复杂一些,在一个表中根据id
查到数据,然后根据这些数据的其他字段查询另一个表的数据。在这些逻辑中,需要保证代码拥有较高的可读性和健壮性,保证数据库表或者DTO
入参产生变化时,相应的业务代码不需要做大的改动。
解决方案
使用Stream
代替for
循环遍历数组,使用map
可以从数据库表一行数据里提取出一个id
字段,再用collect
将其转换为List
。
使用Stream
存储id
时,如果用这些id
查询到了多行数据,有可能会返回由List
组成的Stream
,这时就可以用flatMap
,将它们转换成单行数据组成的Stream
。
实例
假设已得到一个实体数组:
List<EmissionCalcParamDTO> emissionCalcParams |
我们可以使用Stream
轻松地获取这些实体的id
,只需在map
中传入getter
方法:
1 | List<Long> paramIdList = emissionCalcParams.stream() |
之后,可以根据这些id
在数据库中查询另一个表的数据,这里使用了MyBatis-Plus的条件构造器编写查询条件。
1 | List<DetermineDTO> determineList = new LambdaQueryChainWrapper<>(determineMapper) |
最后,根据这些数据里的字段,返回第三个表的数据:
1 | return determineList.stream() |
可以注意到,这些map
传入了getter
方法和内部方法,对Stream
进行多次转换,最终得到我们想要的结果。
再来看另一个实例。这次我们根据id获取到的数据是由List
组成的,需要将它们转换成单行数据,即去除它们的分组。我们可以使用flatMap
,把Stream
的每个List
映射为Stream
,然后合并成一个新的Stream
,只需传入Collection
的内部方法stream()
即可。
1 | return deviceList.stream().map(MonitoringDeviceDTO::getId) |
题外话
我编写Java代码使用的IDE
是IntelliJ IDEA
。在编写Stream
的链式操作时适当换行,IDEA
可以将每一行链式操作得到的数据类型显示在行末,清晰地展现出Stream
的Pipelining
特点,非常有助于阅读和编写代码。
此外,IDEA
内置的Lombok
插件(小辣椒)可以自动生成类的getter
与setter
方法,不需要手动重复编写,需要时直接调用就好,且代码自动补全功能会在这些自动生成的方法图标右下角显示一个小辣椒,非常有趣。
非常感谢你的阅读,辛苦了!
参考文章: (感谢以下资料提供的帮助)