函数式编程05:函数组合与管道
导入文本怎样变成显示结果
订单文件中的数量是字符串,计算需要整数,页面最终需要带前缀的文本。把三件事塞进一个方法当然可行,但当清理空白、校验范围与展示格式需要独立变化时,逐步连接小函数更容易检查。连接之前先写类型:清理是 String 到 String,解析是 String 到 Integer,展示是 Integer 到 String。
管道能成立,是因为相邻边界的类型对得上。解析后仍然可能得到负数,展示也可能把错误整数格式化得很漂亮,所以类型连接只解决结构兼容性。合法性、失败路径和调用次数仍须验证。本章使用三个整数函数检查组合定律,再以真实字符串解析记录事件次序。
Scala 03 的函数值部分已经解释方法如何进入函数参数与集合;Scala 12 的 map 部分指出融合变换可能改变副作用次序。本章先研究一项输入上的普通函数组合,为之后的集合变换和带失败上下文的组合提供基础。
从展开式读懂方向
1 | |
andThen 的参数顺序按数据前进方向写:先 f,后 g。compose 用数学常见的外层函数在前形式,调用 compose(g,f) 得到 g(f(a))。这两个名字并不是两个不同计算能力,而是同一连接关系的不同阅读方向。JDK 21 Function 的 compose、andThen 与 identity给出了相同方向约定,并说明异常向调用方传播。
教学实现只使用精确泛型 A、B、C,目的是展示中间类型如何消去。标准库接口还使用有界通配符提供更宽的接入能力,不能把这里的简化签名当成整个库接口复制品。理解核心关系后,工程代码通常直接调用标准方法,不需要再建立一个同名工具层。
设 f(x)=x+1,g(x)=2x。输入三时,g(f(3)) 等于八,f(g(3)) 等于七。两种组合都类型正确,结果却不同。函数组合通常不满足交换律;如果业务先折扣后加固定费用,与先加费用后折扣,计算顺序本来就有意义。
1 | |
这种类型图能直接发现把 display 接到 trim 前面的误连,也能暴露同型函数的局限:trim 和其他字符串变换都为 String 到 String,交换它们可能仍可编译。因此语义重要的步骤还要给出名称与实例,不能期望泛型拒绝全部错误顺序。
结合律允许重新分组
对于 f、g、h,先组合 f 与 g 再连接 h,与先组合 g 与 h 再接在 f 后面,展开都得到 h(g(f(x)))。结合律改变括号分组,不改变执行顺序。由此可以把一段经过验证的管道命名为子步骤,再用于更大的管道,而不需要重新发明执行规则。
实验在整数 -20 至 20 的四十一项输入上检查两种分组,另选 h(x)=x-3。结果全部相等。这个范围没有触及溢出,比较使用整数值,不比较函数对象身份。有限样本支持当前实现,结合律的一般理由仍来自展开后的同一表达式,而非四十一次碰巧通过。
恒等函数把输入原样返回:identity(a)=a。在 f 前面或后面连接 identity,值结果都应与直接 f 相同。空管道可以据此有一个自然起点,不用引入返回 null 的特殊分支。identity 的类型是 A 到 A,不能拿返回固定零的函数代替;后者会丢掉输入信息。
“原样”在本例指同一个输入值或引用,没有复制对象。如果输入是可变对象,identity 并不生成快照。把它用于共享对象的管道,别名问题仍然存在。函数定律的观察范围与第 02 章不可变性契约相互衔接,不能各自孤立判断。
一次连接应该调用多少次
手写 andThen 内部只调用一次 f,并把结果传给一次 g。实验的 parse 在事件列表增加 parse,display 增加 display。输入字符串三时,返回 qty=3,事件严格为 [parse, display]。这让求值顺序与次数都成为可失败的断言,而不是只通过最终字符串猜测过程。
错误组合器可能为了调试先调用 f 打印结果,再调用 f 把结果交给 g。若 f 是纯且便宜的函数,值结果看不出差异;如果 f 消耗输入游标、产生序号或请求外部数据,第二次执行就可能改变结果。实验另构造 duplicated,把同一个计数函数运行两次,确认计数为二,明确展示这种实现没有遵守“一次”协议。
并非所有带副作用函数都自动破坏正确组合器的结合律。对这里直接嵌套的调用形式,两种分组仍以 f、g、h 的顺序各执行一次。危险来自额外求值、重排、缓存、异常处理或观察方式的变化。不能把“有副作用”笼统当作所有等式必定失败的充分条件;应比较展开后的事件序列。
构造函数本身如果有副作用,则还要区分创建管道的时点与运行管道的时点。本例的组合器创建 lambda 时不调用 f 或 g,事件只在 apply 时产生。若某个包装器在构造时探测一次函数,组合表达式的括号可能改变探测次数或时机,已超出当前简化实现。
失败在哪一步停止
Integer.parseInt("bad") 抛 NumberFormatException 后,display 不会获得一个整数,也不会执行。实验清空事件列表后运行该输入,确认异常发生且列表只含 parse。这是普通函数组合在异常下的控制流,不是自动生成了某种错误容器。
如果把解析改成返回 Optional
预处理位置也有实际影响。trim 放在 parse 前面,空白包围的四能被解析并显示 qty=4;放在 display 后面则无法修复已经失败的解析。实验的修改题把清理函数接到最前面,检查正常输出,同时保留 bad 输入的失败分支。新步骤不能使错误数据悄悄变成默认零。
校验可以放在解析之后、展示之前,例如负数抛业务拒绝;也可以返回带原因的结果。把所有失败统一包装成“格式错误”会混淆格式和范围,因为字符串 -1 的语法合法。管道拆分的收益在于让这些不同步骤显式,而不在于拥有更多方法名称。
重构边界与执行成本
一个固定长度 k 的组合管道通常对每个输入执行 k 次步骤;创建组合对象和实际函数工作应分别计算成本。如果每一步只是整数运算,包装调用开销可能值得测量;如果每一步执行网络请求,则外部延迟和重复调用更重要。本章没有基准,不用打印时间宣称组合更快。
深度组合也不等于栈安全。这里的 lambda 在调用时递归进入内层函数,一条极深链可能消耗调用栈。结合律描述表达式的值关系,不保证运行时把所有嵌套变成循环。对于大量动态步骤,可以用显式列表顺序执行;是否需要 trampoline 等机制由更深的执行需求决定。
调试时可为有业务意义的中间结果命名,而不是为了缩短代码把十几层 apply 写在一行。一个子管道应对应可解释的输入输出,例如“文本数量到合法数量”,这样局部测试和错误定位才有价值。把任意三步随意打包为 helper 只隐藏控制流,没有改善组合。
金额管道还须固定舍入位置。即使每步为 Money 到 Money,先舍入再折扣与最后统一舍入可能不相等。函数组合的结合律不会把已经写在函数内部的舍入移动出去,但人为“融合优化”可能这样做。比较重构前后的输出时,应包含恰好位于舍入边界的输入,而不只测试整数金额。
管道边界决定错误能否被定位
一条导入记录经过清理、解析、校验和展示,每一层可以保留不同的诊断信息。清理后只保存整数,就再也无法区分用户输入的是四还是带空白的四;若审计需要原文,应让管道返回包含原文与规范值的结果,而不是在函数内部写全局日志来补救。纯函数并不要求丢弃上下文,只要求把需要保留的信息放在显式输入输出中。
阶段名称也影响错误定位。NumberFormatException 能表明解析失败,却不能在多字段导入时自动指出订单号与字段名。可以在边界给异常附加这些信息,或把它转换成明确的失败数据。转换范围应局限于预期的格式失败;若展示函数因为程序错误抛异常,不能把整条管道的异常都改称“数量格式错误”。组合器负责连接,业务边界负责解释失败,职责清楚后才不会因复用包装器而丢失原因。
组合定律的测试可以分为两层。对组合器,用计数和事件验证每一步恰好一次、先后正确、异常后不执行下游;对业务步骤,用边界输入验证各自公式。一个错误的 parse 若总把文本三解释成四,组合器仍可能满足结合律。因此定律测试不是业务示例测试的替代,两者也不应该都只使用最终字符串相等这一个观察量。
修改管道时,还要保留不参与修改的行为。例如加入 trim 是放宽对首尾空白的接受范围,不应顺带允许小数、指数格式或空字符串。针对 4 的新正例,应同时保留 bad 的负例,并明确空白字符串清理后为空仍应拒绝。这种回归组织让“新增预处理”拥有可核对的范围,避免在追求管道顺畅时把输入规则不断放松。当前实验验证空白整数与非法文本,其他格式属于读者扩展测试的输入集合。
自测与修改练习
手算:f 加一、g 乘二、h 减三,输入三时,andThen(andThen(f,g),h) 得到五;andThen(f,andThen(g,h)) 也得到五;交换 f 与 g 后得到四。前两个检验分组,第三个检验顺序,不能混用。
类型题:解析为 String → Integer,校验为 Integer → Boolean,展示为 Integer → String。直接把校验接在解析和展示之间是否成立?不成立,Boolean 无法交给展示。若要继续传递整数,可以把校验写成成功时返回原整数、失败时返回明确错误的结构;不能靠忽略 Boolean 来声称校验已经参与流程。
可运行修改题位于 exercise-trim-before-parse。增加去掉数量前缀 qty: 的纯函数,组成从 qty: 4 到 qty=4 的完整路径,保留未知前缀与非法数字的反例。再故意将 andThen 改为调用 f 两次,事件断言应失败;恢复后重新运行全部场景,确保测试能捕捉求值次数错误。
源码为 Main.java,运行:
1 | |
result.json保存四十一项定律样本、8/7 方向差异、事件顺序、失败跳过下游、trim 修改基线和错误组合器的实际输出。每个检查失败都会令进程非零退出。它不包含任意深链栈安全或性能结论,相关判断不能从本章的有限运行扩张出去。


