Scala 03:方法、函数与闭包
同一个价格策略为什么会算出不同结果
一件商品标价 200 分,九折后应付 180 分。若先创建价格策略,再把外部折扣从 10 改为 20,同一个策略调用应返回 180,还是 160?答案取决于函数体读取的是哪个绑定。把一段计算保存为函数值,并不会自动把它使用的全部数据保存成快照。
实验限定为 Scala 3.3.7、JDK 21,Scala CLI 1.9.1;没有第三方依赖。完整源码位于仓库的 examples/scala-lab/snippets/03/Chapter03.scala。实验状态为 LAB_VERIFIED:本章断言已在 Scala CLI 与 sbt 的批次运行中通过,输出与下文一致。证据位于仓库 examples/scala-lab/evidence/20261002-batch01/all.log、sbt.log,对应 JSON 保存实际命令与零退出码。运行方法见本文“实验入口”及附件 RUN.md。
这里使用小整数价格,专门观察函数语义。整数除法会截去小数,乘法也有溢出范围;这些代码不承担通用金额库的舍入规则和输入校验。把价格改成真实结算领域类型时,必须另行决定币种、精度和非法折扣的处理方式。
方法定义如何转换为函数值
方法用 def 定义。函数值能够绑定到变量、作为实参传递,也能够成为返回值。先保留一个普通方法,再把它赋给具有明确函数类型的绑定:
1 | |
discounted 有两个参数和一个结果;methodValue 的类型则是接收两个 Int、产生一个 Int 的函数类型。编译器在这个赋值位置进行 eta expansion,把方法用作函数值。Scala 3 对带参数的方法支持自动转换,常见场景无需沿用 Scala 2 教程中的 discounted _ 写法。Scala 3 Reference:Automatic Eta Expansion
转换本身不等于执行价格计算。函数值建立后,只有调用 methodValue(200, 10),才提供本次计算的参数。判断一段代码何时发生副作用,要继续查看函数体以及捕获表达式的求值位置,不能仅凭出现一个 val 就断言业务计算已经完成。
匿名函数把参数和函数体直接写在表达式里:
1 | |
此处 price 的类型由左侧 Int => Int 提供。若把这段表达式单独拿出来,写成 (price: Int) => discounted(price, 20) 就能明确输入类型。类型推导利用上下文,不是任意省略类型后都能得到唯一答案。Scala 3 Book:Anonymous Functions
函数值有调用操作。对这个例子,twentyPercent(200) 与 twentyPercent.apply(200) 表达相同的调用意图。它也可以放进 List[Int => Int],或传给接收 Int => Int 的方法。方法提供命名计算,函数值让计算进入普通的数据传递路径。Scala 3 Book:Function Variables
无参数计算最好明确写出延后调用的位置:val action: () => Int = () => next()。这与 val result = next() 不同,前者保存一个需要随后调用的函数,后者立即取得结果。不要把带参数方法的自动转换规则机械地推广到所有无参方法、重载方法和所有期待类型。含重载的 API 若出现歧义,先写出完整 lambda 与参数类型,通常比反复增加占位符更容易定位选择的是哪个方法。
可迁移的方法是先写清输入输出,再决定保存的是“一个结果”还是“取得结果的计算”。定价回调、排序比较器与校验谓词都能按这个顺序分析。函数类型只约束调用形状;它没有声明函数不读文件、不修改对象或不抛异常。
多参数列表、柯里化和偏应用
同一个公式可以改成两个参数列表:
1 | |
定义中的括号说明方法的参数分组。先提供 percent 后,还缺一个 price,因此能够取得一元函数值。在读价格策略的代码时,priceWith(10) 可以解释为“固定折扣,保留价格输入”。这种分组也适合先提供配置,随后多次提供业务数据。Tour of Scala:Multiple Parameter Lists
柯里化通常指把接收多个参数的函数,转换成逐层返回函数的形式。已有二元函数值可以调用 curried:
1 | |
箭头向右结合,因此这里是 Int => (Int => Int)。先调用 curried(200) 得到另一个函数,再传入 10 才取得结果。注意原函数的参数顺序是价格、折扣;curried 保留这个顺序。它不会根据名称猜测“折扣更适合放前面”,也不会自动与 priceWith 的分组保持一致。
偏应用(partial application)是在某次使用中固定部分参数,留下更少的参数。柯里化描述函数形状的转换,偏应用描述提供了多少实参。两者经常一起出现,但用显式 lambda 同样可以偏应用一个未柯里化的方法,前面的 twentyPercent 就属于这种情况。
这个区别影响 API 阅读。看到 Int => Int => Int,能够推断结果分两次取得;看到 price => discounted(price, 20),能够知道折扣已经选定。仅看到“柯里化”一词,则不知道哪一组配置被固定、固定发生在什么时候,更不知道内部是否读取共享状态。若配置将随请求变化,就应保留为显式参数;若需要把配置随策略保存下来,就应审查捕获的数据是否真的稳定。
| 表达式 | 类型或形状 | 被固定的参数 |
|---|---|---|
discounted(200, 10) |
Int 结果 |
全部已提供 |
methodValue |
(Int, Int) => Int |
无 |
methodValue.curried |
Int => Int => Int |
无 |
priceWith(10) |
Int => Int |
折扣 |
methodValue.curried(200) |
Int => Int |
价格 |
PartialFunction 的“部分”指输入定义域
PartialFunction[A, B] 的部分性针对输入集合。它通过 isDefinedAt 描述哪些输入由该函数处理。偏应用减少参数个数,PartialFunction 则保留一个输入类型,并声明只处理其中一部分值。两个概念没有因中文都含“偏”字而形成等价关系。Scala 标准库:PartialFunction
1 | |
这个模式字面量仅覆盖偶数。直接执行 halfEven(3) 会走未匹配路径,完整实验捕获 MatchError 并断言确实发生过。结果不是零,也不会自动跳过调用。跳过未覆盖元素的是 collect 的使用方式;lift 则把未覆盖情况编码成 None。这三种调用方式必须分开推导。
这里得到 Some(2) 与 None 的好处是调用者无需借助异常判断奇偶输入是否被接受。不过,lift 并不承诺捕获函数体内的任意异常。若某个已匹配分支执行除零、访问越界或主动抛错,仍要按该分支的实现分析。把“输入没有匹配项”改成可选结果,不等于把所有失败都变成可选结果。
普通函数也可能对某些输入抛异常。例如 value => 100 / value 在零输入时失败,但它的类型仍然可以是 Int => Int,并不会自动获得 isDefinedAt。反例目录中的代码就是对普通函数调用这个不存在的成员,编译应失败。函数类型并不是数学上总函数的证明。
为输入分支选择表示方式时,应从调用方需要得到什么信息开始。如果处理链要跳过不适用的输入,模式匹配加 collect 很直接;如果调用方必须逐项保留成功或缺失,Int => Option[Int] 更明确;如果失败还要携带原因,需要进一步引入有错误内容的结果类型。不能靠把类型换成 PartialFunction 来省掉错误协议。
闭包保存了什么
匿名函数体可以使用其参数以外的绑定。函数值连同所需的外部环境形成闭包。对可变绑定进行捕获时,观察重点是后续调用会读到哪个状态,而不是创建函数那一刻变量曾经等于多少。
1 | |
顺序执行时,第一次数值计算读取 percent = 10,随后赋值改为 20,第二次调用再次读取同一可变绑定。因此 live 的名字没有重新赋值,结果却改变了。函数值由 val 持有,只表示这个绑定不能改指向另一个函数;它没有冻结函数读取的环境。
frozenPercent 在函数创建前取得一个整数值。之后修改 percent,不会改动这个整数绑定,故 snapshot(200) 仍为 180。这里能形成快照,依赖的是复制出来的内容本身具有稳定值语义。若捕获的 val 指向 ArrayBuffer,只是保存同一个缓冲区引用,缓冲区元素仍可能随后变化。
1 | |
这个例子的顺序关系只覆盖单线程执行。把 live 放进异步回调,不会自动建立写入与读取之间的可见性保证,也不会把多步修改变成原子操作。不要用一次运行碰巧返回 160,证明线程间共享变量已经安全。并发实验需要独立的同步条件,后续异步章节再处理。
闭包审查可以画成一张“函数到数据”的引用图:函数读哪些外部绑定,绑定能否重新赋值,绑定引用的对象能否修改,谁还持有这些对象。对于批处理任务,若每条任务应固定提交时的配置,就把不可变配置值显式放进任务数据;若每次都应读取最新配置,就把这一读取放进明确的方法调用,并为并发访问建立相应协议。
return 的目标不随 lambda 改变
下面的旧式写法试图找出第一个正数:
1 | |
return 位于传给 foreach 的匿名函数内,却以外围命名方法为返回目标,构成非局部返回。它不能按 JavaScript lambda 或其他语言闭包中的局部返回来理解,也不是“让这轮 foreach 提前结束”。Scala 官方将从嵌套匿名函数返回的形式标为自 3.2.0 起弃用,并解释了异常式实现及被宽泛捕获干扰的问题。Scala 3 Reference:Nonlocal Returns
这个弃用边界限定在非局部返回;不能据此声称 Scala 禁止所有 return,或说当前源码只要出现这个单词就一定编译失败。本实验在独立反例目录启用 -Werror,把诊断升级为编译失败,借此锁定维护策略。实际验收需同时检查非零退出码和有关 non-local return 的诊断;只有下载失败或普通语法错误,不能算该反例通过。
查找首个满足条件的元素,标准集合操作已经能够表示:
1 | |
结果类型现在区分“找到 7”和“没有正数”。原来的 -1 哨兵值需要额外的业务约定,而 Option 在签名中暴露缺失。将“是否停止查找”交给查找操作,也避免让一个匿名函数要求外围方法退出。
若问题是逐步汇总数据,则应选择折叠、递归或普通循环,使每一步产生下一步需要的状态;若确实需要受控提前退出,再查阅显式边界操作。选择依据是控制流需求,不是为了彻底消灭某个关键字。涉及资源关闭、异常处理或异步回调时,还必须检查返回目标是否仍然存在,以及退出会经过哪些清理路径。
实验入口与失败判定
从仓库根目录运行:
1 | |
第一条应以零退出码完成;本章在批次 Scala CLI 与 sbt 运行中的实测输出为:
1 | |
第二条应因普通函数没有 isDefinedAt 成员而失败。第三条应因非局部返回诊断被 -Werror 升级而失败。正例中的未定义输入调用是捕获后断言的运行时反例,整体程序仍应成功。三个场景不能仅用同一个“编译失败”标签替代。
两个编译反例均已返回退出码 1,且分别出现目标成员错误与非局部返回诊断。原始记录为 examples/scala-lab/evidence/20261002-batch01/negative-03-function-is-not-partial.log 和 negative-03-nonlocal-return.log,同名 JSON 保存命令、退出码与诊断匹配式。后一项启用了 -Werror,观察到的是警告提升为失败。
--jvm 21 表达主版本要求;本批实际使用 Amazon Corretto 21.0.11,并通过 --jvm system 选中冻结的 JDK。更换工具、JDK 或 Scala 版本后,应保留旧记录并重新执行,不把本批输出冒充新环境的实测结果。
练习与解答
手算题:把闭包实验中的赋值顺序改成“创建 live,修改 percent = 20,再创建 frozenPercent 和 snapshot”。两者随后对 200 的第一次调用分别是多少?
答案都是 160。live 在调用时读取 20,frozenPercent 在建立时已经取得 20。区别依然存在,只是这组时序没有把它显露出来。若继续把 percent 改成 30,二者才分别产生 140 与 160。设计测试时需要安排能区分两种假说的状态变更,不能只测创建后的第一次调用。
修改题:把 halfEven 改成仅处理大于零的偶数,并对输入 List(-2, 0, 2, 3, 4) 保留逐项结果。不要丢弃缺失项。
一种解答是把守卫改为 value > 0 && value % 2 == 0,然后运行 input.map(halfEven.lift),预期结果为 List(None, None, Some(1), None, Some(2))。若使用 collect,只能得到 List(1, 2),与题目要求的逐项对应关系不同。补充断言时应同时检查结果长度和每个位置,避免“成功值正确”掩盖“原始位置丢失”。
| 遇到的需求 | 先检查什么 | 可迁移做法 |
|---|---|---|
| 保存一个回调 | 保存结果还是计算 | 用完整函数类型标记边界 |
| 固定部分配置 | 剩余参数与求值时间 | 显式 lambda 或多参数列表 |
| 部分输入不适用 | 缺失要跳过还是保留 | collect 或 lift |
| 回调结果随时间变化 | 捕获绑定与可变对象 | 画引用图,建立不可变快照 |
| 匿名函数需要提前退出 | 返回目标与业务结果 | 查找、折叠或明确的控制边界 |
资料与系列导航
官方资料分别支持方法转换、参数分组、偏函数协议和非局部返回边界;表格中的数值来自本文示例的逐步推导,实际运行状态以实验记录为准。方法转换与非局部返回文档固定到 Scala 3.3.7 的源码提交;其他在线教程属于滚动文档,不能代替该版本的编译结果。标准库 API 链接固定到 2.13.16,实际解析版本以系列版本表为准。
前篇:Scala 02:求值次数与状态。后篇:Scala 04:类、对象与值相等。实验基线:Scala 00:工具链与可复现实验。
