同一个价格策略为什么会算出不同结果

一件商品标价 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
2
3
4
5
def discounted(price: Int, percent: Int): Int =
price * (100 - percent) / 100

val methodValue: (Int, Int) => Int = discounted
assert(methodValue(200, 10) == 180)

discounted 有两个参数和一个结果;methodValue 的类型则是接收两个 Int、产生一个 Int 的函数类型。编译器在这个赋值位置进行 eta expansion,把方法用作函数值。Scala 3 对带参数的方法支持自动转换,常见场景无需沿用 Scala 2 教程中的 discounted _ 写法。Scala 3 Reference:Automatic Eta Expansion

转换本身不等于执行价格计算。函数值建立后,只有调用 methodValue(200, 10),才提供本次计算的参数。判断一段代码何时发生副作用,要继续查看函数体以及捕获表达式的求值位置,不能仅凭出现一个 val 就断言业务计算已经完成。

匿名函数把参数和函数体直接写在表达式里:

1
2
val twentyPercent: Int => Int =
price => discounted(price, 20)

此处 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
2
3
4
5
def priceWith(percent: Int)(price: Int): Int =
discounted(price, percent)

val tenPercent: Int => Int = priceWith(10)
assert(tenPercent(200) == 180)

定义中的括号说明方法的参数分组。先提供 percent 后,还缺一个 price,因此能够取得一元函数值。在读价格策略的代码时,priceWith(10) 可以解释为“固定折扣,保留价格输入”。这种分组也适合先提供配置,随后多次提供业务数据。Tour of Scala:Multiple Parameter Lists

柯里化通常指把接收多个参数的函数,转换成逐层返回函数的形式。已有二元函数值可以调用 curried:

1
2
val curried: Int => Int => Int = methodValue.curried
assert(curried(200)(10) == 180)

箭头向右结合,因此这里是 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
2
3
4
5
6
7
8
val halfEven: PartialFunction[Int, Int] =
case value if value % 2 == 0 => value / 2

assert(halfEven.isDefinedAt(4))
assert(!halfEven.isDefinedAt(3))
assert(halfEven.lift(4).contains(2))
assert(halfEven.lift(3).isEmpty)
assert(List(1, 2, 3, 4).collect(halfEven) == List(1, 2))

这个模式字面量仅覆盖偶数。直接执行 halfEven(3) 会走未匹配路径,完整实验捕获 MatchError 并断言确实发生过。结果不是零,也不会自动跳过调用。跳过未覆盖元素的是 collect 的使用方式;lift 则把未覆盖情况编码成 None。这三种调用方式必须分开推导。

这里得到 Some(2) 与 None 的好处是调用者无需借助异常判断奇偶输入是否被接受。不过,lift 并不承诺捕获函数体内的任意异常。若某个已匹配分支执行除零、访问越界或主动抛错,仍要按该分支的实现分析。把“输入没有匹配项”改成可选结果,不等于把所有失败都变成可选结果。

普通函数也可能对某些输入抛异常。例如 value => 100 / value 在零输入时失败,但它的类型仍然可以是 Int => Int,并不会自动获得 isDefinedAt。反例目录中的代码就是对普通函数调用这个不存在的成员,编译应失败。函数类型并不是数学上总函数的证明。

为输入分支选择表示方式时,应从调用方需要得到什么信息开始。如果处理链要跳过不适用的输入,模式匹配加 collect 很直接;如果调用方必须逐项保留成功或缺失,Int => Option[Int] 更明确;如果失败还要携带原因,需要进一步引入有错误内容的结果类型。不能靠把类型换成 PartialFunction 来省掉错误协议。

闭包保存了什么

匿名函数体可以使用其参数以外的绑定。函数值连同所需的外部环境形成闭包。对可变绑定进行捕获时,观察重点是后续调用会读到哪个状态,而不是创建函数那一刻变量曾经等于多少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var percent = 10
val live: Int => Int = price => discounted(price, percent)

val frozenPercent = percent
val snapshot: Int => Int =
price => discounted(price, frozenPercent)

val before = live(200)
percent = 20
val after = live(200)

assert(before == 180)
assert(after == 160)
assert(snapshot(200) == 180)

顺序执行时,第一次数值计算读取 percent = 10,随后赋值改为 20,第二次调用再次读取同一可变绑定。因此 live 的名字没有重新赋值,结果却改变了。函数值由 val 持有,只表示这个绑定不能改指向另一个函数;它没有冻结函数读取的环境。

frozenPercent 在函数创建前取得一个整数值。之后修改 percent,不会改动这个整数绑定,故 snapshot(200) 仍为 180。这里能形成快照,依赖的是复制出来的内容本身具有稳定值语义。若捕获的 val 指向 ArrayBuffer,只是保存同一个缓冲区引用,缓冲区元素仍可能随后变化。

1
2
3
4
5
6
创建 live ── 捕获 percent 的后续读取
创建 snapshot ── 使用 frozenPercent = 10
调用 live(200) ── percent = 10 ── 180
percent = 20
调用 live(200) ── percent = 20 ── 160
调用 snapshot(200) ── frozenPercent = 10 ── 180

这个例子的顺序关系只覆盖单线程执行。把 live 放进异步回调,不会自动建立写入与读取之间的可见性保证,也不会把多步修改变成原子操作。不要用一次运行碰巧返回 160,证明线程间共享变量已经安全。并发实验需要独立的同步条件,后续异步章节再处理。

闭包审查可以画成一张“函数到数据”的引用图:函数读哪些外部绑定,绑定能否重新赋值,绑定引用的对象能否修改,谁还持有这些对象。对于批处理任务,若每条任务应固定提交时的配置,就把不可变配置值显式放进任务数据;若每次都应读取最新配置,就把这一读取放进明确的方法调用,并为并发访问建立相应协议。

return 的目标不随 lambda 改变

下面的旧式写法试图找出第一个正数:

1
2
3
4
5
def firstPositive(values: List[Int]): Int =
values.foreach { value =>
if value > 0 then return value
}
-1

return 位于传给 foreach 的匿名函数内,却以外围命名方法为返回目标,构成非局部返回。它不能按 JavaScript lambda 或其他语言闭包中的局部返回来理解,也不是“让这轮 foreach 提前结束”。Scala 官方将从嵌套匿名函数返回的形式标为自 3.2.0 起弃用,并解释了异常式实现及被宽泛捕获干扰的问题。Scala 3 Reference:Nonlocal Returns

这个弃用边界限定在非局部返回;不能据此声称 Scala 禁止所有 return,或说当前源码只要出现这个单词就一定编译失败。本实验在独立反例目录启用 -Werror,把诊断升级为编译失败,借此锁定维护策略。实际验收需同时检查非零退出码和有关 non-local return 的诊断;只有下载失败或普通语法错误,不能算该反例通过。

查找首个满足条件的元素,标准集合操作已经能够表示:

1
2
3
4
5
def firstPositive(values: List[Int]): Option[Int] =
values.find(_ > 0)

assert(firstPositive(List(-2, 0, 7, 9)).contains(7))
assert(firstPositive(List(-2, 0)).isEmpty)

结果类型现在区分“找到 7”和“没有正数”。原来的 -1 哨兵值需要额外的业务约定,而 Option 在签名中暴露缺失。将“是否停止查找”交给查找操作,也避免让一个匿名函数要求外围方法退出。

若问题是逐步汇总数据,则应选择折叠、递归或普通循环,使每一步产生下一步需要的状态;若确实需要受控提前退出,再查阅显式边界操作。选择依据是控制流需求,不是为了彻底消灭某个关键字。涉及资源关闭、异常处理或异步回调时,还必须检查返回目标是否仍然存在,以及退出会经过哪些清理路径。

实验入口与失败判定

从仓库根目录运行:

1
2
3
scala-cli run examples/scala-lab/snippets/03/Chapter03.scala --scala 3.3.7 --jvm 21 --server=false --main-class scalaexamples.Chapter03
scala-cli compile examples/scala-lab/negative/03/function-is-not-partial/Example.scala --scala 3.3.7 --jvm 21 --server=false
scala-cli compile examples/scala-lab/negative/03/nonlocal-return/Example.scala --scala 3.3.7 --jvm 21 --server=false --scalac-option -Werror

第一条应以零退出码完成;本章在批次 Scala CLI 与 sbt 运行中的实测输出为:

1
2
3
4
pricing=180,160,180
capture=180,160,180
partial=Some(2),None;collect=1,2;undefined=MatchError
firstPositive=Some(7),None

第二条应因普通函数没有 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:工具链与可复现实验。

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