Java Lambda In Action
封面来源:由博主个人绘制,如需使用请联系博主。
本文涉及的代码:java-new-feature/lambda-in-action
本文基于 JDK21
1. 背景与说明
2014 年 3 月,Java 迎来了其极具历史意义的版本 —— JDK 1.8,该版本带来的 Lambda 表达式、Stream API 等诸多新特性也引领 Java 迈入新世代。
为了进一步推动 Java 的发展,2017 年 9 月,Java 平台的首席架构师 Mark Reinhold (马克·莱茵霍尔德)提议将 Java 的功能更新周期从之前的每两年一个版本缩减到每六个月一个版本,至此,Java 开始了「腹泻式」更新。
2025 年 9 月,Java 又将发布新的 LTS 版本 —— JDK 25,带来「满血版」的虚拟线程。从当前时间节点来看,十多年前 JDK 1.8 带来的特性应该是必知必会的技能,但如今在 Java 社区中仍有无数人争论 Lambda 表达式的使用。
Lambda 表达式有什么用?应该怎么用?
用下 Stream API、来个 Optional 就算 Lambda 表达式的应用?就算函数式编程?
本文将讲解不一样的 Lambda 表达式用法,希望能给你带来一些启发。
需要注意的是,本文偏向 Java Lambda 表达式的单一实战,因此不会涉及以下内容:
- 函数式接口和 Lambda 表达式的定义、Stream 与 Optional 的使用等初级内容
- Lambda 表达式的原理、invokedynamic 指令等高级内容
- Lambda 表达式的调试技巧、性能考量与可读性平衡等注意事项
2. 得窥门径
2.1 延迟执行
现在有一个打印日志的需求,要求在满足日志等级时,输出给定的 message
信息。
根据需求可以有:
1 | public void log(int level, String message) { |
但这种实现存在性能隐患,如果 message
的组装极其耗时,但 level
却不是日志等级 1
,此时依旧会在 log()
方法执行前完成 message
的组装。
1 |
|
更好的方式应该是:
- 满足
level == 1
时,才执行message
的组装
也就是说,将 message
的组装 延迟 到 level
的判定之后。
将 log
方法接收的 String
类型参数修改为 Supplier<String>
,在 level
判定后,才执行其 get()
方法,完成日志信息的组装:
1 | public void log(int level, Supplier<String> log) { |
调用方式则改为:
1 |
|
2.2 闭包与状态保持
Lambda 表达式使用外部定义的变量(局部变量、成员变量、静态变量),函数对象和它外界的变量绑定在一起,就形成了闭包。
使用的局部变量(成员变量、静态变量没这个限制)必须是 final
或者 effective final
。
1 | int NUMBER_INT = 2; |
如果不得不修改局部变量的引用,可以通过数组或对象来包装这个变量:
1 |
|
实际开发中,为了更好的可读性与线程安全性,通常会选择 AtomicInteger
、AtomicReference
等原子类来实现。
2.3 高阶函数
所谓高阶,就是指它是其他函数对象的使用者。
高阶函数是指那些可以接受函数作为参数,或者返回函数作为结果的函数。
先以 JavaScript 为例,一个简单的高阶函数形式如下:
1 | function add(x, y, f) { |
add()
是一个高阶函数,它接收三个参数,其中的 f
作为另一个函数传入 add()
函数中,而 x
和 y
则作为调用函数 f
使用的参数。
1 | let x = add(-5, 6, Math.abs); |
如果要在 Java 里实现这样的功能,就得复杂一些,究其本质是 Java 并不像 JavaScript 那样能够将函数视为一等公民,Java 只能使用函数式接口来实现:
1 | public int add(int a, int b, IntUnaryOperator f) { |
使用高阶函数,能够:
-
将通用、复杂的逻辑隐含在高阶函数内
-
将易变、未定的逻辑放在外部的函数对象中
2.4 复合 Lambda 表达式
内置的函数式接口中提供了许多为方便而设计的默认方法,这意味着可以将多个简单的 Lambda 表达式复合成一个复杂的表达式。比如:
- 可以让两个谓词(
Predicate
)之间做and
操作,进而组成一个更大的谓词 - 还可以让一个函数(
Function
)的结果作为另一个函数的输入
谓词复合
Predicate
接口中有三个常用的方法:
negate
:非and
:与or
:或
1 | enum Color { |
函数复合
Function
接口中有两个常用的方法,它们都能接收一个 Function
,并且能再返回一个 Function
。
假设当前 Function
名为 f
,调用那两个方法传入的 Function
名为 g
。
其中一个方法名为 andThen
,对输入应用当前函数 f
,将返回结果作为输入应用函数 g
,类似 :
另一个方法名为 compose
,相比于 andThen
是反过来的,先对输入应用函数 g
,之后将返回结果作为输入引用当前函数 f
,类似 :
1 |
|
流水线模式
复合 Lambda 表达式的典型应用就是 —— 流水线模式,也被成为管道模式。
流水线模式是一条装配线,其中部分结果从一个阶段传递到另一个阶段。
当前一次的输出能够作为下一次输入时,并且存在多个这样的顺序操作,就可以使用流水线模式。
是的,这和 函数复合 很类似。
创建 Pipeline
类,表示一条流水线,内部提供添加任务和执行任务的方式:
1 | public class Pipeline<I, O> { |
现在来尝试构造一条流水线并执行其中的任务:
1 |
|
2.5 环绕执行模式
现在有一个需求,从 data.txt
文件中读取一行,那么可以写出以下代码:
1 | public String readOneLine() throws IOException { |
之后又额外增加一个需求,需要从 data.txt
文件中读取两行:
1 | public String readTwoLine() throws IOException { |
上述两个方法都涉及到资源处理,简单来说分为三步:
- 打开一个资源
- 做一些处理
- 关闭资源
其中第一步和第三步在大多数实现中总是很类型,并且会围绕着执行处理的那些核心代码,这就是所谓的 环绕执行 模式(execute around)。
需求不是在变化,就是在变化的路上。
现在需要返回使用最频繁的词,又该怎么办呢?
再来一个方法?
为了应对多变的需求,针对可能面对的不同行为,应该把 processFile
的行为 参数化,而传递行为正好是 Lambda 表达式的拿手好戏。
先想想应该怎么做?
首先肯定得拿到 BufferedReader
对象,而这个对象是在方法内部得到的,之后需要返回文本内容,也就是返回 String
。
针对以上信息,可以很清楚地知道需要 Function
函数式接口作为 processFile
的参数,因为 Function
刚好是接收一个参数并返回一种数据。
1 |
|
尝试使用一下:
1 |
|
readLine()
方法抛出了一个受检异常,而内置的函数式接口都不允许抛出受检异常,为了处理受检异常,只能显式使用 try/catch
块。
当然也可以自定义一个允许抛出受检异常的函数式接口:
1 |
|
1 |
|
2.6 包装受检异常
在前一节中:
- 使用内置的函数式接口需要手动处理异常
- 而自定义函数式接口的局限性又很大,接收的参数类型、返回类型以及受检异常类型只适用于当前场景
有没有更通用的做法?
观察先前调用 processFileWithLambda1()
的做法:
1 | processFileWithLambda1(br -> { |
其中 br.readLine() + br.readLine()
依旧是一种行为,而 try/catch
块则是样板代码,只要行为中抛出了受检异常,都应该按照以下方式处理:
1 | try { |
换句话说,可以再进一步地将调用 processFileWithLambda1()
方法的方式进行参数化,这个参数同样需要接收一个对象,也能够返回,还会抛出受检异常。
简单来说就是拓展 Function
函数式接口,在其基础上抛出受检异常:
1 |
|
processFileWithLambda1()
接收的是 Function
类型,而这里是 ThrowingFunction
,为了能沿用 processFileWithLambda1()
,就需要将 ThrowingFunction
转换为 Function
:
1 | public static <T, R> Function<T, R> wrap(ThrowingFunction<T, R, Exception> function) { |
之后可以使用以下方式调用 processFileWithLambda1()
:
1 |
|
进一步优化
为了更好的讲解,简化上述示例为「直接从 data.txt
文件中读取内容」。
因此可以有如下代码:
1 | try { |
由于 readString()
方法抛出受检异常 IOException
,因此在使用时需要用 try/catch
包裹。
根据前文的介绍,可以自定义 ThrowingSupplier
接口,编写 wrapSupplier()
方法,将受检异常包装为非受检异常:
1 |
|
之后可以这样调用:
1 | String str = wrapSupplier(() -> Files.readString(Path.of("data.txt"))).get(); |
这种包装方式仍有一点不足:当 data.txt
文件不存在时,将抛出 NoSuchFileException
异常,该异常会被进一步包装成 RuntimeException
,最终日志文件会有类似如下内容输出:
java.lang.RuntimeException: java.nio.file.NoSuchFileException: data.txt at xxxx at xxxx Caused by: java.nio.file.NoSuchFileException: data.txt at xxxx at xxxx
可以看到 NoSuchFileException
确实被 RuntimeException
包装,但这样的信息并不够清晰,可能会对其他人的排查带来困惑,如果能够直接显示 NoSuchFileException
,而没有额外的包装就好了。
参考 Lombok 中 @SneakyThrows
注解的实现,编写 sneakyThrow()
方法:
1 |
|
之后在包装受检异常时不再抛出 RuntimeException
,而是直接返回 sneakyThrow()
:
1 | public static <T> Supplier<T> sneakySupplier(ThrowingSupplier<T> supplier) { |
最终的调用方式并没有差异:
1 | String str = sneakySupplier(() -> Files.readString(Path.of("data.txt"))).get() |
但同样在 data.txt
文件不存在时,日志文件会输出更清晰的信息:
java.nio.file.NoSuchFileException: data.txt at xxxx at xxxx
2.7 柯里化
内置的函数式接口不仅缺乏对受检异常的原生支持,其预定义的参数数量也降低了在实际开发中的灵活性。
以 Consumer
系列的接口为例,JDK 只提供了最多两个参数的接口:
1 |
|
如果需要三个参数,就不得不自定义一个:
1 |
|
1 |
|
那又需要四个参数呢?继续自定义?
那五个、六个?
又或者是需要接收更多参数的 Function
序列接口呢?
如果还是自定义,那得定义到天荒地老。
在函数式编程有一个名为 柯里化 的概念,其含义是 让接收多个参数的函数转换成一系列接收一个参数的函数。
比如需要三个参数的 Consumer
系列的接口,其中的「三」可以有多种表示,比如:
- 1 + 1 + 1
- 1 + 2
- 2 + 1
以其中的 1 + 2
为例,其含义是将三个参数进行拆分,先传入一个参数,得到一个函数 ,该函数能够接收两个参数并消费,这个 函数就对应 BiConsumer
。
其中传入参数,得到 函数的行为可以通过 Function
实现。
利用柯里化,实现接收三个参数的 Consumer
系列接口可以用以下方式表示:
1 | Function<Double, BiConsumer<Float, Integer>> fun = t -> (u, r) -> { |
1 + 1 + 1
和 2 + 1
也可以按照同样的方式实现:
1 | Function<Double, Function<Float, Consumer<Integer>>> f = t -> u -> r -> { |
2.8 类型推断
函数描述符与目标类型
函数式接口的抽象方法的签名基本上就是 Lambda 表达式的签名。将这种抽象方法叫作 函数描述符。这里使用了一个特殊的表示方式来描述 Lambda 和函数式接口的签名,比如 T -> R
表示接受一个泛型 T
的对象,并返回一个泛型 R
的对象的函数。
注意,在 Java 语言规范中,方法签名不包含返回值类型,仅包含参数名和参数类型列表。Lambda 表达式与函数式接口中抽象方法的匹配不仅包含参数类型的匹配,也包含返回值类型的匹配,为了清晰表达,这里也将返回值类型包含在方法签名中。
Lambda 表达式的类型是从使用 Lambda 表达式的上下文推断出来的。上下文中 Lambda 表达式需要的类型称为 目标类型。
Expression Statements
Expression Statements,即表达式语句,它是 Java 中程序执行的基本单元,以 ;
结尾,将 表达式 转换为可执行的语句。表达式语句的核心特点是通过表达式的副作用(side effect)来改变程序状态,而非单纯地计算一个值。
「表达式语句」一词可以拆分成「表达式」和「语句」两个词:
-
表达式:由变量、运算符、方法调用等组成的语法结构,最终会计算出一个值。比如
a + b
、x = 2
、Math.abs(-1)
、new ArrayList()
等等。 -
语句:程序执行的基本单元,用于完成某个操作(比如赋值、循环、判断)。比如
if
语句、for
循环、return
语句等。
在表达式后添加分号 ;
,就将其转换为一个独立的语句。这个表达式 必须 有「副作用」(比如修改了变量、进行了 IO 操作),否则会报错或被编译器忽略。
表达式语句的常见形式:
1 | // 赋值: 修改 a 的值 |
无副作用的表达式不能称为语句,比如:
1 | 1 + 1; |
void-compatible block 与 value-compatible block
这两个概念主要出现在以下两个场景中:
- Lambda 表达式:当 Lambda 表达式使用代码块
{}
作为主体时,需要根据目标类型的返回值类型确定块的类型。 - 语句块:在某些上下文(方法体、条件判断)中,代码块是否需要返回值。
两者的主要区别是 代码块是否产生了一个值,以及产生的值的类型是否与目标上下文的返回值类型兼容。
void-compatible block
即 void 兼容块,它表示代码块不产生任何值(没有 return
语句),或仅包含 return;
语句。
value-compatible block
即值兼容块,它表示代码块必须产生一个值,且该值的类型与目标上下文的返回值类型兼容。
既是 void-compatible block,又是 value-compatible block
当一个代码块无法正常执行完成时,它可以既是 void-compatible block,又可以是 value-compatible block。在这种情况下,代码块不会产生返回值,也不会违反任意一方的规则。
抛出异常(Uncaught Exception)的代码块可以同时满足这两种情况:
1 | Runnable runnable = () -> { |
只抛出异常时,没有返回值,符合 void-compatible block;异常会终止程序执行,编译器不再强制要求有 return
语句,此时也符合 value-compatible block。
死循环(Infinite Loop)的代码块也可以同时满足这两种情况:
1 | Runnable runnable = () -> { |
没有返回值,符合 void-compatible block;循环无法终止,后续代码不可达(Unreachable),编译器不再要求有 return
语句,符合 value-compatible block。
注意,如果在抛出异常后、死循环后还有其他代码,这些代码称为不可达代码(Unreachable Code),不可达代码会引发编译器报错,比如:
1 | Supplier<String> supplier = () -> { |
Lambda 表达式特殊的
void
兼容规则
如果一个 Lambda 表达式的主体(Lambda body)是一个 表达式语句,它就和一个返回 void
的函数描述符兼容(当然需要参数列表也兼容)。
例如,下面两行都是合法的:
1 | Predicate<String> p = (String s) -> list.add(s); |
尽管 List
的 add()
方法返回了一个 boolean
,并不是 Consumer
上下文 T -> void
所要求的 void
,但它依旧能够兼容 Consumer
,就像实例化一个 ArrayList
后,调用其 add()
方法向列表中不断添加数据,忽略 add()
方法的返回值。
注意,这里的 list.add(s);
应该被认定为是表达式语句,而不是「既是 void-compatible block,又是 value-compatible block」,因为无论 void-compatible block 还是 value-compatible block 都有一个前提,那就是必须是 block,所谓 block 得有 {}
括起来才算,这里都没有 {}
,怎么算 block 呢?
Java 语言规范
参考链接:The Java Language Specification §jls-15.12.2.1
如果满足以下所有条件,那么一个 Lambda 表达式会和函数式接口类型兼容:
- 目标类型函数类型的参数数量(arity)与 Lambda 表达式的参数数量相同
- 如果目标类型函数类型返回
void
,那么 Lambda 主体要么是一个表达式语句,要么是一个 void-compatible block - 如果目标类型函数类型有返回值(非
void
),那么 Lambda 主体要么是一个表达式,要么是一个 value-compatible block
假设一个函数式接口中抽象方法的参数数量为 n
,某一方法引用与该函数式接口的兼容规则如下:
- 方法引用的形式是
ReferenceType::[TypeArguments]
时,方法引用指向的方法满足以下某个条件:- 是静态方法,且参数数量为
n
- 不是静态方法,且参数数量为
n - 1
(隐含this
参数)
- 是静态方法,且参数数量为
- 当方法引用是其他形式时,方法引用指向的方法是非静态方法,且参数数量为
n
Lambda 表达式遇上方法重载
前面的概念铺垫完毕,现在来看一个使用案例。
有这样两个方法,它们都以一个函数式接口作为参数:
1 | public void process(Consumer<String> consumer) { |
方法名都叫 process
,仅接收一个参数,但是类型不一致,是典型的 方法重载。
来试试调用它们,比如打印一句 Lambda In Action
,就像这样:
1 | process(str -> System.out.println("Lambda In Action")); |
想法是美好的,但 IDEA 中会立即出现编译报错:
Ambiguous method call.
提示这是一次「模棱两可」的调用,也就是当前的调用会同时匹配到两个 process()
方法。
对其应用 Alt + Enter
快捷键进行快捷修复,最终优化成:
1 | process((Consumer<String>) str -> System.out.println("Lambda In Action")); |
println()
方法没有返回值,按理说应该会匹配到 Consumer
参数,怎么会产生「模棱两可」的调用呢?
这是因为 Java 语言的设计者在结合类型推断选择重载方法的过程中进行了有意的删减,导致 Lambda 表达式作为参数时并不是所有方面都会用于确定正确的重载方法。
在此引入显式类型(explicitly typed)、隐式类型(implicitly typed) Lambda 表达式的概念:
- 没有参数的 Lambda 表达式是显式类型
- 形参有声明类型的 Lambda 表达式也是显式类型
- 形参有推断类型的 Lambda 表达式是隐式类型
举个例子:
1 | // 隐式 |
在第一个使用示例中使用的 Lambda 表达式就是一个隐式类型的 Lambda 表达式,对于这样的 Lambda 表达式,在方法重载解析中不会考虑其返回值类型,也就是不会去推断其返回值究竟是个什么类型,因此按第一个示例的方式调用 process()
方法会出现「模棱两可」的错误。
如果将 Lambda 表达式声明为显式类型就不会报错了,比如:
1 | process((String str) -> System.out.println("Lambda In Action")); |
除此之外,process()
方法接收的两个函数式接口主要是返回类型的区别,一个是 void
,一个是非 void
。
根据前面已经讲过的概念:
- 如果目标类型函数类型返回
void
,那么 Lambda 主体可以是一个 void-compatible block; - 如果目标类型函数类型有返回值(非
void
),那么 Lambda 主体可以是一个 value-compatible block。
因此也可以使用以下方式来正确调用 process(Consumer<String>)
:
1 | // x -> { foo(); } |
此时将直接使用 Lambda 表达式的结构(structure),或者说形状(shape)来精准定位要调用的方法,而无需解析实际类型。
就像使用 x -> { foo(); }
来使 Lambda 表达式与 void
兼容一样,还可以使用 x -> ( foo() )
来使 Lambda 表达式与值兼容:
1 | // 正确调用 process(Function<String, String>) |
更搞不懂了?
方法重载解析过程中不会考虑返回值类型是吧,那这样呢?
1 | process(str -> "Lambda In Action"); |
为什么不会出现「模棱两可」的错误,而是精准匹配了 process(Function<String, String>)
呢?
这是因为 str -> "Lambda In Action"
的返回类型显然是 String
,它并不是调用了一个会返回 String
类型的方法,不需要去推断它的返回类型,它只具有值兼容性,不适用于 process(Consumer<String>)
。
同样,str -> {}
仅具有 void
兼容性,不适用于 process(Function<String, String>)
:
1 | process(str -> { |
不精准的方法引用表达式
前面讲到在方法重载解析中不会考虑隐式类型 Lambda 表达式的返回值类型,根据 The Java Language Specification §15.12.2.2. 中的内容,也不会考虑不精准的方法引用表达式(inexact method reference expression)的类型。
比如又有以下两个方法:
1 | private void consumerIntFunction(Consumer<int[]> consumer) { |
尝试以下面的方式调用 consumerIntFunction()
方法时也会出现「模棱两可」的错误:
1 | consumerIntFunction(Arrays::sort); |
Arrays.sort()
方法能够接收一个 int[]
类型的参数,因此在处理调用 consumerIntFunction()
方法的重载解析时会匹配上两个方法,此时 Arrays::sort
就属于不精准的方法引用表达式,在重载解析过程中不会考虑其返回类型,因此出现「模棱两可」的错误。
可以使用以下方式来正确调用 consumerIntFunction()
方法:
1 | // 明确具体类型 |
更多思考
当方法重载遇上 Lambda 表达式时,如果不熟悉 JSL 的相关规范,一个「模棱两可」的错误不仅莫名其妙地出现,而后又会使用 IDEA 的快捷键莫名奇妙地修复这个错误。
为了防止方法重载,在设计 API 时必须确保不会出现歧义,比如 Comparator API:
comparing(Function) comparingDouble(ToDoubleFunction) comparingInt(ToIntFunction) comparingLong(ToLongFunction)
而不是使用:
comparing(Function) comparing(ToDoubleFunction) comparing(ToIntFunction) comparing(ToLongFunction)
类型的情况也出现在 Stream.map()
、mapToDouble()
、mapToInt()
和 mapToLong()
等方法中。
由于难以正确处理方法重载,应该避免在可能会使用隐式类型 Lambda 表达式的地方使用方法重载。
这不禁让我想起了前几年在知乎上看到的一个问题「为什么Go,Rust等新语言都不支持函数重载?」,这其中或许就有这样的原因。
2.9 一些细节
Objects#isNull()
与Objects#nonNull()
顾名思义,这两个方法是用来判断与 null
值的等值关系,它们都是 JDK 1.8 中新增的:
1 | public static boolean isNull(Object obj) { |
在工作中会有人它们用作普通的 if
判断,比如:
1 | if (Objects.isNull(a)) { |
这虽然并没有什么坏处,但就个人观点来看,并不建议这样做,而是直接使用 ==
或 !=
的方式进行判断会具备更好的可读性。
除此之外,在这两个方法的注释上也有写到它们的存在是为了用作 Predicate
形式的方法引用:
Function#identity()
参考资料:Java 8 lambdas, Function.identity() or t->t
先前介绍高阶函数的函数复合时提到 Function
接口中有两个名为 compose()
和 andThen()
,除此之外,还有一个名为 identity()
的静态方法,它长得很「抽象」:
1 | static <T> Function<T, T> identity() { |
为什么说它「抽象」,因为它表示原样返回接收的参数。
直接书写 t -> t
也能表示相同的含义,为什么要额外多出这样一个方法呢?
恒等函数(identity function)在数学术语中很常见,identity()
方法沿用这种含义更便于理解。
不仅如此,在 Lambda 表达式的底层实现中,直接使用 Function.identity()
总是返回同一个实例,而对于 t -> t
不仅会创建自己的实例,甚至针对 t
的不同类型,还会多出一个额外的实现类。
注意,如果一个 Lambda 表达式没有捕获任何值,创建的实例总是一个单例对象,每次调用时都会重复使用这个单例对象。
所以在面对 t -> t
与 Function.identity()
的抉择时,请毫不犹豫地选择后者,除非你真的觉得 t -> t
比 Function.identity()
的可读性要强 100 倍。
3. 渐入佳境
3.1 Factory Kit
无论是工厂方法,还是抽象工厂,它们能创建出的对象范围往往是固定的。
如果:
- 工厂不知道该创建什么类型的对象
- 工厂实例也不是全局的,而是谁用谁创建
又该怎么办呢?
可以使用 「Factory Kit」。
首先定义工厂构造器,它在大方向上限制了工厂能创建出什么样的对象:
1 | public enum ProductType { |
如果需要得到一个对象,需要传入一个 ProductType
类型,而工厂能否生产出这种类型的对象,完全由当前新构造的工厂实例决定。
为了完成类型与对象的映射,还需要一个 FactoryKit
:
1 | public interface ProductFactoryKit { |
实际使用时,先构造 ProductFactoryKit
实例,并添加类型与对象的详细映射:
1 |
|
3.2 Higher-Order Function Factory
Higher-Order Function Factory,即 高阶函数工厂。
高阶函数 是能够接收若干个函数或者能够返回一个函数作为结果的函数,而 高阶函数工厂 就是能够生产这样的函数的工厂。
说到工厂,就不得不提到设计模式中的工厂模式。实际生产中,为了更方便地获取到具体的工厂,通常会将某个类型(通常来说是枚举)与某个工厂绑定,客户端通过传入指定的类型后,可以快速方便地获取到具体的工厂,之后使用该工厂构造出具体的对象。
将类型与特定对象进行绑定的方式也能应用到策略模式,与创建型模式的工厂模式相比,策略模式属于行为模式,在这时,类型不再与某个生产对象的工厂绑定,而是与某种行为绑定。
策略模式和工厂模式其实很类似,只不过:
- 工厂模式中,类型与工厂绑定,获取到工厂后,生产不同的对象(有返回值);
- 策略模式中,类型与行为绑定,获取到行为后,执行不同的行为(无返回值)。
见到「行为」两字,不由得想起 Lambda 表达式。
在工厂模式中,将生产对象的方式使用 Lambda 表达式完成;在策略模式中,将需要执行的行为也由 Lambda 表达式完成。
高阶函数工厂的运作模式可以用下图表示:
注意,同一个高阶函数工厂不能既生产创建对象用的工厂,又生产需要执行的策略。
那么高阶函数工厂中类型与函数的映射关系是什么时候建立的?
这应该在高阶函数工厂运行之前,比如将映射关系的建立放在 static
块中,而在 Spring 工程中,这通常在 Bean 的初始化阶段完成。
1 |
|
1 |
|
更多思考
使用 Map
作为容器来承载类型与函数的映射关系真的就完美了吗?
当需求发生变化,需要额外新增类型和函数,是否有可能在增加类型后,忘记在 init()
方法中增加映射关系?
这种问题 应该尽量在编译期被发现。
在 JDK21 中,switch
模式匹配成功在「转正」:
1 | public static Function<String, String> run(Type type) { |
当前 switch
语句中包含了 Type
的所有情况,当新增类型而未添加映射关系时,swicth
语句在编译阶段会发现这个错误。
3.3 Step Builder
创建的对象所用的构造器(Constructor)或静态工厂有多个参数时,可以考虑使用建造者模式;如果这些参数有部分是必选的,还有部分是可选的,那就更应该考虑建造者模式。
尽管建造者模式在构造复杂对象时表现得很出色,但它在某些方面上仍有不足,比如需要按照一定顺序填入某些字段信息时,使用建造者模式无法从 API 层面上控制这些顺序。
这种构造方式与流水线很类似,只有前一步完成后,才能进行下一步。
比如现在需要构造一个 Book
对象,需要依次传入类型、作者、书名和出版时间等信息。一般来说,一个作者会出版许多同类型、不同名称的书籍,在构造这样的 Book 对象时,如果每次都传入相同的类型和作者信息会使得代码很冗余,利用 柯里化 简化步骤:
1 | public record Book(Genre genre, String author, String title, LocalDate publicationDate) { |
1 |
|
利用柯里化实现了按步骤构造复杂对象,但它又丧失了建造者模式的能力,无法实现参数的可选,默认所有参数都是必选。
是否有办法实现「我全都要」的目标呢?
设计一种升级版的建造者模式,拥有:
- 原始建造者模式的所有功能
- 清晰的对象构建过程,构建过程中,用户只会看到下一步可用的方法,直到构建对象的正确时间才能看到最终的
build()
方法
这就是「Step Builder」,Let the API guide you。鉴于其实现与 Lambda 表达式并无关系,详细内容可以查看 【设计模式】建造者模式 一文。
3.4 Combinator Pattern
Combinator Pattern,即 组合子模式,组合子的概念在 Java 中并不多见,因为 Java 并不是一门函数式编程语言,但在 Java 8 引入 Lambda 表达式后,在 Java 里也能使用一些函数式编程的概念,也包含组合子。
组合子模式的相关概念与使用仅用这一节难以讲述清楚,详细内容见 Combinator Pattern 一文。
4. 融会贯通
4.1 实现递归
对于一个正整数 , 的阶乘 可以通过连乘积来定义:
如果拓展到 ,定义 。
以 表示非负整数 的阶乘,那么:
将 转换为代码实现则有:
1 | static class Factorial { |
那么问题来了,如果以 Lambda 表达式来实现阶乘呢?
首先明确 Lambda 表达式的目标类型,显然需要接收一个整型并返回一个整型,其目标类型显然是 Function<Integer, Integer>
。
那么该怎么定义呢?像这样?
1 | Function<Integer, Integer> func = x -> { |
想法是美好的,现实是残酷的。
这样的定义会编译报错,提示:
Cannot read value of field 'func' from inside the fields's definition
这是因为在定义 func
时,Lambda 表达式捕获了一个尚未初始化完成的 func
自身。Java 不允许在变量初始化表达式中直接引用该变量本身,就像不能定义 int x = x + 1;
一样。
这就陷入了一个矛盾的循坏:
- 要定义好
func
,得先初始化完成func
- 要初始化完成
func
,得先定义好func
递归,就是函数内部又调用了自己。使用 Lambda 表达式实现递归时,Lambda 表达式内部无法捕获到当前 Lambda 表达式,因为它还没初始化完成。
换句话说,在定义 Lambda 表达式时,其内部无法获取到自身的状态。
关键词,状态。
要想捕获某种状态,可以使用 闭包。
将需要定义的 Lambda 表达式包装成一个对象,把定义 Lambda 表达式的操作转换成对象成员变量的赋值,内部不再直接引用 Lambda 表达式本身,而是引用对象的成员变量。
1 | static class Fn { |
如果觉得就为了这事儿单独新建一个类太奢侈,也可以像前文介绍闭包那样使用一个数组实现状态保持:
1 |
|
在 Java 中,Lambda 表达式本身通常是 无状态 的(stateless),它们的行为应仅依赖于输入参数,而不依赖于外部可变状态。
使用闭包,Lambda 表达式可以表现出 状态保持(stateful)的行为。
函数式编程推荐使用纯函数(pure function),尽量编写无状态的 Lambda 表达式,确保代码可预测且线程安全。
那上述递归实现还能再优化吗?
使用 Lambda 表达式实现递归的唯一难题是无法在定义过程中获取到自身,既然如此,为什么不能将自身作为 Lambda 表达式的参数呢?
1 | static class RecursionWithHigherOrderFunction { |
1 | int value = FACTORIAL.apply(FACTORIAL, 5); |
4.2 延迟计算
获取自然数 范围内的所有质数
对于大于 的自然数 ,除了 和它本身以外不再有其他因数,那么 是一个质数。
与质数对应的是合数,对于大于 的自然数 ,除了 和它本身以外还有其他因数,则 是一个合数。
若一个数 是合数,它必然可以分解为两个因子 和 ,满足 ,那么 和 之间的最大可能值是 。
比如 ,其因子对有 、 、 和 ,当检查到因子 时,后续的因子其实(如 )在先前已经被检查。
基于以上性质,如果需要求自然数 范围内的所有质数,可以利用 Stream
API 写出以下代码:
1 | public final class MathUtils { |
这种实现有些笨拙:需要枚举 范围内的所有自然数 ,判断其是否能被候选数 () 整数。
上述求取质数的方式属于暴力求解,在此之外,还可以使用「埃拉托斯特尼筛法[1]」求取一定自然数范围内的所有质数。
所有的合数都能分解为质数的乘积。
「埃氏筛」的核心原理就是通过逐步排除已知质数的倍数,最终剩下的数就是质数。
根据上述原理,再次尝试使用 Stream
API 来实现。
第一步: 构造从 2 开始的自然数组成的整型流
1 | public static IntStream numbers() { |
构造出的整数流是一个无限流,实际使用时需要使用调用 limit()
方法限制生成的自然数个数。
第二步: 获取当前流中的第一个质数
1 | static int head(IntStream numbers) { |
第三步: 跳过当前流的第一个元素,也就是跳过第一个质数,便于后续继续过滤
1 | static IntStream tail(IntStream numbers) { |
第四步: 递归地创建由质数构成的流
1 | public static IntStream primesErr(IntStream numbers) { |
一起看看它的处理逻辑:
- 先获取流中的第一个元素,这个元素也是被确定为质数的整数
- 使用
concat()
方法合并两个流,其中一个是已经确定为质数的 ,另外一个是递归调用primesErr()
方法形成的流 - 递归调用
primesErr()
时,对质数 后续的整数进行判断,移除所有能整除 的数,保留下来的第一个数又是一个新的质数
聪明的你已经发现这个方法名以 Err
结尾,是的,primesErr()
方法的实现是错误的。
首先一开始调用的 head()
方法内部使用了 findFirst()
方法,该方法是一个终端操作,也就是会消费当前的流。众所周知,流只能被消费一次。后续调用 tail()
再次操作流时,会因为操作的流已被消费而抛出 IllegalStateException
异常,并提示:
stream has already been operated upon or closed
就算流能够被多次消费,上述实现也还有一个更大的问题。
使用递归时需要 明确递归结束条件,否则递归会无限执行下去,最终引发 OOM,而这个结束条件在上述实现中是不存在的。
先把多次消费流放在一边,现在急需解决的是:
- 需要一种方法「延迟」
primesErr()
方法中对IntStream#concat()
方法第二个参数的计算
简单来说,只有需要处理那个质数的时候(比如调用了 limit()
方法)才对流进行计算。以更加技术性的术语来描述,那就是「延迟计算」。
延迟列表
接下来将实现 LazyList
延迟列表,它与流很类似,是一种更加通用的流形式。与 LinkedList
相比,LazyList
中的元素并不直接存在于内存中,而是在需要使用时动态创建:
仿造 java.util.List
定义最基本的 MyList
接口:
1 | public interface MyList<T> { |
为了更好地理解,你可以将 MyList
当成 LinkedList
中 Node
:
1 | private static class Node<E> { |
其中:
MyList
中的head()
方法相当于是获取Node
中item
的方式,即获取当前节点值MyList
中的tail()
方法相当于是获取Node
中next
的方式,即获取当前节点连接的下一个节点
根据定义的 MyList
接口,不难完成 MyLinkedList
的实现:
1 | public class MyLinkedList<T> implements MyList<T> { |
此时的 MyLinkedList
并不具备延迟计算的特性,对其进行改造的最简单方式是避免 tail
立即出现在内存中,使用 Supplier
包装 tail
,只有在实际需要时才计算 tail
:
1 | public class LazyList<T> implements MyList<T> { |
再提供一个 from()
静态工厂方法,用于构建由数字组成的无限延迟列表:
1 | public static LazyList<Integer> from(int n) { |
使用与先前 primesErr()
方法相同的逻辑定义 primes()
方法:
1 | public static MyList<Integer> primes(MyList<Integer> numbers) { |
内部调用的 filter()
还是 MyList
接口里的默认实现,在 LazyList
中需要额外实现。
实现延迟列表的
filter()
首先需要明白,filter()
入参 predicate
的作用对象应该是当前的 head
,也就是:
1 | predicate.test(head()) ? XXX : YYY |
接下来需要对是否满足条件进行不同的处理:
- 如果当前
head
满足条件,那么应该新构建一个LazyList
,包含当前head
并将predicate
继续应用在tail()
上(相当于构造包含当前head
的流) - 如果不满足条件,跳过当前数,对下一个数即
tail()
应用predicate
filter()
方法的完整实现如下:
1 | public MyList<T> filter(Predicate<? super T> predicate) { |
测试延迟列表求质数
1 |
|
4.3 更多实践
在本文发布前,已经编写过两篇在实际生产中运用 Lambda 表达式的文章,参考:
- 利用「复合 Lambda 表达式」实现通用 JSON 组件定位:Reduce Functions
- 利用「环绕执行模式」进行代码重构:使用 Lambda 表达式重构成回溯
5. 不断进步
Java 8 引入的 Lambda 表达式彻底重塑了 Java 的编码范式,为 Java 注入了新的基因,使其在面向对象与函数式编程的杠杆中找到了独特的平衡点。
尽管本文已从语法特性、设计模式到生产实践层层递进,但所探讨的内容仍如浩瀚星海中的点点星光。除此之外在序列化机制、底层实现原理等深水区话题上,因涉及虚拟机层面的复杂机制且自身尚未完成系统性研究,为避免以讹传讹,本文选择暂且搁笔。
从函数式接口的契约精神到设计模式的优雅重构,Lambda 表达式不断突破传统编码的思维边界,而将这种声明式的编程范式融入日常开发后,不仅能收获更简洁的代码形态,更能开启对程序本质的重新思考。
子曰:「举一隅不以三隅反,则不复也」,希望这趟函数式编程的启蒙之旅,能成为你探索更高阶特性的跳板,让代码在抽象与具象的平衡中绽放出新的生命力。
6. 参考资料
- 陆明刚与劳佳(译)(2019)。《Java 实战》(第二版)(原作者:Raoul-Gabriel Urma、Mario Fusco 与 Alan Mycroft)。北京:人民邮电出版社。(原作出版年:2018)
- The Java® Language Specification
- Java Design Patterns
- Reference to method is ambiguous when using lambdas and generics
- Java 8 lambda ambiguous method for functional interface - Target Type
- Java8: ambiguity with lambdas and overloaded methods
埃拉托斯特尼筛法,sieve of Eratosthenes,简称埃氏筛。 ↩︎