系列导读与能力自测

折扣规则成为参数之后

订单总额为 100.00,普通活动打九折,会员活动打八折。把两个规则分别塞进报价器的条件分支,报价器就要知道所有活动种类。另一种接口只要求传入一个金额变换:报价器负责提供总额,策略负责返回应付金额。增加封顶八十五元的规则时,可以传入另一个函数,不必扩张原来的分支表。

这种拆分并没有把策略变成可信代码。策略可能返回负数、读取数据库或抛出异常。函数作为值只提供一种传递行为的方式,是否纯、是否终止、是否符合金额域,仍由实现和契约决定。本章用相同订单金额检查策略替换,再用闭包读取可变数组反驳“lambda 自然是纯函数”。

已有 Scala 03“闭包保存了什么”对比了捕获 var percent 与冻结整数值的行为。本章把这一区别迁移到 Java:局部变量捕获受 effectively final 约束,但变量指向的数组仍能变化。旧文的 Scala 语法和编译行为保持原归属,不混作 Java 规则。

函数值的输入输出形状

1
2
3
4
5
6
7
8
static BigDecimal apply(BigDecimal total,
UnaryOperator<BigDecimal> policy) {
return policy.apply(total);
}
static UnaryOperator<BigDecimal> discount(BigDecimal rate) {
return amount -> amount.multiply(rate)
.setScale(2, RoundingMode.HALF_UP);
}

UnaryOperator<BigDecimal> 表示接收金额并返回金额的函数形状。apply 的第二个参数是行为值,因此它是高阶函数;discount 返回一个函数,也属于高阶函数。使用处的 discount(new BigDecimal("0.90")) 产生九折策略,随后 policy.apply(total) 才计算具体订单。

在 Java 中,lambda 和方法引用依赖目标函数式接口,不能把任意没有目标类型的 lambda 直接赋给 var。这里显式使用接口类型,也可以把多个策略存入 List,并在不同订单处理中选出一个。函数值能够作为参数、返回值和集合元素,就是本章采用“一等”的具体含义。

JDK 21 的 Function API描述一个输入和一个结果的函数接口;UnaryOperator 是输入输出类型相同的特例。接口本身没有禁止副作用,也没有声明输出非空。实际程序若要求合法金额,需要在策略入口或结果边界增加检查,而不能把泛型参数当作领域证明。

报价类型可以先写成 Money → Money,策略工厂是 Rate → (Money → Money)。外层接收费率后返回一个仍等待金额的函数。调用工厂和执行策略是两个时点:前者保存配置,后者使用配置计算。把这两个时点混为一谈,会使“创建策略是否已经查询外部报价”之类问题难以检查。

高阶函数抽取的是变化位置

实验将九折与八折策略放进同一列表,以 100.00 调用,分别得到 90.00 和 80.00。高阶接口把不变部分限定为“把总额交给规则并返回结果”,而不是为全部活动建立一个庞大的抽象层。只有一处简单判断且不会被复用时,直接分支也可能更容易阅读。

函数参数适合单一、清晰的计算能力。如果一个策略还需要生命周期、多个互相关联的方法、可查询身份和配置校验,用具名接口或领域对象可能更明确。把任何对象都压成 Function<Object,Object> 会丢掉类型和协议,增加转换错误。函数式设计应减少隐含约定,不应以更短的代码掩盖能力边界。

方法引用也只是行为适配。一个 service::quote 可能捕获 service 引用,并在调用时读取 service 的字段。即使代码里没有 lambda 箭头,依赖仍存在。相反,一个显式 lambda 如果只使用不可变参数和稳定捕获值,可以保持纯计算。审阅时应进入被调用方法,而不能根据语法外观分类。

异常传播同样属于接口行为。实验传入一个主动抛 IllegalStateException("policy") 的策略,确认高阶 apply 原样传播异常,没有把它吞成零金额。这个失败路径是有意拒绝“返回类型看起来正常,所以调用必成功”的推断。若业务希望把拒绝作为数据,应另设计结果类型。

闭包捕获引用,不保证复制对象

1
2
3
4
5
final BigDecimal[] live = { new BigDecimal("0.90") };
UnaryOperator<BigDecimal> changing =
x -> x.multiply(live[0]).setScale(2);
var stable = discount(live[0]);
live[0] = new BigDecimal("0.80");

changing 每次运行都读取同一数组的第零项。数组引用没有改绑,所以满足捕获要求;数组内容修改后,同一个函数对 100.00 返回值从 90.00 变成 80.00。stable 则在调用 discount 时已经取得原来的 BigDecimal 引用,之后修改数组槽位不会改掉这个不可变金额对象,所以仍返回 90.00。

这一区别的关键是读取发生在哪个时点。x -> multiply(live[0], x) 把槽位读取留在未来调用中;discount(live[0]) 先求实参,再把已经取得的值交给工厂。不是因为一个写了 final 而另一个没写,也不是因为工厂返回的函数具有某种特殊复制能力。

Java 21 的 JLS 15.27.2规定 lambda 使用的局部变量必须为 final 或 effectively final。这个限制针对绑定,不会递归冻结引用对象。把数组换成 AtomicReference 可以使某些并发读写操作具备指定原子语义,但仍然是变化中的环境,不能恢复同输入同结果的性质。

捕获 this 时要额外注意生命期。保存回调可能间接保存其服务对象,服务对象又保存缓存或大型集合。即使回调体只有一行,所保留的引用图也可能很大。这里没有测量堆保留量,但能够从引用关系推导哪些对象必须继续可达。长生命周期注册回调时,捕获范围是实际的资源设计问题。

保存计算与保存结果

实验用 Supplier<Integer> 保存一个递增计数的动作。创建 Supplier 后计数仍是零;连续两次 get 得到一和二。这个小例子同时说明函数值不会因赋给变量就执行,也不会因为变量稳定而自动缓存结果。

如果写 int result = action.get(),则当前立即执行并保存一个整数。重复读取 result 不会再次调用动作。若写 Supplier<Integer> copy = action,只是增加一个指向同一动作的引用,两者仍操作相同计数器。保存对象引用和复制计算状态是不同操作。

这会直接影响默认值、重试和回调接口。接收普通值的 API 无法阻止调用者先执行昂贵计算;接收 Supplier 的 API 可以选择何时调用,但也可以调用零次、多次或并发调用。参数类型只让延期执行成为可能,调用协议仍应写明。第 07 章会分别检查延期、共享和一次消费。

计数 Supplier 不能被当作纯函数证明,但很适合观察调用次数。测试中的修改状态被限制在一项实验内部,目的就是让重复执行留下可比较的结果。把这种计数搬进生产策略后,就要承认它改变了策略的可观察作用。

成本与类型精度

策略工厂可能产生持有捕获值的对象,调用可能经过接口分派;JVM 优化可以改变实际分配和内联情况。源码中出现 lambda 不能直接换算成固定对象数量,更不能由一次小输入运行给出比循环更快的结论。本章只证明行为与捕获边界,不做性能排名。

大量临时组合可能形成长调用链,保留配置也可能延长配置对象生命周期。对于每条订单都相同的稳定规则,通常可以先创建策略再复用,而不用在每条记录里重复创建。若规则随请求变化,提前复用则可能把旧配置带到新请求,应把版本或配置输入明确纳入设计。

使用 BigDecimal 保留了十进制计算,但参数类型仍把费率与金额都表示为同一类,误传参数有时也能编译。第 04 章会从参数组织进一步区分工厂、部分应用和柯里化;如果错误频繁,应该引入 Rate 与 Money 这样的领域类型,而不是只给变量换更长的名字。

策略是否应包含舍入也需统一。若每个策略各自舍入,再把它们组合,结果可能不同于全部计算结束后一次舍入。策略组合的可交换性、结合方式和业务预期不能靠 Function 接口保证。金额类型一致只说明程序可以连接,不说明连接顺序正确。

回调的调用协议也是接口的一部分

同一个函数参数可以被不同调用者使用得完全不同。报价器可能恰好调用一次,筛选器可能为每一行调用一次,带重试的执行器可能在失败后再次调用。若策略偷偷发送优惠券,即使类型仍是金额到金额,重试就可能重复发券。高阶接口应说明调用次数是否受限、调用是否可能延后、异常是否继续传播。只读函数签名不足以回答这些执行问题。

当前 apply 的实现只有一次 policy.apply,没有提前试算、重复校验或失败重试,因此能从方法体直接确认一次调用。若将来为了验证输出又调用一次策略,再比较两个值,这种“保护”会改变非纯策略的行为。更合适的结构是保存一次返回值,对该值做校验;校验本身若需要访问其他系统,则应另外声明。保存函数并不等于保存它的一次结果,这个差别会贯穿后续的惰性求值与缓存实验。

捕获配置还需要区分版本语义。一个请求开始时取得九折策略,处理中配置更新为八折,应该继续九折还是立即跟随新配置?稳定闭包适合前一种约定,动态读取适合后一种约定,但后者必须承认同一请求可能受到更新时间影响。不能把稳定捕获一概称为修复,也不能把读取最新配置一概称为更正确。业务先确定观察时点,函数工厂再把这个时点落实为值捕获或能力读取。

使用具名函数通常也有诊断收益。把费率校验放进 discount 工厂,可以在创建策略时拒绝非法配置,而不是等处理第一笔订单时才报错;本实验只演示合法费率,未实现完整的配置加载协议。若工厂只保存配置而不检查,则工厂成功不能证明策略对全部金额都可执行。检查配置域、订单输入域以及输出金额,是三个独立的位置,不宜用“策略创建成功”替代后两项。

自测与修改练习

手算:先创建 live 数组为 0.9,得到 changing 与 stable,再把数组改成 0.8。对 200.00 调用两者,分别得到多少?changing 为 160.00,stable 为 180.00。若把工厂参数换成数组本身并在返回函数里读第零项,stable 也会变成动态读取,名字无法改变行为。

类型题:discount(rate) 的返回值是 BigDecimal 还是 UnaryOperator?它返回后者,还缺一个订单金额。apply(total, policy) 才返回 BigDecimal。能沿两个调用阶段写出类型,比仅记住“函数可以返回函数”更有助于读代码。

可运行修改题以 exercise-cap-strategy=85 为起点,将封顶值从 85.00 改为 75.00,更新这一规则对应的预期,并增加总额 60.00 的断言,确认低于上限时不抬价。然后把 live 的修改移到 changing 第一次调用之前,重新预测两次结果。已有 stable 断言应继续成立。

实验源码是 Main.java,从根目录执行:

1
node examples/functional-programming/run.mjs 03

result.json保存实际编译运行命令、JDK、源码哈希与输出。检查包括策略替换、动态捕获、稳定捕获、Supplier 计数、封顶策略和异常传播。所有金额样例都是教学输入;并发配置更新、生产策略注册与分配基准不在本章实测范围。