本章我们介绍Java的函数式编程。
我们先看看什么是函数。函数是一种最基本的任务,一个大型程序就是一个顶层函数调用若干底层函数,这些被调用的函数又可以调用其他函数,即大任务被一层层拆解并执行。所以,函数就是面向过程程序设计的基本单元。
Java不支持单独定义函数,但可以把静态方式视为独立的函数,把实例方法视为自带this参数的函数。而函数式编程(Functional Programming),虽然也可以归结到面向过程的程序设计,但其思想更接近数学计算。
我们首先要搞明白计算机(Computer)和计算(Compute)的概念。
在计算机的层次上,CPU执行的是加减乘除的指令代码,以及各种条件判断和跳转指令,所以,汇编语言是最贴近计算机的语言。
而计算则指数学意义上的计算,越是抽象的计算,离计算机硬件越远。
对应到编程语言,就是越低级的语言,越贴近计算机,抽象程度低,执行效率高,比如C语言;越高级的语言,越贴近计算,抽象程度高,执行效率低,比如Lisp语言。
函数式编程就是一种抽象程度很高的编程范式,纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数我们称之为没有副作用。而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的。
函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数!
函数式编程最早是数学家阿隆佐·邱奇研究的一套函数变换逻辑,又称Lambda Calculus(λ-Calculus),所以也经常把函数式编程称为Lambda计算。
Java平台从Java 8开始,支持函数式编程。
Lamda基础
在Java程序中,我们经常遇到一大堆单方法接口,即一个接口只定义了一个方法:
- Comparator
- Runnable
- Callable
以Comparator为例,我们想要调用Arrays.sort()时,可以传入一个Comparator实例,以匿名类方式编写如下:
1 | |
上述写法非常繁琐。从Java 8开始,我们可以用Lambda表达式替换单方法接口。改写代码如下:
1 | |
观察Lamda表达式的写法,它只需要写出方法定义:
1 | |
其中,参数是(s1, s2),参数类型可以省略,因为编译器可以自动推断出String类型。->{...}表示方法体,所有代码写在内部即可。Lamda表达式没有class定义,因此写法非常简洁。如果只有一行return xxx;的代码,还可以使用更简单的方法:
1 | |
返回值类型,也是由编译器自动推断的,这里推断的返回值是int,因此,只要返回int,编译器就不会报错。
FunctionalInterface
我们把定义了单方法的接口称之为FunctionalInterface,用注解@FunctionalInterface标记。例如,Callable接口:
1 | |
再来看Comparator接口:
1 | |
虽然Comparator接口有很多方法,但只有一个抽象方法int compare(T o1, T o2),其他的方法都是default方法或static方法。另外注意到boolean equals(Object obj)是Object定义的方法,不算在接口方法内。因此,Comparator也是一个FunctionalInterface。
小结
单方法接口被称为FunctionalInterface。接收FunctionalInterface作为参数的时候,可以把实例化的匿名类改写为Lambda表达式,能大大简化代码。Lambda表达式的参数和返回值均可由编译器自动推断。
方法引用
使用Lambda表达式,我们就可以不必编写FunctionalInterface接口的实现类,从而简化代码:
1 | |
实际上,除了Lambda表达式,我们还可以直接传入方法引用。
1 | |
上述代码在Arrays.sort()中传入了静态方法cmp的引用,用Main::cmp表示。
因为Comparator<String>接口定义的方法是int compare(String, String),和静态方法int cmp(String, String)相比,除了方法名外,方法参数一致,返回类型相同,因此,我们说两者的方法签名一致,可以直接把方法名作为Lambda表达式传入。
在这里,方法签名只看参数类型和返回类型,不看方法名称,也不看类的继承关系。
我们再看看如何引用实例方法,我们把代码改写如下:
1 | |
不但可以编译通过,而且运行结果也是一样的,这说明String.compareTo()方法也符合Lamda定义。观察String.compareTo()的方法定义:
1 | |
这个方法的签名只有一个参数,为什么和int Comparator<String>.compare(String, String)能匹配呢?
因为实例方法有一个隐含的this参数,String类的compareTo()方法在实际调用时,第一个隐含参数总是传入this,相当于静态方法:
1 | |
所以,String.compareTo()方法也可以作为方法引用传入。
构造方法引用
除了静态方法和实例方法,我们还可以引用构造方法。
我们来看一个例子:如果要把一个List<String>转换为List<Person>,应该怎么办?
1 | |
传统的做法是先定义一个ArrayList<Person>,然后用for循环填充这个List:
1 | |
要更简单地实现String到Person的转换,我们可以引用Person的构造方法:
1 | |
后面我们会讲到Stream的map()方法。现在我们看到,这里的map()需要传入的FunctionalInterface的定义是:
1 | |
把泛型对应上就是方法签名Person apply(String),即传入参数String,返回类型Person。而Person类的构造方法恰好满足这个条件,因为构造方法的参数是String,而构造方法虽然没有return语句,但它会隐式地返回this实例,类型就是Person,因此,此处可以引用构造方法。构造方法的引用写法是类名::new,因此,此处传入Person::new。
小结
FunctionalInterface允许传入:
- 接口的实现类(传统写法,代码较繁琐)
- Lambda表达式(只需列出参数名,由编译器推断类型)
- 符合方法签名的静态方法
- 符合方法签名的实例方法(实例类型被看做第一个参数类型)
- 符合方法签名的构造方法(实例类型被看做返回类型)
FunctionalInterface不强制继承关系,不需要方法名称相同,只要求方法参数(类型和数量)与方法返回类型相同,即认为方法签名相同。
使用Stream
从Java 8开始,不但引入了Lamda表达式,还引入了一个全新的流式API:Stream API。它位于java.util.stream包中。
注意,这个Stream不同于java.io中的InputStream和OutputStream,它代表的是任意Java对象的序列,二者对比如下:
| java.io | java.util.stream | |
|---|---|---|
| 存储 | 顺序读写的byte或char |
顺序输出的任意Java对象实例 |
| 用途 | 序列化至文件或网络 | 内存计算/业务逻辑 |
注意,这个Stream和List也不一样,List存储的每个元素都是已经存储在内存中的某个Java对象,而Stream输出的元素可能并没有预先存储到内存中,而是实时计算出来的。换句话说,List的用途是操作一组已存在的Java对象,而Stream实现的是惰性计算,二者对比如下:
| java.util.List | java.util.stream | |
|---|---|---|
| 元素 | 已分配并存储在内存 | 可能未分配,实时计算 |
| 用途 | 操作一组已存在的Java对象 | 惰性计算 |
Stream看上去不太好理解,但我们来举个例子。
如果我们要表示一个全体自然数的集合,显然,用List是不可能写出来的,因为自然数是无限的,内存再大也没法放到List中:
1 | |
但是,用Stream可以做到。写法如下:
1 | |
我们先不考虑createNaturalStream()这个方法是如何实现的,我们看看如何使用这个Stream。
首先,我们可以对每个自然数做一个平方,这样我们就把这个Stream转换成了另一个Stream:
1 | |
因为这个streamNxN也有无限多个元素,要打印它,必须首先把无限多个元素变成有限个元素,可以用limit()方法截取前100个元素,最后用forEach()处理每个元素,这样,我们就打印出了前100个自然数的平方:
1 | |
我们总结一下Stream的特点:它可以“存储”有限个或无限个元素。这里的存储打了个引号,是因为元素有可能已经全部存储在内存中,也有可能是根据需要实时计算出来的。
Stream的另一个特点是,一个Stream可以轻易地转换为另一个Stream,而不是修改原Stream本身。
最后,真正的计算通常发生在最后结果的获取,也就是惰性计算。
1 | |
惰性计算的特点是:一个Stream转换为另一个Stream时,实际上只存储了转换规则,并没有任何计算发生。
例如,创建一个全体自然数的Stream,不会进行计算,把它转换为上述s2这个Stream,也不会进行计算。再把s2这个无限Stream转换为s3这个有限的Stream,也不会进行计算。只有最后,调用forEach确实需要Stream输出的元素时,才进行计算。我们通常把Stream的操作写成链式操作,代码更简洁:
1 | |
因此,Stream API的基本用法就是:创建一个Stream,然后做若干次转换,最后调用一个求值方法获取真正计算的结果:
1 | |
Stream API的特点是:
- Stream API提供了一套新的流式处理的抽象序列;
- Stream API支持函数式编程和链式操作;
- Stream可以表示无限序列,并且大多数情况下是惰性求值的。
创建Stream
创建Stream有很多种方法。
Stream.of()
创建Stream最简单的方式是直接用Stream.of()静态方法,传入可变参数即创建了一个能输出确定元素的Stream。
1 | |
虽然这种方式没什么实际用途,但测试的时候很方便。
基于数组或Collection
第二种创建Stream的方法是基于一个数组或者Collection,这样该Stream输出的元素就是数组或者Collection持有的元素:
1 | |
把数组变成Stream使用Arrays.stream()方法。对于Collection(List、Set、Queue等),直接调用stream()方法就可以获得Stream。
上述创建Stream的方法都是把一个现有的序列变为Stream,它的元素是固定的。
基于Supplier
创建Stream还可以通过Stream.generate()方法,它需要传入一个Supplier对象:
1 | |
基于Supplier创建的Stream会不断调用Supplier.get()方法来不断产生下一个元素,这种Stream保存的不是元素,而是算法,它可以用来表示无限序列。
例如,我们编写一个能不断生成自然数的Supplier,它的代码非常简单,每次调用get()方法,就生成下一个自然数:
1 | |
上述代码我们用一个Supplier<Integer>模拟了一个无限序列(当然受int范围限制不是真的无限大)。如果用List表示,即便在int范围内,也会占用巨大的内存,而Stream几乎不占用空间,因为每个元素都是实时计算出来的,用的时候再算。
对于无限序列,如果直接调用forEach()或者count()这些最终求值操作,会进入死循环,因为永远无法计算完这个序列,所以正确的方法是先把无限序列变成有限序列,例如,用limit()方法可以截取前面若干个元素,这样就变成了一个有限序列,对这个有限序列调用forEach()或者count()操作就没有问题。
其他方法
创建Stream的第三种方法是通过一些API提供的接口,直接获得Stream。
例如,Files类的lines()方法可以把一个文件变成一个Stream,每个元素代表文件的一行内容:
1 | |
此方法对于按行遍历文本文件十分有用。
另外,正则表达式的Pattern对象有一个splitAsStream()方法,可以直接把一个长字符串分割成Stream序列而不是数组:
1 | |
基本类型
因为Java的范型不支持基本类型,所以我们无法用Stream<int>这样的类型,会发生编译错误。为了保存int,只能使用Stream<Integer>,但这样会产生频繁的装箱、拆箱操作。为了提高效率,Java标准库提供了IntStream、LongStream和DoubleStream这三种使用基本类型的Stream,它们的使用方法和范型Stream没有大的区别,设计这三个Stream的目的是提高运行效率:
1 | |
使用map
Stream.map()是Stream最常用的一个转换方法,它把一个Stream转换为另一个Stream。所谓map操作,就是把一种操作运算,映射到一个序列的每一个元素上。
例如,对x计算它的平方,可以使用函数f(x) = x * x。我们把这个函数映射到一个序列1,2,3,4,5上,就得到了另一个序列1,4,9,16,25。
可见,map操作把一个Stream的每个元素一一对应到应用了目标函数的结果上。
1 | |
如果我们查看Stream的源码,会发现map()方法接收的对象是Function接口对象。Function接口定义了一个apply()方法,负责把一个T类型转换为R类型。
1 | |
其中,Function的定义是:
1 | |
利用map(),不但能完成数学计算,对于字符串操作,以及任何Java对象都是非常有用的。例如:
1 | |
通过若干步map转换,可以写出逻辑简单、清晰的代码。
使用filter
Stream.filter()是Stream的另一个常用转换方法。所谓fliter操作,就是对一个Stream的所有元素一一测试,不满足条件的就被过滤掉了,剩下满足条件的元素就构成了一个新的Stream。
例如,我们对1,2,3,4,5这个Stream调用filter(),传入的测试函数f(x) = x % 2 != 0用来判断元素是否是奇数,这样就过滤掉偶数,只剩下奇数,因此我们得到了另一个序列1,3,5。
使用IntStream写出上述逻辑,代码如下:
1 | |
filter()方法接收的对象是Predicate接口对象,它定义了一个test()方法,负责判断元素是否符合条件。
1 | |
filter()除了常用于数值外,也可应用于任何Java对象。例如,从一组给定的LocalDate中过滤掉工作日,以便得到休息日。
使用reduce
map()和filter()都是Stream的转换方法,而Stream.reduce()则是Stream的一个聚合方法,他可以把一个Stream的所有元素按照聚合函数聚合成一个结果。
reduce()方法传入的对象是BinaryOperator接口,它定义了一个apply()方法,负责把上次累加的结果和本次的元素进行运算,并返回累加的结果。
1 | |
我们来看一个简单的聚合方法:
1 | |
上述代码看上去不好理解,我们用for循环改写一下:
1 | |
可见,reduce()操作首先初始化结果为指定值(这里是0),紧接着,reduce()对每个元素调用(acc, n) -> acc + n,其中,acc是上次计算的结果。
因此,实际上这个reduce()操作是求和。
如果去掉初始值,我们会得到一个Optional<Integer>。
1 | |
这是因为Stream的元素有可能是0个,这样就没法调用reduce()聚合函数了,因此返回Optional对象,需要进一步判断结果是否存在。
利用reduce(),我们可以把求和改成求积,代码也十分简单。注意,计算求积时,初始值设为1。
1 | |
除了可以对数值进行累积计算外,灵活运用reduce()也可以对Java对象进行操作。下面的代码演示了如何将配置文件的每一行配置通过map()和reduce()聚合成一个Map<String, String>。
1 | |
小结
reduce()方法将一个Stream的每个元素依次做用于BinaryOperator,并将结果合并。
reduce()是聚合方法,聚合方法会立刻对Stream进行计算。
输出集合
我们介绍了Stream的几个常见操作:map(),filter(),reduce()。这些操作对Stream来说可以分为两类,一类是转换操作,即把一个Stream转换为另一个Stream,例如map()和reduce();另一类是聚合操作,即对Stream的每个元素进行计算,得到一个确定的结果,例如reduce()。
区分这两种操作是十分重要的,因为对于Stream来说,对其进行转换操作并不会触发任何计算。因为转换操作只是保存了转换规则,无论我们对一个Stream转换多少次,都不会有实际计算发生。
而聚合操作则不一样,聚合操作会立即促使Stream输出它的每一个元素,并依次纳入计算,以获得最终结果。可见,聚合操作是真正需要从Stream请求数据的,对一个Stream做聚合计算后,结果就不再是一个Stream了,而是一个Java对象。
输出为List
把Stream的每个元素收集到List的方法是调用collect()并传入Collectors.toList()对象,它实际上是一个Collector实例,通过类似reduce()的操作,把每个元素添加到一个收集器中(实际上是ArrayList)。
类似的,collect(Collectors.toSet())可以把Stream的每个元素收集到Set中。
1 | |
输出为数组
和把Stream的元素输出为数组类似输出为List,我们只需要调用toArray()方法,并传入数组的“构造方法”。
1 | |
注意到传入的构造方法是String[]::new,它的签名实际上是IntFunction<String[]>定义的String[] apply(int),即传入int参数,获得Stringp[]数组的返回值。
输出为Map
如果我们要把Stream的元素收集到Map中,稍微麻烦一点。因为对于每个元素,添加Map时需要key和value,因此,我们要指定两个映射函数,分别把元素映射为key和value。
1 | |
分组输出
Stream还有一个强大的分组功能,可以按组输出。
1 | |
分组输出使用Collectors.groupingBy(),它需要提供两个函数:一个是分组的key,这里使用s -> s.substring(0, 1),表示只要首字母相同的String分到一组,第二个是分组的value,这里直接使用Collectors.toList(),表示输出为List,上述代码运行结果如下:
1 | |
可见,结果一共有3组,按"A","B","C"分组,每一组都是一个List。
假设有这样一个Student类,包含学生姓名、班级和成绩:
1 | |
如果我们有一个Stream<Student>,利用分组输出,可以非常简单地按年级或班级把Student归类。
其他操作
除了前面介绍的转换操作和聚合操作,Stream还提供了一系列非常有用的方法。
排序
对Stream的元素进行排序非常简单,只需要调用sorted()方法。
1 | |
此方法要求Stream的元素必须实现Comparable接口,如果要自定义排序,传入指定的Comparator即可。
1 | |
注意sorted()只是一个转换操作,它会返回一个新的Stream。
去重
对一个Stream的元素进行去重,没必要先转换为Set,可以直接用distinct()。
1 | |
截取
截取操作常用于把一个无限Stream转换成有限Stream,skip()用于跳过当前Stream的前N个元素,limit()用于截取当前Stream最多前N个元素。
1 | |
截取操作也是一个转换操作,将返回新的Stream。
合并
将两个Stream合并为一个Stream可以使用Stream的静态方法concat()。
1 | |
flatMap
如果Stream的元素是集合:
1 | |
而我们希望把上述Stream转换为Stream<Integer>,就可以使用flatMap():
1 | |
所谓flatMap()是指把Stream的每个元素(这里是List)映射为Stream,然后合并为一个新的Stream。
并行
通常情况下,对Stream的元素进行处理是单线程的,即一个元素一个元素处理。但是,我们希望可以并行处理Stream的元素,因为在元素数量非常大的情况,并行处理可以大大加快处理速度。
把一个普通Stream转换为可以并行处理的Stream非常简单,只需要用parallel()进行转换。
1 | |
经过parallel()转换后的Stream只要可能,就会对后续操作并行处理,我们不需要编写任何多线程的代码就可以得到并行处理带来的效率提升。
其他聚合方法
除了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提供的常用操作有:
转换操作:map(),filter(),sorted(),distinct();
合并操作:concat(),flatMap();
并行处理:parallel();
聚合操作:reduce(),collect(),count(),max(),min(),sum(),average();
其他操作:allMatch(), anyMatch(), forEach()。