三条订单行,需要四种不同结果

输入包含三条订单行:商品甲两件,每件一百分;商品乙一件,三百分;商品甲三件,每件一百分。报表要各行金额,发货任务要逐件商品列表,统计要订单总额,界面只显示数量至少为二的行。它们遍历同一列表,输出形状却不同。

map 保持一项输入对应一项输出;flatMap 允许一项输入对应零项或多项,再把结果接成一层;fold 把输入归入累加状态;collect 只处理部分函数定义的输入。先写输出类型,通常比背方法名更容易找到正确组合。本文以 Scala 3.3.7、标准库 2.13.16、JDK 21 运行断言,不把小整数样例冒充完整货币计算规则。

map 保留每项对应关系

1
2
3
4
5
6
7
8
case class Line(product: String, quantity: Int, cents: Long)
val lines = List(
Line("a", 2, 100),
Line("b", 1, 300),
Line("a", 3, 100)
)
val amounts = lines.map(l => l.quantity * l.cents)
assert(amounts == List(200L, 300L, 300L))

输入元素是 Line,变换是 Line => Long,结果是 List[Long]。这份严格 List 按顺序处理元素,结果位置对应输入位置。map 没有推断金额单位,分的约定来自 cents 字段;把乘法写错成加法也可能通过类型检查,所以还需要结果断言。

若回调返回列表,结果就是嵌套列表。它不会因为内层也叫 List 就自动展开。嵌套可以保留每个订单对应一组行金额的关系;展开则丢掉这层分组。选择形状之前应确认分组是不是后续所需的信息,不能为了让类型看起来简单就删除。

map 也没有强制纯函数。回调可以打印、修改变量或抛异常。把连续两次 map 合并成一次,可能改变副作用顺序:原来先对所有元素执行第一步,再对所有中间结果执行第二步;合并后则每个元素连续完成两步。纯变换可以按值组合,有副作用时必须另查时序。

flatMap 连接每项贡献的结果段

1
2
3
4
val expanded = lines.flatMap(l =>
List.fill(l.quantity)(l.product)
)
assert(expanded == List("a", "a", "b", "a", "a", "a"))

第一行贡献两个 a,第二行贡献一个 b,第三行贡献三个 a。手算时可以先列出三段小列表,再按原顺序连接。输出长度是每段长度之和,因此可能增加、减少或者归零。这个语义模型不要求实现一定分配一份完整中间嵌套列表,解释结果与解释分配应分开。

逐件展开的规模取决于总件数,而不只取决于订单行数。一行巨大数量就可能生成大量元素。若只需要总件数,直接折叠 quantity 更合适;先生成所有商品再取长度,虽然结果相同,却保存了并不需要的数据。优化首先来自避免无用工作,而不是换一个看起来更函数式的方法名。

集合的 flatMap 也能把缺失结果变成零项输出,但这会影响错误信息。解析外部订单时,若每个失败都变成空列表,最终列表不再包含出错位置和原因。只有明确允许跳过错误输入时,这种删除才符合契约;否则应保存 Either 或一份成功、失败分开的结果。

两层循环同样可以表达展开。外层逐行,内层按数量追加商品,顺序与 flatMap 一致。若改成并发运行内层工作,结果顺序和副作用时序就需要额外协议,不能从这个顺序列表例子推导并发实现自然等价。

foldLeft 的状态类型在每一步保持一致

1
2
3
4
def total(lines: List[Line]): Long =
lines.foldLeft(0L) { (sum, line) =>
sum + line.quantity * line.cents
}

初始状态为零分,第一步后是二百分,第二步五百分,第三步八百分。每次回调读取上一步状态与当前元素,返回下一步状态。空列表不调用回调,直接返回零。这两个条件可以合起来描述正确性:任意已处理前缀的累加值,等于该前缀所有行金额之和。

累加器与元素不必是同一种类型。这里元素是 Line,状态是 Long;同时统计数量和金额时,状态可以是一个 case class;按商品统计时,状态可以是 Map。把状态中每个字段的含义写清楚,通常就能推导每一步应如何更新。

初始值也不是随便挑的默认值。求和的零有单位元含义,求最小值若用零,就会错误地让全为正数的输入得到一个从未出现的最小值。需要同时表达空输入的最小值,可以让状态为 Option,而不是拿任意哨兵值冒充有效结果。边界类型选择会影响后续判断能否可靠。

隔离编译反例以 Int 零作为起点,回调却返回 String。编译器拒绝这种状态类型不一致,目标诊断要求包含实际字符串与需要整数。这个失败验证的是 foldLeft 的类型契约;它无法验证整数金额是否计算正确,也无法阻止合法类型中的错误公式。

分组累加可以沿前缀不变量推导

1
2
3
4
5
6
val grouped = lines.foldLeft(Map.empty[String, Int]) {
(acc, line) =>
acc.updated(line.product,
acc.getOrElse(line.product, 0) + line.quantity)
}
assert(grouped == Map("a" -> 5, "b" -> 1))

空前缀对应空 Map。处理每一行,只增加该商品的累计数量,其他键保持原值。这样处理任意前缀之后,每个键都对应这个前缀内同商品数量之和。三行之后,商品甲是二加三,商品乙是一。这个不变量同时解释正常输入与空输入,不依赖记住某个输出数字。

getOrElse 在这里补零合理,是因为尚未出现的商品在已处理前缀中贡献零。它与“缺失的外部数量字段默认为零”完全不同,后者可能掩盖数据错误。相同 API 的正确性取决于缺失含义,而不是某个方法是否被归类为好风格。

groupBy 之后分别汇总也能表达同样内容,但会保存组内行集合。只需要统计值时,直接折叠可以只保留聚合状态;需要后续明细时,则不应为了减少中间结构而丢弃原始行。最终类型是否足够,必须根据后续使用判断。

Map 内容相等不保证遍历顺序符合报表要求。本章不依赖打印 Map 的键顺序,而是断言内容。如果需要商品号排序,就在输出阶段显式排序;如果需要首次出现顺序,就另外保留对应信息。测试应分别写出内容与顺序要求,避免碰巧通过。

左右折叠改变的是括号位置

List(1,2,3) 以零开始减法,左折叠得到 ((0 - 1) - 2) - 3 = -6,右折叠得到 1 - (2 - (3 - 0)) = 2。正常程序同时断言这两个结果,直接展示非结合运算不能任意重排。

数学整数加法的结合律,不自动涵盖所有实际数值和副作用。浮点加法有舍入,重排结合方式可能改变低位结果;回调若读取外部状态,连相同参数是否返回相同值都需要重新检查。考虑并行归约之前,先明确运算的代数条件和实现边界。

foldLeft 规定从左侧逐步推进的语义,适合依赖前一步状态的过程。其他归约接口可能需要结合性条件。审阅时先用三项输入展开括号,比把所有操作泛称为“聚合”更容易发现错误。尤其函数参数中哪个是累加器、哪个是元素,方向改变后不能机械照抄。

右折叠的结果语义也不直接证明运行时栈形状。标准库可以用不同实现得到同一个括号结果。是否栈安全,应查具体版本或运行针对性测试,不能根据方法名字断言每个元素增加一个栈帧。本章只证明结果顺序,尾调用另章讨论。

collect 选择输入并计算结果

1
2
3
4
val large = lines.collect {
case Line(p, q, _) if q >= 2 => p -> q
}
assert(large == List("a" -> 2, "a" -> 3))

模式与守卫定义部分函数的输入范围。商品乙不满足数量门槛,因此不贡献输出。商品甲出现两次,collect 保留两个结果,不负责按键合并。若需要得到五,仍要聚合;若换成 Set,则又变成去重语义。每个方法承担一项明确的形状变化,组合之前不能遗漏步骤。

filter 后 map 常能表达同一个纯计算,collect 则能在一次描述里识别结构并绑定字段。两者对任意回调不一定可以机械互换,因为谓词重复、异常时点和副作用都有可能改变。这里仅用稳定字段,足以验证这份具体列表结果。

直接调用部分函数可能遇到未定义输入,collect 则根据其定义范围收集结果。若业务需要报告排除原因,仅返回筛选后的列表不够。应把失败与成功一起保存,或者返回单独的拒绝清单。筛选不是免费的信息压缩,调用方必须知道哪些数据被有意删除。

空输入和单元素揭示累加器设计

空列表能迅速检验初始值是否符合业务。金额汇总返回零是合理约定,平均单价却不能只返回零而不说明没有样本。如果平均值状态只保存总金额,最后还缺少除数;若同时保存金额和件数,就能在终点判断是否允许除法。好的状态设计把最后一步真正需要的信息保留下来。

单元素还能暴露累加器方向错误。假设把日志字符串接在已有日志之前,处理一项看不出逆序,处理两项就能看到顺序翻转。重复商品则能暴露覆盖与累加的区别:updated(product, quantity) 会丢掉此前数量,updated(product, old + quantity) 才表达累计。测试输入应针对这些不同错误,而不是只增加随机正常行。

本章用局部 Long 变量再写一个循环汇总,断言与 foldLeft 结果相同。局部可变状态没有逃逸,当前样例的对外结果一致。它没有证明所有溢出和异常场景等价,更没有建立吞吐排名;这两类问题需要各自的边界测试和测量。

将错误的位置保留到输出

批量订单处理常要求同时报告行号。若先把原始行变成金额,再过滤掉无效值,结果位置就不再等于原始行号。可以在处理开始时显式附加索引,使每个成功或失败结果都携带来源位置。这个索引表示输入序列中的位置,不是商品标识;同一商品的两条输入依然需要两个独立位置。

类似地,先排序再报告位置会改变位置含义。若错误消息要求指向原文件第几行,就应保留排序前的行号。集合变换不会自动保存被丢弃的信息,输入位置、原始字符串和规范化值是否需要共存,应该在输出类型中明确。这样后面的组合只需维护已经写明的字段,而不必试图根据最终金额反推原始输入。

这条方法也适用于分组:聚合总数之外,若还需要定位贡献该总数的订单行,可以让累加状态同时保存来源标识。额外字段会增加空间成本,但这是业务审计所需信息,不应作为“冗余”删除。先确定正确输出,再讨论更紧凑的表示,才能避免把优化做成信息丢失。

手算与修改练习

手算:第三行数量改成零,各行金额变成二百、三百、零,总额五百;逐件展开只剩 a、a、b;分组甲累计二、乙累计一。collect 排除第三行。若业务禁止零数量,应在汇总之前验证,不能用这些数学结果证明业务允许零。

修改练习:定义 Summary(totalCents: Long, pieces: Int),一次 foldLeft 同时得到八百分和六件。参考解从 Summary(0L,0) 开始,每行分别增加金额与数量;空输入应得到两个零。再用直接循环计算同样字段,断言相等。增加平均每件金额时,应明确整数除法舍入与零件数分支,不能只增加一个除号。

实验记录与依据

入口为 scalaexamples.Chapter12,源码在 examples/scala-lab/snippets/12/Chapter12.scala。命令、退出码与实际结果见 RUN.md。

前置阅读:Iterator、View 与 LazyList。

顺序导航:系列入口:00 · 上一篇:11 · 下一篇:13。