函数式编程04:部分应用、柯里化与偏函数
九折规则固定之后还剩什么
报价公式接收费率与订单金额,返回按两位小数舍入的应付金额。活动期间费率固定为 0.9,订单金额则逐笔变化。把费率先提供给报价函数,可以得到一个只等待金额的策略。这是部分应用:某次使用已经固定了一部分实参,剩余实参决定之后的调用形状。
另一个接口可以把二元函数改写成“接收费率,返回接收金额的函数”。这个转换是柯里化。再考虑仅允许非负订单金额的报价规则,它没有在所有 BigDecimal 输入上定义正常金额结果,这是偏函数的定义域问题。三个概念会同时出现在一个例子中,但回答的问题不同。
Scala 03 的“多参数列表、柯里化和偏应用”以及“PartialFunction 的部分指输入定义域”已经给出语言侧对照。本章延续其区分,使用 Java 21 写出每一步类型,并检查固定参数之后仍可能遇到非法输入。这里的偏函数是数学建模用语,不声称 Java Function 自动带有 Scala 的 isDefinedAt 方法。
从同一公式推导三个类型
教学金额类型直接采用 BigDecimal。费率在零至一之间,金额非负,最终用 HALF_UP 舍入到两位小数。空引用不在教学输入域中,负金额代表导入错误,而不代表退款;退款若进入业务,需要独立定义符号规则。
1 | |
binary 是 Rate × Amount → Amount。curried 是 Rate → (Amount → Amount),并没有固定任何具体费率。fixed 是 Amount → Amount,已经把 0.9 放进闭包。因此只看返回值都是函数还不够,必须看是否有实参已经被选定。JDK 的 BiFunction 文档规定它接收两个参数并产生一个结果;这里的柯里化转换由显式 lambda 实现。
调用 curried.apply(new BigDecimal("0.9")) 才得到与 fixed 对应的一元函数。最后再提供表示 100.00 的 BigDecimal,两者都得到 90.00。实验直接比较这两个结果,验证的是当前公式在当前输入上的一致性。没有哪一步把 0.9 当作已经计算好的订单金额,也没有提前执行报价。
箭头向右分组非常重要。A → B → C 通常读成 A → (B → C),它先收一个 A,再返回等待 B 的函数。若误读成 (A → B) → C,输入就变成函数,两者完全不同。Java 的嵌套泛型虽然较长,却把这个括号关系写得明确。
部分应用不要求先柯里化
fixed 直接通过 lambda 调用 binary,并没有先调用一个通用 curry 工具。只要能够保存已经提供的参数,再等待其余参数,就可以部分应用。三元报价函数也可以固定一个或两个参数,甚至固定中间参数,再用 lambda 明确重排剩余参数。
这种灵活性也意味着命名不能替代类型审阅。若参数全是 BigDecimal,交换费率与金额仍能通过编译,运行时可能触发输入域拒绝,也可能在某些数据上碰巧得到一样的乘积。乘法的对称性会掩盖语义混乱,加入税率与固定费用后错误就更明显。领域类型或具名构造器可以让参数角色在编译期更清楚。
参数顺序应该服务于使用频率。配置先到、业务数据后到时,把配置放在外层便于复用策略。若每笔订单使用不同费率,预先固定它可能没有价值,直接二元调用更简洁。不要因为某种写法能展示柯里化,就把所有接口改成多层函数。
返回的闭包会保存固定参数所需的环境。保存不可变费率值能保持策略稳定;保存配置对象并在调用时读取可变字段,则仍可能得到不同结果。部分应用没有自动复制参数。第 03 章动态数组的反例可以直接迁移到这里,说明函数形状转换和环境稳定性是两项独立判断。
偏函数保留未定义输入这个问题
本章 quote 对负金额抛 IllegalArgumentException。fixed 只固定了费率,仍然允许调用者把负金额作为 Java 实参传进来。实验确认 fixed.apply(-1) 被拒绝。减少参数个数没有扩大合法输入域,部分应用也没有把一个可能失败的计算变成总函数。
若要把负金额拒绝改成正常返回值,可以把这项“无法报价”放进返回类型:
1 | |
现在类型是 Amount → Optional<Amount>。在教学域内,负数返回 empty,零返回表示 0.00 的值,正数得到折扣金额。零金额合法而缺席不同,所以不能拿零充当失败哨兵。若后续业务有多种失败原因,Optional 只有有无两种形状,信息就不足。这个包装只处理负值分支,没有处理 null、极端小数位或资源耗尽,因此没有证明对所有 Java BigDecimal 对象都正常返回。
把偏函数提升为带缺席的普通函数,是一种未定义输入处理策略。另一种策略是在边界先构造 NonNegativeAmount,使核心只接收合法域;也可以保留异常协议,让调用层统一处理。每一种都有明确成本:缺席需要调用方继续分支,受控类型需要转换步骤,异常需要界定捕获范围。选哪一种取决于错误是否属于日常业务流以及是否需要保留原因。
Scala 标准库的 PartialFunction 2.13.16 API给出 isDefinedAt 与 lift 这样的具体能力。它能帮助表达定义域,但普通函数在部分输入上抛错,并不会自动获得这些成员。这里引用该接口是为了明确语言工具与数学概念之间的区别,本章可执行代码不依赖 Scala。
定义域检查和实际执行必须一致
一种容易出错的设计是先调用 isAllowed(x),再调用使用动态配置的 quote(x)。两次读取配置之间如果规则变化,第一次判断不保证第二次仍然适用。将规则版本与数据一同固定,或把检查与执行放入一次返回结构化结果的函数,可以避免这类检查和使用分离的问题。
即使单线程,重复的前置谓词也可能造成额外工作。例如校验函数查询远程服务,报价函数又查一次,两次结果不一定相同。偏函数的教学例子通常使用纯谓词,工程接口却不能自动沿用这个假设。一个方法声称“可处理”还不意味着实际执行不会因为程序缺陷或资源故障而抛异常。
Optional 化也不应使用无差别 catch。若把所有 RuntimeException 都变成 empty,数组越界和错误费率配置也会伪装成普通业务缺席。当前实现只把负金额这个已定义分支变成 empty,其余缺陷仍应暴露。显式处理范围比泛泛宣称“安全函数”更有用。
本例费率与金额的乘法最终会舍入。若把柯里化转换写成先对费率舍入,再对金额舍入,虽然输入输出类型不变,却已经更改公式。正确的 curry 与 uncurry 只组织参数,不插入新的业务计算。程序重构时应比较合法域上的实际结果和异常路径,而不仅比较两份声明的泛型形状。
反向转换与成本
1 | |
uncurry 把分阶段接收参数的函数改回二元接口。实验对费率 0.8 与金额 100.00 检查结果为 80.00。对纯函数和值观察,curry 后再 uncurry 应保持同一公式;但若外层函数本身产生副作用,构造内层函数的次数就进入观察范围,转换不能随意缓存或重用外层调用。
考虑一个外层工厂每次递增计数,内层再计算金额。调用一次工厂复用十次,与每笔订单都重新调用工厂,金额可能相同而计数不同。这个问题与定义域无关,来自求值次数。因此教学等式应声明讨论的是哪一类函数,不能用类型箭头代替作用分析。
额外函数层可能保存捕获环境,也可能被运行时优化掉。本章没有分配基准,不以闭包数量推断准确内存字节数。合理的工程边界是先让接口表达实际变化点,再在热路径中用相同输入和相同业务结果进行测量。为一次简单乘法引入多层函数而没有复用需求,会增加读代码的成本。
参数分阶段到达与错误分阶段暴露
把接口拆成两次调用,还需要决定校验发生在哪一次。当前 curried 外层只保存 rate,真正进入 quote 是收到 amount 之后。因此即使费率非法,创建内层函数这一步也可能暂时成功,直到第一次处理金额才抛异常。如果希望活动配置加载时就拒绝非法费率,应在外层显式校验。这个改动改善了失败时点,但已经不只是机械的柯里化转换,需要单独的工厂契约与测试。
这对复用有直接影响。费率校验若每次都依赖远程配置,提前固定参数可能减少查询次数,也可能错过后续更新;如果费率是已验证的不可变值,则把检查放在构造边界更容易解释。外层保存的是值还是服务引用,应从闭包环境判断。写成多参数列表不会自动选择其中一种,也不会把所有外部依赖转成快照。
偏函数的定义域还可能依赖已固定参数。设规则只允许折后金额不超过某一审批限额,固定不同费率之后,剩余金额参数的合法范围便不同。不能先替所有费率写一个统一“金额非负即合法”的谓词,再假定部分应用以后仍然正确。更稳妥的表达是让返回策略同时携带它的输入约束,或者由一次执行返回成功与具体拒绝原因。当前练习没有审批限额,所以其负值检查只是较简单的域,不应直接复制成生产校验。
手写转换的审阅方法是逐个追踪自由变量。fixed 的自由变量只有已选费率,实参 amount 来自后续调用;uncurried 则把每次收到的 rate、amount 原样传进两层 apply。若意外读取了外部默认费率,或者把 amount 也提前固定,类型可能仍相同,行为已经不同。用两组不同费率和不同金额交叉验证,能够比单独的百分之一百样例更容易暴露这些接线错误。
自测与修改练习
类型题:f 的类型为 (Rate, Amount) → Amount,g 等于 r → a → f(r,a),h 等于 g(0.9)。g 是柯里化形式,h 是已经部分应用的结果。h 是否一定是总函数?不能由这个形状判定;如果 f 拒绝负金额,h 仍然拒绝。将三件事分别写出来,才算通过本章最小诊断。
手算:restricted 分别输入 -1、0 和 100.00,结果是 empty、包含 0.00、包含 90.00。若用 orElse(BigDecimal.ZERO) 消费前两者,显示值可能一样,却已经丢掉“输入无效”和“合法零金额”的区别。是否允许这种信息丢失,必须由后续用途决定。
修改练习以 exercise-uncurry=80.00 为基础,增加一个 fee 参数,计算 rate × amount + fee,只在最后舍入。写出先固定费率和费用的一元策略,并与直接三参数方法比较。用 amount=100、rate=0.8、fee=3 验证 83.00,再用负金额检查拒绝路径没有因包装而消失。
完整 Main.java可从根目录运行:
1 | |
现有二参数基线的 result.json记录编译与五项显式检查:两种组织方式等值、合法与非法域、固定参数仍拒绝非法值、uncurry、合法零值。练习中的三参数扩展需要读者修改后重新运行,不属于已运行结果。当前材料的文档核验与实验分别支持接口规则和具体实现,均不证明任意业务函数满足总性。

