内容来自《Java 8函数式编程》
1. Lambda表达式
1.1 匿名内部类与Lambda表达式
1 |
|
匿名内部类:
实现了 ActionListener 接口。这个接口只有一个方法
actionPerformed
。匿名内部类实现了该方法。但是匿名内部类不够简便,样板代码,可读性很差,我们不想传入对象,只想传入行为。
Lambda 表达式:
上述代码也可以改写为 Lambda 表达式
event 是参数名,和上面匿名内部类示例中的是同一个参数。-> 将参数和 Lambda 表达式的主体分开,而主体是用户点击按钮时会运行的一些代码
Lambda 表达式中无需指定类型,程序依然可以编译。这是因为 javac 根据程序的上下文(
addActionListener
方法的签名)在后台推断出了参数 event 的类型。这意味着如果参数类型不言而明,则无需显式指定。
为了增加可读性并迁就我们的习惯,声明参数时也可以包括类型信息,而且有时编译器不一定能根据上下文推断出参数的类型!
1.2 几种Lambda表达式的变体
1 |
|
- 使用空括号 () 表示没有参数,实现了 Runnable 接口,该接口也只有一个 run 方法,返回类型为 void
- 只包含一个参数,可省略参数的括号
- 主体可以是一段代码块,可以用返回或抛出异常来退出,只有一行代码的 Lambda 表达式也可使用大括号
- Lambda 表达式也可以表示包含多个参数的方法,这行代码创建了一个函数,用来计算两个数字相加的结果。变量 add 的类型是
BinaryOperator
,它不是两个数字的和,而是将两个数字相加的那行代码 - 可以显式声明参数类型,需要使用小括号将参数括起来
目标类型是指 Lambda 表达式所在上下文环境的类型。比如,将 Lambda 表达式赋值给一个局部变量,或传递给一个方法作为参数,局部变量或方法参数的类型就是 Lambda 表达式的目标类型。
1.3 引用值,而不是变量
在使用匿名内部类时,当你需要引用它所在方法里的变量,需要将变量声明为 final
Java 8 虽然放松了这一限制,可以引用非 final 变量,但是该变量在既成事实上必须是 final。如果坚持用作非终态变量,编译器就会报错。
- 既成事实上的 final 是指只能给该变量赋值一次。
- 换句话说,Lambda 表达式引用的是值,而不是变量。
1 |
|
如果你试图给该变量多次赋值,然后在 Lambda 表达式中引用它,编译器就会报错:
local variables referenced from a Lambda expression must be final or effectively final
1
2
3
String name = getUserName();
name = formatUserName(name);
button.addActionListener(event -> System.out.println("hi " + name));
1.4 函数接口
函数接口是只有一个抽象方法的接口,用作 Lambda 表达式的类型
例如 Swing 中的 ActionListener
, 只有一个抽象方法:actionPerformed
,被用来表示行为。该接口也继承自一个不具有任何方法的父接口:EventListener
。
1 |
|
接口中单一方法的命名并不重要,只要方法签名和 Lambda 表达式的类型匹配即可。
- 这里的函数接口接受一个
ActionEvent
类型的参数,返回空(void)
JDK 提供了一组核心函数接口会频繁出现,以下罗列一部分
接口 | 参数 | 返回类型 | 示例 |
---|---|---|---|
Predicate<T> |
T | boolean | 这张唱片已经发行了吗 |
Consumer<T> |
T | void | 输出一个值 |
Function<T, R> |
T | R | 获得 Artist 对象的名字 |
Supplier<T> |
None | T | 工厂方法 |
UnaryOperator<T> |
T | T | 逻辑非(!) |
BinaryOperator<T> |
(T, T) | T | 求两个数的乘积(*) |
1.5 类型推断
Lambda 表达式中的类型推断,实际上是 Java 7 就引入的目标类型推断的扩展,例如 Java 7 中的菱形操作符
1 |
|
如果将构造函数直接传递给一个方法,也可根据方法签名来推断类型
1 |
|
Java 8 更进一步,可省略 Lambda 表达式中的所有参数类型, javac 根据 Lambda 表达式上下文信息就能推断出参数的正确类型。
程序依然要经过类型检查来保证运行的安全性,但不用再显式声明类型罢了。这就是所谓的类型推断。
一些示例
使用 Lambda 表达式检测一个 Integer 是否大于 5。这实际上是一个 Predicate (用来判断真假的函数接口)
1 |
|
- Predicate 只有一个泛型类型的参数,Integer 用于其中。
- Lambda 表达式实现了 Predicate 接口,因此它的单一参数被推断为 Integer 类型。
- javac 还可检查 Lambda 表达式的返回值是不是 boolean,这正是 Predicate 方法的返回类型
Predicate 接口的源码,接受一个对象,返回一个布尔值
1
2
3
public interface Predicate<T> {
boolean test(T t);
}
BinaryOperator:该接口接受两个参数,返回一个值,参数和值的类型均相同。实例中所用的类型是 Long
1 |
|
没有泛型,代码则通不过编译
BinaryOperator add = (x, y) -> x + y;
编译器给出的报错信息如下:
Operator '& #x002B;' cannot be applied to java.lang.Object, java.lang.Object.
上面的例子中并没有给出变量 add 的任何泛型信息,给出的正是原始类型的定义。因此,编译器认为参数和返回值都是 java.lang.Object 实例。
1.6 练习
练习答案可在 GitHub 上本书所对应的代码仓库中找到
Java 有一个 ThreadLocal 类,作为容器保存了当前线程里局部变量的值。Java 8 为该类新加了一个工厂方法,接受一个 Lambda 表达式,并产生一个新的 ThreadLocal 对象,而不用使用继承,语法上更加简洁
public final static ThreadLocal<DateFormat> formatter = withInitial(() -> new SimpleDateFormat("dd-MMM-yyyy"));
以如下方式重载 check 方法后,还能正确推断出 check(x -> x > 5) 的类型吗?
1 |
|
- 不能,只能二选一,或者更改 Predicate 泛型类型,并且声明 x 的类型
1 |
|
2. 流
Java 8 对核心类库的改进主要包括集合类的 API 和新引入的流(Stream)。流使程序员得以站在更高的抽象层次上对集合进行操作。
2.1 从外部迭代到内部迭代
外部迭代
在使用集合类时,一个通用的模式是在集合上进行迭代,然后处理返回的每一个元素,一个常用的方式是使用 for 循环,但样板代码模糊了代码的本意,无法流畅传达意图
就原理来看,for 循环其实是一个封装了迭代的语法糖:
- 首先调用
iterator()
方法,产生一个新的 Iterator 对象,进而控制整个迭代过程,这就是外部迭代。 - 迭代过程通过显式调用 Iterator 对象的
hasNext()
和next()
方法完成迭代 - 然而,外部迭代也有问题,首先,它很难抽象出后面提及的不同操作;此外,它从本质上来讲是一种串行化操作。总体来看,使用 for 循环会将行为和方法混为一谈。
1 |
|
内部迭代
另一种方法就是内部迭代。首先要注意 stream()
方法的调用,它和 iterator()
的作用一样, 返回内部迭代中的相应接口:Stream。
Stream 是用函数式编程方式在集合类上进行复杂操作的工具
1 |
|
filter
: 过滤在这里是指“只保留通过某项测试的对象”。测试由一个函数完成,该函数返回 true 或者 false。- 由于 Stream API 的函数式编程风格,我们并没有改变集合的内容,而是描述出 Stream 里的内容。
count()
: 计算给定 Stream 里包含多少个对象。
2.2 实现机制
即使代码被分解为两步操作,但实际上只对列表迭代了一次
通常,在 Java 中调用一个方法,计算机会随即执行操作。但 Stream 里的一些方法却略有不同,它们虽是普通的 Java 方法,但返回的 Stream 对象却不是一个新集合,而是创建新集合的配方。
- 对于像
filter
这种只描述 Stream,最终不产生新集合的方法叫作惰性求值方法- 返回值是 Stream
- 像
count
这样最终会从 Stream 产生值的方法叫作及早求值方法- 返回值是另一个值或为空
1 |
|
这段代码并未做什么实际性的工作,filter 只刻画出了 Stream,但没有产生新的集合。
- 由于使用了惰性求值,没有输出艺术家的名字
- 加入一个拥有终止操作的流,如
count()
,艺术家的名字就会被输出
使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果
整个过程和建造者模式有共通之处。建造者模式使用一系列操作设置属性和配置,最后调用一个 build 方法,这时,对象才被真正创建。
2.3 常用的流操作
collect(toList())
1 |
|
of
方法使用一组初始值生成新的 Streamcollect(toList())
方法由 Stream 里的值生成一个列表,是一个及早求值操作
这个例子也展示了本节中所有示例代码的通用格式。首先由列表生成一个 Stream,然后进行一些 Stream 上的操作,继而是 collect 操作,由 Stream 生成列表,最后使用断言判断结果是否和预期一致
map
1 |
|
- 如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以使用该函数,将一个流中的值转换成一个新的流
- 参数和返回值不必属于同一种类型
- 但是 Lambda 表达式必须是 Function 接口的一个实例,Function 接口是只包含一个参数的普通函数接口
filter
1 |
|
- filter 接受一个函数作为参数,该函数用 Lambda 表达式表示
- 经过过滤,Stream 中符合条件的,即 Lambda 表达式值为 true 的元素被保留下来
- 该 Lambda 表达式的函数接口正是前面章节中介绍过的 Predicate
flatMap
假设有一个包含多个列表的流,现在希望得到所有数字的序列
1 |
|
- flatMap 方法可用 Stream 替换值,然后将多个 Stream 连接成一个 Stream
- 调用 stream 方法,将每个列表转换成 Stream 对象,其余部分由 flatMap 方法处理。
- flatMap 方法的相关函数接口为 Function 接口,只是方法的返回值限定为 Stream 类型
max和min
1 |
|
为了让 Stream 对象按照曲目长度进行排序,需要传给它一个 Comparator 对象。Java 8 提 供了一个新的静态方法 comparing,使用它可以方便地实现一个比较器
- comparing 方法接受一个函数并返回另一个函数
此外,还可以调用空 Stream 的 max 方法,返回 Optional 对象
- Optional 对象代表一个可能存在也可能不存在的值。如果 Stream 为空,那么该值不存在,如果不为空,则该值存在
- 通过调用 get 方法可以取出 Optional 对象中的值
max 和 min 方法都属于更通用的一种编程模式: reduce 模式
1
2
3
4
Object accumulator = initialValue;
for(Object element : collection) {
accumulator = combine(accumulator, element);
}
reduce
reduce 操作可以实现从一组值中生成一个值。在上述例子中用到的 count、min 和 max 方 法,因为常用而被纳入标准库中。事实上,这些方法都是 reduce 操作。
1 |
|
- 以 0 作为起点: 一个空 Stream 的求和结果,每一步都将 Stream 中的元素累加至 accumulator,遍历至 Stream 中的最后一个元素时,accumulator 的值就是所有元素的和。
- Lambda 表达式就是 reducer,它执行求和操作
- 有两个参数:传入 Stream 中的当前元素和 acc,acc 是累加器,保存着当前的累加结果。
- 返回值是最新的 acc
- reducer 的类型是 BinaryOperator
也可以将 reduce 操作展开
1 |
|
reduce
方法还有一种形式,它接受三个参数:初始值identity
、累加器accumulator
和组合器combiner
。这种形式的reduce
方法用于并行处理流时,可以在多个部分上并行累积结果,然后再将这些部分的结果合并为一个最终结果。
2.4 高阶函数
高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数
- 可以通过函数签名辨认:函数的参数列表里包含函数接口或者该函数返回一个函数接口
map 是一个高阶函数,因为它的 mapper 参数是一个函数。事实上,本章介绍的 Stream 接口中几乎所有的函数都是高阶函数。
之前的排序例子中还用到了 comparing 函数,它接受一个函数作为参数,获取相应的值,同时返回一个 Comparator。Comparator 可能会被误认为是一个对象,但它有且只有一个抽象方法,所以实际上是一个函数接口
2.5 正确使用Lambda表达式
本章介绍的概念能够帮助用户写出更简单的代码,因为这些概念描述了数据上的操作,明确了要达成什么转化,而不是说明如何转化。这种方式写出的代码,潜在的缺陷更少,更直接地表达了程序员的意图。
明确要达成什么转化,而不是说明如何转化的另外一层含义在于写出的函数没有副作用。这一点非常重要,这样只通过函数的返回值就能充分理解函数的全部作用
没有副作用的函数不会改变程序或外界的状态
- 向控制台输出信息、给变量赋值都是副作用
鼓励用户使用 Lambda 表达式获取值而不是变量。获取值使用户更容易写出没有副作用的代码。
无论何时,将 Lambda 表达式传给 Stream 上的高阶函数,都应该尽量避免副作用。唯一的例外是 forEach 方法,它是一个终结方法。
2.6 要点回顾
- 内部迭代将更多控制权交给了集合类。
- 和 Iterator 类似,Stream 是一种内部迭代方式。
- 将 Lambda 表达式和 Stream 上的方法结合起来,可以完成很多常见的集合操作。
2.7 练习
- 编写一个函数,接受艺术家列表作为参数,返回一个字符串列表,其中包含艺术家的姓名和国籍;
1 |
|
- 在一个字符串列表中,找出包含最多小写字母的字符串。对于空列表,返回
Optional<String>
对象
1 |
|
- 只用 reduce 和 Lambda 表达式写出实现 Stream 上的 map 操作的代码,如果不想返回 Stream,可以返回一个 List。
1 |
|
- 只用 reduce 和 Lambda 表达式写出实现 Stream 上的 filter 操作的代码,如果不想返回 Stream,可以返回一个 List。
1 |
|
3. 类库
3.1 在代码中使用Lambda表达式
在 slf4j 和 log4j 等几种常用的日志系统中,有一些记录日志的方法,当日志级别不低于某个固定级别时就会开始记录日志。
- 例如
void debug(String message)
,当级别为 debug 时,就开始记录日志消息
但频繁计算消息是否应该记录日志会对系统性能产生影响:可以通过 if 语句预先判断
if (logger.isDebugEnabled()) {...}
但使用 Lambda 表达式可以进一步简化日志代码
1 |
|
3.2 基本类型
装箱类型是对象,在内存中存在额外开销。
- 比如整型在内存中占用 4 字节,整型对象却要占用 16 字节,这一情况在数组上更加严重。
- 将基本类型转换为装箱类型,称为装箱,反之则称为拆箱,两者都需要额外的计算开销。
为了减小这些性能开销,Stream 类的某些方法对基本类型和装箱类型做了区分,在 Java 8 中,仅对整型、长整型和双浮点型做了特殊处理
对基本类型做特殊处理的方法在命名上有明确的规范
- 如果方法返回类型为基本类型,则在基本类型前加 To,如
ToLongFunction
- 如果参数是基本类型,则不加前缀只需类型名即可,如
LongFunction
- 如果高阶函数使用基本类型,则在操作后加后缀 To 再加基本类型,如
mapToLong
这些基本类型都有与之对应的 Stream,以基本类型名为前缀,如 LongStream
事实上,
mapToLong
方法返回的不是一个一般的 Stream,而是一个特殊处理的 Stream。在这个特殊的 Stream 中,map 方法的实现方式也不同,它接受一个
LongUnaryOperator
函数,将一个长整型值映射成另一个长整型值。通过一些高阶函数装箱方法,如mapToObj
,也可以从一个基本类型的 Stream 得到一个装箱后的 Stream,如Stream<Long>
。
1 |
|
3.3 重载解析
Lambda 表达式作为参数时,其类型由它的目标类型推导得出,推导过程遵循如下规则:
- 如果只有一个可能的目标类型,由相应函数接口里的参数类型推导得出
- 如果有多个可能的目标类型,由最具体的类型推导得出
- 如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型
- 可以对 Lambda 表达式进行强转
3.4 @FunctionalInterface
Java 中有一些接口,虽然只含一个方法,但并不是为了使用 Lambda 表达式来实现的。有些对象内部可能保存着某种状态,使用带有一个方法的接口纯属巧合。例如 java.lang.Comparable 和 java.io.Closeable。
和Closeable
和Comparable
接口不同,为了提高 Stream 对象可操作性而引入的各种新接口,都需要有 Lambda 表达式可以实现它。它们存在的意义在于将代码块作为数据打包起来。因此,它们都添加了@FunctionalInterface
注解。
该注释会强制 javac 检查一个接口是否符合函数接口的标准。如果该注释添加给一个枚举类型、类或另一个注释,或者接口包含不止一个抽象方法,javac 就会报错。重构代码时,使用它能很容易发现问题。