两个生成器,是四个结果还是一个结果

数量列表为一、二、三,单价候选为一百分、二百分。过滤掉数量一之后,每个数量都与两种单价组合,得到二百、四百、三百、六百。如果把数量换成 Option(2),同样的 for 外形却只得到一个可选金额;换成 Left(“quantity”),后面的价格获取甚至不会执行。

for 没有给这些容器强加同一种运行策略。编译器把语法转换成它们支持的方法调用,具体集合或容器决定调用函数几次、怎样组合结果、失败是否短路。因此,理解 for 的入口应该是生成器的类型和所需方法,而不是把所有箭头都解释成普通循环。

本章冻结 Scala 3.3.7、标准库 2.13.16 和 JDK 21。for 的转换规则会随语言版本演进,本文以该版本的 Desugar.makeFor 源码和实际程序为准,不把后续 Better fors 的优化套进旧版本。正文展示的是各例的等价调用,不宣称存在适用于所有模式、绑定与版本的单行模板。

单个生成器把值交给 map

1
2
val amounts = for q <- List(1, 2, 3) yield q * 100
val expanded = List(1, 2, 3).map(q => q * 100)

生成器右侧产生一个支持 map 的对象,左侧绑定当前元素,yield 产生结果表达式。这里 map 接收 Int => Int,因此结果仍是整数列表。把右侧换成 Option,map 就按有值或无值的规则工作;不是编译器额外写了“如果是 Option 就检查 None”的专门业务逻辑。

yield 与 do 的目的不同。yield 收集返回值,do 用于执行操作,对应的方法形状也不同。不能把打印日志放在 yield 中,就假设只执行一次;如果 List 有三个元素,这个结果计算会针对三个元素执行。需要统计实际执行次数时,必须沿接收者的遍历行为分析。

生成器左侧也不总是简单变量。元组、类型模式或其他解构会引入额外匹配条件,其翻译和检查规则要单独分析。先掌握简单变量的 map 形状,再增加守卫与模式,才能看清每个额外语法究竟增加了什么工作。

第二个生成器位于第一个回调内部

1
2
3
4
val result = for
q <- List(2, 3)
p <- List(100, 200)
yield q * p

这个例子可以展开为 List(2,3).flatMap(q => List(100,200).map(p => q*p))。外层每取得一个 q,内层就产生对应的一组金额,再把各组接起来。结果顺序是先完成 q 等于二时的两个价格,再处理 q 等于三,因而是二百、四百、三百、六百。

第二个生成器可以依赖第一个绑定。例如按数量选择批发价格,就是在 q 可见的回调里计算价格来源。它也可能包含副作用;这时副作用会对每个进入该回调的 q 执行,而不是在整个 for 开始时只执行一次。本章用 inner 计数器观察这一点。

如果价格来源昂贵但与 q 无关,可以事先计算并保存结果,再在内层复用。但这会改变求值时点:原来外层为空时不会计算价格,提前保存则会在判断外层结果之前执行。提取公共表达式不是无条件安全的优化,尤其是外部请求、异常和可变来源。

多层 List 生成器产生组合结果,并不自动执行笛卡尔积的去重。如果数量或单价列表包含重复值,重复结果也会保留。若业务只允许唯一报价,应明确唯一性的定义,不能通过“for 看起来像查询”推导数据库式的隐含约束。

守卫需要 withFilter,并受容器接口限制

1
2
3
4
5
6
7
8
var tests = 0
var inner = 0
val result = for
q <- List(1, 2, 3)
if { tests += 1; q >= 2 }
p <- { inner += 1; List(100, 200) }
yield q * p
assert(tests == 3 && inner == 2)

守卫对应当前生成器的 withFilter。数量一被排除后,不进入后续价格生成器;数量二和三分别进入一次,所以谓词三次、价格来源两次。结果断言验证值,计数断言验证控制流,两者结合才排除“提前计算价格后再丢弃”的不同实现。

withFilter 与 filter 的相同之处是筛选条件,不同容器对求值和中间对象的安排可能不同。不能只因为教学展开常写筛选,就把所有 withFilter 调用机械替换成严格 filter。对于带副作用的谓词,重复消费和具体操作还可能影响执行次数。

标准库 Either 提供右偏 map、flatMap,但没有这里需要的 withFilter。因此,在 Either 的生成器后直接写 if,冻结版本会因为缺少该成员而编译失败。语言没有凭空替它发明一个 Left 值,因为布尔条件失败究竟对应哪个错误,是业务必须决定的信息。

正确修复通常是把条件写成返回 Either 的步骤,例如大于零时 Right(n),否则 Left(“quantity”),再用生成器组合。这让错误通道明确,而不是利用 filter 失败时的隐式异常。Option 可以用 None 表示条件不满足,但如果需要知道原因,Option 的这种简洁就不足以完成契约。

Option 和 Either 保留各自的组合行为

1
2
3
4
5
6
7
8
9
val optional = for
q <- Option(2)
p <- Option(100)
yield q * p

val absent = for
q <- Option.empty[Int]
p <- Option(100)
yield q * p

第一段得到 Some(200),第二段得到 None。与 List 的多结果不同,Option 最多包含一个值;外层缺失时,flatMap 不调用后续函数。若第二个生成器会修改变量,是否执行仍取决于它是否放在这个回调内部。

Either 的失败同理沿链保留。本章把外层设为 Left(“quantity”),后续价格步骤内放计数器,最终断言错误仍是同一个业务字符串且计数为零。这证明当前组合不会执行依赖成功数量的价格步骤,但不证明业务可以在任意失败时跳过所有独立检查。

for 也不会自动混合不相容的容器。一个生成器使用 Either,另一个返回 Option,需要在明确位置转换并指定缺失错误。语法看起来整齐,不会消除类型之间的差别。若为编译通过而把所有失败都转成 None,可能让结果丢失调用方必需的原因。

这个视角也解释了为什么 for 不等于自动并行。嵌套 flatMap 表达后一步可依赖前一步结果;是否并发、任务是否已经启动,取决于右侧对象和创建位置。当前章节只运行同步标准库容器,不把此处的短路次数推广到已经启动的异步任务。

显式 case 表达有意过滤可失败模式

1
2
3
4
val mixed: List[Any] = List(("a", 2), "bad", ("b", 1))
val pairs =
for case (name: String, q: Int) <- mixed
yield name -> q

结果包含两对数据,中间字符串被过滤。case 告诉语法这里有意使用可能不匹配的生成器模式。输入类型为 Any 时,不能静态保证每项都是所需元组,因此失败路径属于操作本身。

不加 case,直接写 for (a,b) <- values,在本章冻结版本会对可失败模式给出警告。隔离反例使用 -Werror 将它提升成构建失败。不能把较旧版本“生成器模式默认过滤”的印象直接套入当前规则,也不能靠忽略警告把过滤意图藏起来。

类型模式中的 String 与 Int 是对实际元素的可检查条件;这不同于检查 List[String] 的完整泛型参数。若模式继续嵌套泛型类型,仍要检查擦除警告。模式放入 for 不会让运行时恢复已擦除的信息,上一篇的边界仍然适用。

模式过滤会丢弃输入。如果 “bad” 代表损坏订单,静默跳过可能不符合业务。可改成每项产生 Either,并保留输入索引,然后决定整批停止还是收集错误。语言能够表达过滤,不意味着过滤一定是正确的错误策略。

中间绑定也参与版本化降级

for 中还可以写 subtotal = q * p 这样的中间绑定。它不是独立的新来源,而是在已有绑定基础上计算值,并让后续语句使用。冻结编译器需要在生成的方法调用中传递这些局部结果,源码中的 GenAlias 分支可看到相应组织。

这类语法说明“每个箭头一个 flatMap,最后一个 map”只能作为简单案例的入门记忆。守卫会增加 withFilter,模式会增加检查,中间绑定会影响传递结构,无 yield 的形式则使用 foreach。实际排错应该查看出错成员和推导类型,再核对该版本的转换。

后续 Better fors 规则会改变部分中间绑定和冗余 map 的处理。本文不根据滚动网页改写 3.3.7 的实验,也不把新版优化当作普通手工简化的普遍证明。如果升级后依赖回调次数或自定义容器副作用,应重跑针对性验证,而不是只确认最终值相等。

源码阅读也应有界限。Desugar.makeFor 能解释编译器生成怎样的方法调用,但这些方法的实际求值策略仍来自接收者实现。只看编译器源码无法证明某个用户自定义 map 严格、纯粹或符合组合定律,两层证据必须连接起来。

从报错位置反推缺少的协议

遇到推导式不能编译,可以先把错误定位到三个问题。生成器右侧实际推断出了什么类型;当前这一步需要哪个成员;该成员回调返回的类型能否与下一步衔接。例如单个生成器也报缺少 map,说明当前对象连最基本的结果变换接口都不具备;只有增加第二个生成器后报错,则应该检查 flatMap 的回调结果要求;增加守卫后才失败,优先查看 withFilter,而不是猜测 yield 写法。

这些诊断也可以发现过窄的局部类型。如果把成功值直接推断为某个具体子类,后续组合需要更一般的容器类型时,错误信息可能出现在远离定义的位置。给生成器来源加上业务接口期望的显式类型,能够把约束提前暴露。这个修复不是随意扩大所有类型,而是让读者和编译器都看到实际承诺。

自定义容器只要提供合适形状的方法,就可能参与推导式,但编译器不会因此验证它满足数学定律。一个 map 完全可以调用回调两次,再丢掉第一次结果;这种设计或许能通过类型检查,却会破坏使用者对组合的预期。标准容器的行为、定律测试与语言翻译因此各自承担一层责任。

排错时保留一个只有两个生成器的小程序很有帮助。移去业务网络请求,换成固定返回值;随后逐个加入守卫、中间绑定和模式,每次记录目标诊断。这样能分辨失败来自容器协议还是外部代码。若一口气改掉来源类型、错误模型和语法形式,即使编译成功,也很难知道究竟修复了哪个约束。

手算与修改练习

手算:把守卫移动到价格生成器之后,条件仍然只检查 q,谓词执行几次?答案是六次,因为三种数量各自进入两种价格,之后才筛选;内层来源变为三次。最终金额仍可能相同,但执行次数不同。这个差异说明移动守卫可能改变成本和副作用。

修改练习:为 Either 数量添加显式正数校验,不使用 if 守卫。参考解是在解析结果之后增加 valid <- if q > 0 then Right(q) else Left("non-positive"),yield 使用 valid。新增零数量与正数量断言,检查错误保留且价格回调在失败时不执行。再把 List 的守卫移动位置,保存实际谓词计数,验证上述手算。

实验记录与依据

源码为 examples/scala-lab/snippets/13/Chapter13.scala,入口 scalaexamples.Chapter13。隔离目录分别验证 Either 缺 withFilter 与可失败生成器模式,命令及输出见 RUN.md。

前置阅读:map、flatMap 与 fold。

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