函数式编程06:递归、fold、map与filter
三条订单行的不同输出
教学输入有三行:数量二、单价一百分;数量一、单价三百分;数量三、单价一百分。逐行金额是二百、三百、三百,总金额八百分,总件数六。若只显示数量至少为二的行,结果又应保留第一行和第三行。四项需求读取同一个集合,却产生不同形状的输出。
map 为每项输入产生一个结果,filter 保留满足条件的原元素,fold 把逐项贡献归入一个累加状态。递归和循环则是表达遍历控制流的方法。先写输出形状与顺序要求,再选择操作,比把“有循环”当成需要消除的问题更可靠。
Scala 12 的 map 与 foldLeft 段落已经给出相同订单数据的类型推导,并比较左右减法折叠。本章用 Java 21 手写折叠内核,直接比较循环、递归与折叠;旧文是概念承接入口,新的 Java 执行记录独立保存。
从循环提取不变量
1 | |
金额采用固定币种的整数分,避免本章遍历问题被小数舍入遮盖。乘法和总额加法使用精确溢出检查。数量和单价来自已知非负教学数据,record 本身没有实施业务校验;如果来自文件,需要在进入汇总前处理负数和解析失败。
循环正确性的依据是前缀不变量:处理完前 k 行后,total 等于这 k 行的金额之和。起点是空前缀,总额零。每一步加入当前行金额,保持同一含义。处理全部三行后得到八百。这个解释覆盖空输入与任意合法长度,比只在结果后打印八百更有说服力。
total 是局部变量,调用之间不共享,返回值也不允许外部修改它。这样的循环仍可作为纯核心的实现。如果把 total 改成静态字段,第二次调用会接着上一次累计;操作符和循环外观几乎不变,调用契约却已经改变。
fold 将状态变化作为参数
1 | |
类型 A 是元素,B 是状态,step 的类型是 B × A → B。每次都返回 B,所以状态可以继续交给下一项。元素与状态不必同型,订单行为 Line,总额为 Long;若需要同时计算总件数和金额,B 可以是包含两个字段的 Summary。
空列表不会调用 step,直接返回 zero。zero 因而不仅是“没有数据时随便给的默认值”,它决定整个计算的起点。求和以零开始合适;求最小值若也以零开始,对全为正数的输入就会返回一个原本没有出现的值。可以用缺席状态表示尚无最小值,或采用无初始值的归约接口。
fold 的名字不保证 step 纯。它可以修改全局变量或直接修改累加器对象。如果 B 是可变列表,zero 被多个调用共享,就可能使结果相互污染。当前总额使用不可变数值,Summary 使用记录值,每一步产生新的状态,便于按前缀解释。
实验的 Summary 对三行返回数量六、金额八百。这个修改证明 fold 可以保存多个维度,而不需要先把集合遍历两遍。是否合并遍历仍取决于可读性与需求;如果两个统计的筛选条件不同,强行合并可能增加分支和错误耦合。
Summary 的 quantity 与 cents 累加都使用 Math.addExact,正常样本与两个累加溢出负例共用同一个 step。数量负例依次输入 (Integer.MAX_VALUE, 0)、(1, 0),金额始终为零,第二行使总件数超出 int 上限。金额负例输入 (1, Long.MAX_VALUE)、(1, 1),每行乘法均可表示,总件数仅为二,第二行使总金额超出 long 上限。两项检查分别要求抛出 ArithmeticException,避免单行乘法溢出掩盖累加边界。
递归把未完成的计算留在调用栈
普通递归求和可以写成当前位置金额加上剩余列表之和。空后缀返回零,非空后缀继续推进索引。三行展开为 200 + (300 + (300 + 0))。实验对相同输入检查递归结果八百,与循环、fold 相等。
当前递归在下一层返回之后还要做加法,调用链保留了这些待完成工作。长度增加时栈深也增加,小输入通过不能证明任意大列表安全。Java 代码即使把累加器挪进参数,也不应据此假定 JVM 一定消除尾调用。若需求只是汇总大列表,局部循环已经能明确控制调用栈。
输入在本实验是可重复读取的 List,三种实现读取同一组元素。若改为 Iterator,第一次汇总会消耗游标,再让第二种实现使用同一个对象,看到空输入不是算法不等价,而是测试前提变了。每种实现应获得相同数据的新游标,或者先物化一个稳定集合。
递归很适合沿树形结构描述计算,因为每个子问题形状相同。线性列表并不要求用递归才算函数式。选择哪种控制结构,应同时考虑数据形状、栈上限与表达成本;本章把行为等价放在语法偏好之前。
左右折叠改变括号和参数位置
左折叠的减法从零开始处理一、二、三,展开为 ((0-1)-2)-3,得到负六。右折叠则是 1-(2-(3-0)),得到二。实验同时断言这两个值,避免加法的巧合掩盖顺序差异。
教学 right 用反向索引循环实现,每次执行 step(element, acc)。它得到右侧嵌套的结果语义,却没有逐项递归的调用栈。方法名字说明折叠方向,不能单凭名字推断实现的栈行为。对 ArrayList 这样的随机访问列表,反向索引合适;若输入改成链表,反复按索引查找可能变为平方级工作,应改用反向迭代或其他策略。
字符串例子更直观:右折叠 a、b、c,以 z 为起点,每步组成括号,结果为 (a:(b:(c:z)))。这项断言检查结果的树形分组,不依赖整数运算。若误把 step 的元素和状态参数交换,输出形状会立即不同。
本章手写的 left 明确规定顺序,允许减法这类不结合操作。Java Stream.reduce 的契约不同,JDK 21 reduce 文档要求合适的单位元和结合运算,尤其不能把有序循环减法随意迁成并行归约。接口长得像逐步累计,不代表它只允许按原循环方式执行。
数学加法的结合性也不能直接套到所有机器数值。浮点舍入会影响分组结果,固定宽度整数溢出又是另一种边界。实验对 2 × Long.MAX_VALUE 要求抛 ArithmeticException,明确拒绝静默绕回。对于会跨越溢出中间值的组合,即使最终数学结果可表示,分组也可能改变是否先触发异常。
map 与 filter 保留或删除哪些信息
映射 Line → Long 得到 [200,300,300],长度与输入一致,位置一一对应。映射结果不再包含商品数量与单价,若后续还要显示明细,就不能只保留金额列表。类型变化同时是信息选择,不能只看 API 是否简洁。
过滤数量至少为二,再映射金额,得到 [200,300]。这两个三百中的哪一个被保留,需要根据对应订单行判断,不能因为金额相等就误认为删除没有区别。测试中保留顺序,业务若需要追踪拒绝原因,应返回另一份错误或排除清单;filter 只删掉不满足条件的元素。
先 map 再 filter 有时可以等价,例如金额变换纯且谓词能正确迁移;有时完全改变问题。按数量筛选与按金额筛选不是同一个条件,把它们交换需要重新推导。严格集合上的两次遍历与 Stream 的逐元素管道也可能产生不同副作用顺序,本章只用纯金额函数说明值结果。
map 和 filter 会形成输出集合,空间与保留下来的元素数量有关;直接 fold 总额只需一个数值状态。若仅需要总金额,先保存所有行金额再求和可能保留了不必要的中间结果。若页面同时需要每行金额,则这份列表有实际用途,不能为了减少分配而把需要的信息删掉。
空间成本跟输出需求一起计算
假设只需总金额,先 map 成金额列表再 fold,严格列表实现会保存每一项中间金额;把 amount 放进 fold 的 step,则可边读取边累加。两种方式在合法稳定输入上可以得到同一金额,但中间列表是否必要取决于后续用途。如果页面要逐行展示,又要显示总额,中间金额就是输出所需数据,不是纯粹浪费。成本分析应先明确需要返回什么,不能只统计源码里有几个操作符。
过滤后的数量也不能用输入长度替代。筛选可能一项都不保留,也可能全部保留,输出空间上界为线性,实际大小由谓词决定。若 filter 谓词执行昂贵计算,而 map 又重复同一计算,可以考虑一次生成包含判定与贡献的临时状态;但必须先确认重复计算是纯的,且合并没有改变失败顺序。把可能抛错的映射移到过滤前面,会让原本被过滤掉的非法行也触发错误,这不是单纯的性能变化。
fold 的状态增长同样值得检查。以数值为 B 时只保存一个累加结果;以字符串为 B 并在每步拼接此前全部文本,可能反复复制已构造前缀;以列表为 B 时,最终本来就要保存所有元素。因此“只有一个 acc 变量”不代表常数空间或线性总成本。应追踪 acc 所指对象的大小、每次 step 的工作以及旧状态是否仍被引用。
并行处理还需要拆分和合并规则。顺序 step 从 Summary 与 Line 产生新 Summary,并没有自动给出两个 Summary 怎样合并。若引入合并器,应分别验证空分区、分区内部汇总、分区结果合并与原始顺序结果一致。当前金额用精确溢出异常,分组可能影响中间溢出,不能只因为数学求和可结合就直接放进任意并行 reduce。当前实验保留顺序实现,不宣称验证了并行归约协议。
在导入数据边界上,还要决定一条坏行是否使整批失败。当前 amount 的溢出直接中断遍历,后续行不再计算;若业务希望收集全部错误,就需要把状态扩展为成功汇总和错误列表,并约定坏行是否计入行数。单纯捕获异常后继续循环,会隐藏这项政策选择,也可能把不完整金额误报为整批总额。
自测与修改练习
手算:以十为初始状态,对一、二、三做左减法折叠,结果为四;右减法折叠得到 1-(2-(3-10))=-8。初始值参与真实计算,并不是仅在空输入时才使用。改变初始值后,应重新展开全部括号。
类型题:若元素为 Line,状态为 Summary,step 必须是什么形状?它接收 Summary 与 Line,返回新的 Summary。把返回值写成 Long 会丢掉总件数并破坏状态类型。这个错误可以由编译器识别,金额公式错写成相加则仍可能类型正确,需要行为断言。
修改练习从 exercise-summary=6/800 开始,给 Summary 增加行数,要求空列表为三个零,三行样例为行数三、件数六、金额八百。再给输入增加一行数量零的数据:行数应增加,件数和金额不变。这个样本能够区分“行数”与“件数”,避免用同一计数掩盖概念差异。
完整 Main.java的入口:
1 | |
result.json记录十项实际检查,覆盖循环、递归、fold、空输入、映射筛选、左右分组、字符串形状、单行金额乘法溢出、正常双字段 Summary,以及 quantity 和 cents 各自的累加溢出拒绝。它没有测试大输入递归栈阈值,也没有性能基准。当前成本说明来自实现结构;把这些结论用于不同 List 实现前,需要重新检查访问操作的代价。
