函数式编程01:纯函数、引用透明性与总函数
同一订单为何需要保存日期和抽签结果
教学订单包含两件单价 12.50 元的商品。活动规则是每月第三日且抽签数小于十时打九折,其他情况原价。若报价方法只接收单价和数量,内部读取系统日期并生成随机数,保存这两个参数仍不足以重放报价。第二天重算、换一次随机结果,都可能得到不同金额。把方法改成静态方法,或者把实现缩成一行 lambda,都没有补齐缺失的信息。
报价核心可以接收已经取得的日期与抽签数:quote(unit, quantity, day, draw)。外层从时钟和随机源读取输入,核心按照固定规则返回金额。这一分工使 12.50、2、2026-10-03、5 对应 22.50,而同一天把抽签数改为 50 得到 25.00。记录一次计算所需的信息,现在可以直接从参数表得到。
本章的前置是 Java 方法、异常和基本测试。目标是区分纯度、可替换性和总性,不要求先理解任何组合抽象。Scala 14 的“把隐藏状态变成显式输入和输出”已经给出购物车状态的例子;这里保留其值语义观察范围,增加时钟、抽签、异常和不终止路径的 Java 对照。旧章的 Scala 3.3.7 实验记录不作为本章执行证据。
输入域先于“相同输入”
完整实验使用 Java 21。金额用 BigDecimal,币种在教学场景中固定为人民币;最终结果统一保留两位小数并使用 HALF_UP。这不是多币种结算模型。数量允许零,禁止负数;单价非负;日期非空;抽签数是零至九十九的整数。若真实业务要求数量至少为一,应修改输入域及零数量测试,不能仅换一条文案。
1 | |
签名展示四个参数,输入域还要补充参数间的规则。BigDecimal 类型本身允许负数,int 也允许负数量。Java 编译器确认参数类型匹配,不能证明这些值已经通过业务校验。因此这里的“纯报价”指合法输入上的计算;声明整个 Java 参数空间时,必须把拒绝路径一起纳入模型。
金额相等也需要约定。BigDecimal API规定 equals 会区分小数位数不同的表示,compareTo 则按数值比较。当前方法在出口统一 scale 为二,所以测试用 22.50 做 equals 是有意检查金额与表示规范。若删除归一化步骤而保留同一断言,失败可能来自表示差异;若只关心数值,应明确更换观察方式。
公式只有固定数量的乘法,不表示对任意大小金额都具有固定运行成本。BigDecimal 的有效数字可以很长,计算与分配随表示规模增长,scale 也有整数范围。教学样例只使用短小金额,没有验证极端 scale 或资源耗尽。若对外宣称报价总能返回正常金额,还应在导入边界限制金额位数、数量和小数位,并为拒绝建立明确结果;纯度不能替代这些规模边界。
纯度与引用透明性的观察范围
纯函数在约定输入与观察边界内不依赖可变外部状态,也不产生可观察副作用。引用透明性讨论表达式:把表达式换成它的值,周围程序能否观察到变化。前者描述计算的依赖和作用,后者给出重构与推理的替换条件。
quote(...) + quote(...) 可以先算一次再复用结果,前提是金额组合按值观察,且两次调用参数相同。若在方法中增加“已报价次数”的共享计数,每次返回值仍可能相等,替换后计数却减少。实验中的 counted(2) 每次返回四并增加一次全局计数,重复调用相加与保存一次结果相加都得到八,计数分别为二和一。相同返回值只是替换条件的一部分。
打印、审计日志、数据库写入和网络请求都能被外部观察。即使写入内容完全相同,次数也可能有意义。不能因为日志“只为调试”就自动忽略它;可以在一项明确限定的等价讨论中排除诊断日志,但业务审计记录应保留在执行协议中。实验计数器本身是观察装置,并不被当作纯函数示范。
对象身份也会扩大观察范围。两次构造值相等的结果对象可能产生两个引用,缓存后则可能复用一个引用。这里用金额值判断报价,不以 == 判断新对象身份,不比较纳秒耗时、内存地址和分配数量。若调用方依赖身份锁、弱引用或可变返回对象,这些就不能被排除,替换结论必须重新检查。
局部可变变量并非自动违规。一个方法内部创建累加器,扫描不可变输入,返回一个稳定数值,没有把累加器暴露给其他调用,外部仍可以按输入输出推理。反过来,一个没有赋值语句的方法若读取系统时钟,也可能依赖隐含输入。判定应沿读取和写入路径进行,而不能按 final 数量或方法长度打分。
可注入的时钟仍是一个能力
实验用 Clock.fixed 构造固定时点,再在边界取得 LocalDate。JDK 21 的 Clock 文档明确提供可替换时钟和固定时钟,适合控制测试时间。这里的纯核心接收日期值,不把一个会继续变化的时钟对象藏在报价函数中。
若签名改为 quote(..., Clock clock),依赖虽然可见,但每次读取是否相同仍由时钟决定。传入 systemUTC() 并不等于冻结当前日期;传入固定时钟能让特定测试稳定,也不能证明任意实现都稳定。抽签源同理:传入 Random 实例会携带游标状态,连续读取不同;传入已经取得的整数才把这次选择固化成普通数据。
记录随机种子与记录抽签值服务于不同目标。种子还依赖算法、版本和此前消费次数,抽签值直接描述本次报价使用的输入。需要重放整个模拟时可以记录种子及消费协议;只需复核一笔报价时,保存实际抽签值更直接。本章不声称完成随机数分布检验,也没有通过随机抽样证明折扣正确。
纯计算未必对全部输入返回结果
总函数对声明域中的每个输入都有定义,并在计算模型允许的条件下终止。x / y 没有外部写入,却不能作为从所有整数对到普通整数的总函数:分母为零时无法返回商。Java 的最小整数除以负一还涉及固定宽度表示边界。实验把这两种情况映射为 OptionalInt.empty(),其余返回商,使失败在返回形状中可见。
这个修改没有让非法输入产生正常商,而是扩大输出类型:Int × Int → OptionalInt。空结果表明该输入没有这里约定的整数商。若调用方需要区分除零与溢出,就应返回有错误原因的类型;只返回缺席会丢失原因。后续错误建模章再处理信息结构,本章只用最小例子展示总化思路。
异常的分类取决于模型。若观察结果限定为正常返回值,抛异常路径不满足总性;若把异常作为结果集合的一部分,就要明确它怎样组合、由谁捕获。不能一面忽略异常,一面宣布整个方法对所有 Java 值都有正常输出。这里负数量确实抛出异常,实验断言捕获到的是预期拒绝,正常进程才继续。
不终止是另一条轴。for (;;) Thread.onSpinWait() 不修改业务数据,却永远没有退出分支。在理想执行模型中,可以从控制流分析它不会正常返回;有限运行只能证明一段观察窗口内未退出。实验为这个分支启动子 JVM,先等待 ENTERED_SPIN,再检查一百毫秒内未退出,最后强制终止并回收子进程。进入标记避免把“JVM 还没启动”误当作“不终止分支已执行”。
该超时不是一般终止性判定器,也不是性能指标。真实机器可能受资源限制而终止,外部强制结束更不同于函数返回。把纯度、总性、栈安全和资源上限分开后,才能准确说出一个程序究竟获得了哪种保证。
重构时怎样保持边界协议
把日期与抽签移到外层,并不意味着可以任意调整读取顺序。旧接口若先校验数量,再读取随机源,负数量请求不会消耗随机数;重构后若先抽签再调用核心,负数量也会推进随机源。对单笔正常报价,两种写法可能返回相同金额,对连续请求的随机序列却不等价。应先列清合法性检查与外部动作的先后约定,再决定哪些校验留在边界、哪些在核心重复保护。
同样,边界应为一笔报价读取一次日期,而不是每行各读一次。午夜附近逐行读时钟可能让同一订单的不同商品使用两天的规则。把日期保存为订单级输入可以消除这类内部不一致,但没有决定应该使用用户所在地、店铺所在地还是统一结算时区。固定 UTC 时钟只是实验装置,领域日期的解释仍需要业务规则,不能从测试便利性推导出来。
引用透明性还提供一个实用的审阅问题:假设编译器或维护者把两次相同调用合并为一次,会少发生什么?对报价核心,少的是可以忽略的重复计算;对扣库存,少的是一次写入;对读取游标,第二个元素可能不再被取得。先写出具体损失,比笼统询问“有没有副作用”更容易发现藏在依赖方法里的动作。这种思考并不授权编译器做任意优化,而是检验当前观察协议是否允许替换。
测试组织也应保持这一区分。公式测试直接提供日期和抽签值,让失败只指向分支、算式或舍入;边界测试检查日期取得次数、抽签范围及动作顺序。两组测试服务于不同承诺,不能用一组稳定的纯函数测试代替外部协议测试。本章执行的是前一组及若干可观察性反例,真实请求的读取、校验与审计顺序仍需在接入代码中验证。
自测与可运行修改
手算:日期为十月三日,单价 0.05,数量一,抽签数零,九折后的未舍入结果是多少?结果是 0.045,按照本章规则最终为 0.05。若先把单价打折并舍入后再乘数量,数量增加时可能与整单最后舍入不同。这不是纯度问题,而是业务算式不同。
类型题:Supplier<Integer> 与 Integer 哪一个足以记录本次抽签结果?后者保存值;前者只表示取得值的能力,可能每次读取不同。即使函数参数表包含 Supplier,也不能从类型推导可替换性。
修改练习在 Main.java 的 exercise-rounding-0.045-to-0.05 断言处提供可运行基准。先把舍入模式改为 DOWN,运行后该断言应失败;再恢复规则,并增加抽签数九与十、日期三日与四日、零数量的边界断言。每次修改只改变一个规则,才能定位失败的含义。不要靠把预期值同步改错来“修复”测试。
完整入口为 Main.java,从仓库根目录运行:
1 | |
本章编译、正常报价、拒绝输入、共享计数、不终止观察与舍入断言的实际记录见 result.json。其中保存 JDK 环境、源码哈希、命令、退出码和输出;出现 PASS 后仍需检查整个执行步骤为零退出码。源码中的显式检查在禁用 Java assert 时也有效。
这些断言验证的是具体程序与输入。它们没有证明任意实现都纯,没有验证真实付款、时区切换部署或随机分布。将外部事实转换成稳定输入之后,业务公式才有一个可独立检查的范围。

