Skip to content

Java 里的函数式设计:不可变值、函数组合与副作用边界 ​

用 record 定义的购物车,调用方往原来的 ArrayList 里加了一件商品,购物车里的内容也跟着变了。record 只保证字段不能重新赋值,不保证字段指向的对象不变。函数式设计在 Java 里不是换一套语法,而是控制「什么会变、在哪里变」。

封装、组合与多态 讲的是用对象隔离变化。本文讲另一组手段:不可变值、把行为当作值传递、函数组合,以及把副作用推到边界。它们与面向对象并不冲突,同一段代码里经常一起出现。最后落到一个实用的结构上:决策写成纯函数,I/O 和状态留在外层,即「函数式核心、命令式外壳」。实验环境:JDK 21.0.5。

一、三种范式,三条限制 ​

讨论范式时,容易把它们理解成三套可以互相替代的语法。换一个角度会清楚得多:每种范式都拿走了程序员的一项自由,换来一种可以依赖的性质。

范式限制了什么换来的性质Java 中的对应
结构化任意跳转(goto)程序可以按顺序、分支、循环逐层分解方法、if、for
面向对象直接访问别人的状态;调用方直接绑定具体实现状态规则集中,实现可以替换封装、接口、多态
函数式赋值和共享可变状态同样的输入总得到同样的输出,可以安全地组合、缓存、并行record、Lambda、Stream、不可变集合

这张表来自 Robert C. Martin 对范式的归纳,它说明了一件事:三者约束的是不同的维度,所以可以在同一段代码里共存。一个服务类可以是面向对象的(依赖接口、封装状态),它内部的计价逻辑可以是函数式的(输入订单,返回价格),计价逻辑内部的循环仍然是结构化的。

真正要避免的是不知道自己在违反哪条限制,比如在 Stream 的 Lambda 里修改外部集合,或者把可变对象放进一个被当作值使用的 record。用了 Lambda 和 Stream 并不等于函数式:在 Lambda 里修改外部状态,只是换了一种写循环的方式,这条限制换来的性质一个也没有得到。

二、不可变值:record 只做了一半 ​

2.1 浅不可变的实测 ​

java
record Cart(String user, List<String> items) {}

List<String> src = new ArrayList<>(List.of("book"));
Cart c = new Cart("u1", src);
src.add("phone");            // 修改构造时传入的列表
c.items().add("pen");        // 通过访问器修改
System.out.println(c.items());
text
Cart.items after caller mutates source: [book, phone]
Cart.items after mutate via accessor: [book, phone, pen]

record 的字段是 final 的,这只意味着 items 这个引用不能改指向。它指向的 ArrayList 依然由调用方共享。这类对象一旦作为缓存值、Map 的键或跨线程传递的消息,就会出现「谁也没改它,它却变了」的问题。

修复方法是在紧凑构造器里复制:

java
record SafeCart(String user, List<String> items) {
    SafeCart { items = List.copyOf(items); }   // 复制并得到不可修改的列表
}
text
SafeCart.items after caller mutates source: [book]
SafeCart add -> UnsupportedOperationException

List.copyOf 会拒绝 null 元素,如果业务上允许 null,需要换成别的做法,但更好的选择通常是不允许。字段是数组、Date 或其他可变类型时,同样要复制。

2.2 不可变带来什么,代价是什么 ​

不可变值的好处可以逐条验证:

  • 可以放心共享:多个线程读同一个对象,不需要加锁,也不会看到修改到一半的状态。
  • 相等性稳定:作为 HashMap 的键时,不会因为字段被改而「找不到自己」。
  • 推理局部化:看到一个值,就知道它从构造之后没有变过,不用去找所有持有它的代码。

代价主要是修改时要创建新对象。对订单、金额、配置、消息这类数量不大的领域值,这点分配成本通常可以忽略;对一次处理几百万个元素的数值计算或热点循环,要按实际压测结果决定,不要为了风格统一强行不可变。

三、把行为当作值:函数组合 ​

3.1 用函数替代一层类型 ​

当一个「策略」只有一个方法、没有状态时,可以直接用函数式接口表达,不需要为每个策略写一个类:

java
UnaryOperator<BigDecimal> off20 = p -> p.multiply(new BigDecimal("0.8"));
UnaryOperator<BigDecimal> minus30 = p -> p.subtract(new BigDecimal("30"));

List<UnaryOperator<BigDecimal>> rules = List.of(off20, minus30);
Function<BigDecimal, BigDecimal> all = rules.stream()
        .map(r -> (Function<BigDecimal, BigDecimal>) r)
        .reduce(Function.identity(), Function::andThen);

策略需要名字、配置参数、多个方法或者需要被 Spring 管理时,接口加实现类仍然更合适,设计模式决策图 里讨论了这条边界。

3.2 组合顺序就是业务规则 ​

off20.andThen(minus30)200× 0.8160− 30130minus30.andThen(off20)200− 30170× 0.8136
图 1 · 同样两条规则,andThen 的顺序不同,200 元的商品分别得到 130 元和 136 元;组合顺序本身是业务规则,应当写进测试,而不是依赖集合的遍历顺序
text
8折再减30: 130.0  减30再8折: 136.0
reduce 组合: 130.0

reduce 按列表顺序组合,所以规则的顺序取决于 rules 是怎么构造出来的。如果规则从数据库读出、放进 HashSet,或者由多个 Spring Bean 注入成一个 List,顺序就可能在某次改动后悄悄变化。

对应的做法有两条:

  • 把顺序显式建模,比如给规则加一个优先级字段,排序后再组合;
  • 为顺序写测试,断言「先打折后满减」的结果,而不是只测每条规则各自的结果。

四、副作用:Stream 里最容易出错的地方 ​

Stream 的设计前提是:中间操作是无副作用的函数,终止操作决定要做多少工作。在 Lambda 里做副作用,就等于依赖了一个 API 没有承诺的执行过程。

4.1 count() 可能跳过整条流水线 ​

java
AtomicInteger peeked = new AtomicInteger();
long n = List.of(1, 2, 3, 4, 5).stream().peek(x -> peeked.incrementAndGet()).count();
text
count=5 peek 执行次数=0
加 filter 后 count=5 peek 执行次数=5
map 中的副作用执行次数=0

count() 的 Javadoc 说明,如果能直接从数据源算出数量,实现可以不执行流水线。List 的大小是已知的,peek 和 map 都没有执行;加了 filter 之后大小未知,才逐个执行。所以,在 peek 或 map 里写日志、计数、调用外部服务,是否执行取决于你后面接了哪个终止操作以及 JDK 的实现细节。

4.2 并行流与共享的可变集合 ​

java
List<Integer> out = new ArrayList<>();
IntStream.range(0, 100_000).parallel().forEach(out::add);
text
20 轮 forEach(ArrayList::add):丢元素 14 轮,抛异常 6 轮
20 轮 toList():不一致 0 轮

出错的轮数与线程调度有关,复测时是 19 轮(16 轮丢元素、3 轮抛异常),能确定的只是「会出错」,不是出错的概率。

ArrayList 不是线程安全的,并行 add 会丢元素,扩容时还会抛 ArrayIndexOutOfBoundsException。改成 toList() 或 collect(...),由 Stream 自己负责合并各线程的部分结果,就不存在共享可变状态。

规则可以简化成一句:Stream 用来计算一个结果,不用来驱动一组副作用。需要对每个元素调用外部系统时,用普通循环写清楚错误处理和重试。

五、Optional 与记忆化的边界 ​

5.1 Optional 不消灭空指针 ​

text
Optional.of(null) -> NPE
值存在时 orElse 仍调用默认值方法 1 次
值存在时 orElseGet 调用 0 次
Optional 变量本身为 null -> NPE

Optional 的价值在于让方法签名说出「这里可能没有结果」,逼调用方处理缺失的情况。它不能阻止别人传 null 或返回 null。使用时注意三点:

  • orElse(x) 的参数总会被求值,默认值计算有成本或有副作用时用 orElseGet;
  • 它主要用于返回值,不适合做字段、方法参数或集合元素;
  • 集合类型的返回值用空集合表示「没有」,不用 Optional<List<T>>。

5.2 记忆化要满足前提 ​

记忆化(memoization)是用缓存记住纯函数的结果。它成立的前提是函数是纯的:同样的输入总是同样的输出,没有副作用。在 Java 里常见的写法是 computeIfAbsent,但递归使用时会出问题:

java
static long fib(int k, Map<Integer, Long> memo) {
    if (k < 2) return k;
    return memo.computeIfAbsent(k, i -> fib(i - 1, memo) + fib(i - 2, memo));
}
text
ConcurrentHashMap 递归 computeIfAbsent -> Recursive update
HashMap 递归 computeIfAbsent -> ConcurrentModificationException

两者的 Javadoc 都要求映射函数在计算过程中不能修改这个 Map。递归的记忆化要么先查后放(get 再 put),要么改成自底向上的迭代。缓存外部服务的结果则是另一件事:那不是纯函数,要考虑过期、容量和一致性,属于 缓存专题 的范围。

六、函数式核心,命令式外壳 ​

前面几节的共同结论是:纯函数容易组合、容易测试,副作用和共享状态难以推理。但服务端程序不可能没有副作用,它要读请求、查数据库、发消息。实际可行的结构是把两者分开:

命令式外壳:读取输入、执行副作用、持有状态告警 WebhookClock.instant()规则文本、去重记录函数式核心:值进,值出Router.route(alert)QuietHours.suppress(…, now)RuleText.parse(text)发电话、发群消息写入去重记录记录日志与指标值决定核心的测试:给定告警和时间,断言返回的通知目标;不需要网络、数据库和 mock外壳的测试:少量集成测试,确认输入被正确读取、决定被正确执行
图 2 · 把决策写成只接收值、返回值的函数,把读取输入和执行副作用留在外层:核心可以不用 mock 直接测试,外壳足够薄,少量集成测试就能覆盖

以 通知路由案例 为例:

java
// 核心:只接收值、返回值
List<Target> targets = router.route(alert);                 // 给定告警,算出通知谁
boolean quiet = quietHours.suppress(alert, target, now);    // 时间也作为参数传入

// 外壳:读时钟、发送、记录状态
Instant now = clock.instant();
for (Target t : targets) if (!quiet(t, now)) channels.get(t.channel()).send(t, alert);

这样拆分之后:

  • 核心的单元测试不需要 mock,给定输入断言输出,毫秒级完成(关注点分离与可测试性 里有同样思路的实测);
  • 外壳里的代码大多是「读、调用、写」,逻辑很少,用少量集成测试覆盖;
  • 需要持有状态的部分(比如去重记录)明确地放在外壳,或者把状态作为参数传进核心、把新状态作为返回值传出来。

判断一段代码能不能放进核心,可以问:给定同样的输入,它是否总是返回同样的结果、不修改任何外部状态?做不到的部分放在边界,并保持简单。

不可变集合、final 与 record 在改写时各自的契约差别,见换一种写法,改了哪些契约。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权