Skip to content

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

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

封装、组合与多态 讲的是用对象隔离变化。本文讲另一组手段:不可变值、把行为当作值传递、函数组合,以及把副作用推到边界。它们与面向对象并不冲突,同一段代码里经常一起出现。实验环境:JDK 21.0.5。

一、先说结论 ​

  • 范式的价值在于它禁止了什么:结构化编程限制随意跳转,面向对象限制对状态的直接访问,函数式限制赋值和共享可变状态。Java 可以混用三者,关键是清楚每段代码在遵守哪条限制。
  • record 是浅不可变:字段不能重新赋值,但字段里的 List 仍然可以被修改。要得到真正的值对象,需要在紧凑构造器里做防御性复制。
  • 函数组合有顺序:andThen 的先后就是业务规则的先后,实测同样两条优惠规则,200 元分别算出 130 元和 136 元。
  • Stream 里的副作用不可靠:count() 可能根本不执行 peek;并行流往 ArrayList 里 add,20 轮里有 19—20 轮出错。
  • Optional 用于表达「可能没有结果」的返回值,它不会消灭空指针;orElse 的参数总会被求值。
  • 最实用的结构是「函数式核心、命令式外壳」:决策写成纯函数,I/O 和状态留在外层。

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

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

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

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

真正要避免的是不知道自己在违反哪条限制,比如在 Stream 的 Lambda 里修改外部集合,或者把可变对象放进一个被当作值使用的 record。

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

3.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 或其他可变类型时,同样要复制。

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

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

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

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

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

4.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 管理时,接口加实现类仍然更合适,设计模式决策图 里讨论了这条边界。

4.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 没有承诺的执行过程。

5.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 的实现细节。

5.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 与记忆化的边界 ​

6.1 Optional 不消灭空指针 ​

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

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

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

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

八、常见误区 ​

  • 「用了 Lambda 和 Stream 就是函数式」:在 Lambda 里修改外部状态,只是换了一种写循环的方式,函数式的好处一个也没有得到。
  • 「record 就是不可变对象」:它是浅不可变,含有集合、数组或其他可变字段时要防御性复制。
  • 「Optional 可以消灭 NPE」:它只是在类型上标出「可能没有」,不能阻止 null 被传进来。
  • 「函数式和面向对象二选一」:两者限制的是不同维度,对象负责封装状态和边界,函数负责无状态的计算和组合。
  • 「记忆化就是加个缓存代理」:只有纯函数才能放心记忆化;对外部调用加缓存,要处理过期与一致性。

小结 ​

在 Java 里做函数式设计,要点不是多写 Lambda,而是控制变化:值对象做到真正不可变,行为作为值组合时把顺序当作业务规则来测,Stream 只用来计算结果,副作用集中到外壳。判断一段代码是否合格,可以问:给定同样的输入,它是否总是返回同样的结果、不修改任何外部状态?能做到的部分放进核心,做不到的部分放在边界并保持简单。


配套实验

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

参考资料

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