保存一份报价计算,费率什么时候才需要出现

订单金额已经确定,但费率由稍后的活动选择决定。可以保存 Rate => Int 普通函数,执行时显式传费率;也可以保存 Rate ?=> Int 上下文函数,让执行点像调用 using 方法一样补全参数。两者都表达“还需要一个 Rate 才能得到结果”,差别在参数传递形式。

这个场景里有三个时间点:普通工厂创建计算方案,上下文函数真正计算费用,某个闭包提前捕获了特定费率。如果只看最终数字,可能分不清费率来自创建点还是执行点,也可能把尚未执行的函数误当作已经完成的报价。

本文固定 Scala 3.3.7、标准库 2.13.16 和 JDK 21,用普通 Plan 工厂中的计数观察方案构造,用计算体中的独立计数观察执行,再与提前捕获费率的普通闭包对照。计数放在真正边界内,不由调用者手工增加一个数字后拿它自证发生了构造。

问号箭头描述需要上下文参数的函数

1
2
case class Rate(percent: Int)
type Pricing = Rate ?=> Int

Pricing 是一种函数类型。它需要一个 Rate 上下文参数,计算后产生 Int。这里没有定义一个全局费率,也没有立刻执行金额计算;它只给待保存的计算一个精确类型。

普通函数使用 Rate => Int,调用时写普通实参;上下文函数可以显式写 pricing(using rate),也可以在有适用 given 的位置省略实参。省略并不意味着没有参数,仍然由编译器按上下文搜索规则补上 Rate。

该类型不承诺纯函数。计算体可以读取外部可变状态、修改计数器或抛异常,本章计数正是有意加入的观察装置。也不承诺记忆化:同一个上下文函数调用两次,通常就执行两次函数体,除非实现另外加入缓存。

把结果类型写成 Int 也没有业务校验含义。费率是否合法、金额是否溢出、四舍五入规则是什么,仍由方法实现负责。上下文函数把依赖留在类型里,不能自动让那份依赖正确。

期望类型可以让表达式延后到函数体中

1
2
3
4
var evaluated = 0
def make(cents: Int): Pricing =
evaluated += 1
cents * summon[Rate].percent / 100

make 的返回类型期望一个上下文函数。根据冻结规则,适当表达式会被转换成带上下文参数的函数体,使 Rate 在体内可用。因此计数器增加与费用计算属于之后的调用阶段,不能看到 def 被引用就断言内部已经执行。

为了让两个时间点更清楚,本章用普通工厂包装函数:

1
2
3
4
5
final class Plan(val run: Pricing)
var built = 0
def build(cents: Int): Plan =
built += 1
new Plan((rate: Rate) ?=> make(cents)(using rate))

build 返回普通 Plan,所以工厂体里的 built 在实际调用工厂时增加。Plan 内保存显式上下文函数字面量。调用 build(1000) 后,构造计数为一,计算计数仍为零。这两个计数来自不同函数体,能够区分“创建方案”与“执行费用计算”。

显式字面量把依赖传递关系写得更完整:执行时得到 rate,再把它显式交给 make 产生的计算。这样即使后续增加更复杂上下文,也能看见究竟传递了哪一个实例,避免单靠自动搜索猜测时间边界。

保存函数与需要结果会触发不同动作

1
2
3
4
5
6
val plan = build(1000)
val pricing: Pricing = plan.run
assert(built == 1 && evaluated == 0)
assert(pricing(using Rate(10)) == 100)
assert(pricing(using Rate(20)) == 200)
assert(evaluated == 2)

pricing 的显式类型仍然是上下文函数,赋值保留待执行计算。随后两次显式应用分别传入百分之十和二十,得到一百和二百,计算体各执行一次。对象是同一份保存的计算,参数却可以不同,所以它不是创建时就固定结果的缓存。

如果当前位置期望 Int,使用 pricing 就需要应用这份上下文函数,编译器会寻找 Rate。当前没有适用实例时,不能得到 Int;隔离反例正是声明 val bad: Int = pricing 并要求缺少 Rate 的诊断。

因此,类型标注对读者很重要。省略标注时,周围期望类型可能影响表达式如何被适配或应用,复杂代码会让执行点不明显。涉及昂贵工作或副作用的边界,可以显式保存函数类型、显式 using 调用,把时点写清楚。

这个原则不仅适用于局部 val。把上下文函数作为参数传给另一方法时,接收方法是在体内立即使用、重复使用,还是保存到对象字段,会改变求值次数。参数类型说明需要什么环境,方法实现决定什么时候应用。

显式参数版本提供独立对照

1
2
def explicit(cents: Int)(rate: Rate): Int =
cents * rate.percent / 100

正常程序比较显式参数计算与上下文函数在相同金额、相同费率下的结果。这个对照不依赖自动候选搜索,能帮助确认费用公式本身没有因传递形式变化而改变。

显式写法更容易在单个调用点发现依赖,上下文写法则能减少一连串辅助函数重复书写相同环境参数。选择应根据调用链规模和可读性,不能因为上下文函数更短就默认所有依赖都应省略。

当不同步骤需要不同费率时,显式参数可能更能表达业务选择。若整段计算共享一份稳定环境,上下文函数可以把这份依赖保留为整体计算的输入。关键不是问号箭头本身,而是依赖在何处取得、是否可变,以及由谁决定。

算法测试也可以先用显式参数版本建立预期,再验证上下文包装和搜索是否正确。这样出错时容易定位为公式错误、传递错误或实例选择错误,而不是把所有失败都归为“上下文行为不明”。

提前捕获的普通闭包不会改用后来实例

1
2
def capture(cents: Int)(using rate: Rate): () => Int =
() => explicit(cents)(rate)

capture 在调用时已经收到 rate,返回的普通零参数函数把它保留下来。用 Rate(10) 创建 captured 后,即使之后的作用域引入 Rate(20),captured() 仍用原来的费率,得到一百。它没有声明还要搜索 Rate 的参数,因此后来的 given 不会替换已捕获引用。

同时,保存的 pricing 仍需要一个 Rate。此时在有 Rate(20) 的位置把它用作结果,就得到二百。这两个值并不矛盾:一个闭包在创建时选定依赖,另一个上下文函数把依赖推迟到应用时。

“捕获”也不等于深复制。如果 rate 是可变对象,闭包捕获其引用后,外部修改字段仍可能改变稍后结果。本章 Rate 是不可变 case class,便于隔离“实例选择时点”这一问题;不能用这个例子证明任意配置对象都形成历史快照。

若业务需要请求开始时的费率快照,可以捕获稳定值;若需要执行时最新费率,可以保留显式环境参数。两者都是可定义策略,错误在于没有说明就混用,导致同一笔订单的不同步骤采用不同版本。

上下文函数不会自动关闭资源

把参数类型从 Rate 换成数据库连接,语法仍然可以保存一个依赖连接的计算。它可能稍后运行,也可能运行多次。但这个函数类型没有说明连接何时获取、何时关闭、能否跨线程使用,也没有保证执行时仍然有效。

如果某个已打开连接被闭包捕获,资源块结束后连接关闭,保存的函数仍可能存在。稍后调用会使用失效资源。上下文参数搜索成功只说明有一个类型匹配的值,不说明资源生命周期符合需求。

正确设计需要让获取、执行与关闭覆盖同一实际使用范围,或使用专门的资源协议。本文不通过一个普通函数类型宣称资源安全,也不把它等同于可取消效果。类型能够表达依赖,并不自动管理依赖的生命。

异常边界也一样。上下文函数体抛异常时,异常会按普通调用规则传播,除非外层明确捕获或函数返回错误容器。若需要保存失败信息,可以把结果类型改成 Either 或 Try,但捕获时点仍需明确;仅把返回类型换个名字并不能自动捕获所有抛出。

重复执行、记忆化与配置版本分别设计

同一 pricing 应用四次,实验最终 evaluated 为四。没有任何自动“执行过就缓存”的行为。若把第一次结果保存为 Int,则后续读取这个整数不再计算,但它也不再随着费率变化。这是保存结果与保存计算的区别。

给上下文函数加缓存需要决定缓存键是否包含环境。若只按金额缓存,却允许费率变化,第二次用新费率可能错误复用旧结果。若环境中包含资源句柄或可变配置,以对象身份作键也未必代表业务版本。缓存不是上下文函数的附带特性,需要单独契约。

配置快照还需要可追踪版本。可以让环境包含费率与版本号,在计算结果中记录采用的版本,这比事后根据当前配置猜测更可靠。上下文函数只负责把环境送入计算,版本选择与记录仍由应用边界完成。

测试时可以用两个不同费率和同一金额,确认保存的是依赖参数的计算;再用同一费率重复调用,确认执行次数没有意外缓存。若之后确实引入缓存,应更新断言并明确结果等价条件,不能让旧计数失效而未被发现。

从方法参数到函数值的迁移方法

当一串方法都有相同 using 参数时,可以先把最外层操作看成“环境到结果”的函数,再决定是否需要把它作为值保存、组合或传递。若只是立即调用一个方法,普通 using 方法已经足够,不必为了使用新语法再包装一层。

真正需要保存时,明确返回的是计算还是结果。如果工厂本身还要做一次性工作,像本章一样用普通返回对象包住上下文函数,可以把构造阶段与执行阶段清楚分开。直接把整个工厂返回类型写成上下文函数,可能让原以为立即执行的代码进入延后函数体。

组合多个上下文计算时,还应说明共享环境还是各自选择环境。用一个环境连续运行两个计算可以保证参数一致,但不保证外部状态没有变化;分别传两个环境则可能有意表达不同策略。签名与测试应反映这个决定,而不是只验证最后数字相等。

手算与修改练习

手算:用 Rate(10) 创建 captured,随后引入 Rate(30),captured() 与 pricing(using Rate(30)) 分别是多少?在金额一千分时,前者一百,后者三百。原因是依赖选择时点不同,而不是某个 given 覆盖了另一个对象。

修改练习:在 Rate 中增加版本号,把结果改为包含费用与版本的 case class。构造一次 Plan,分别用两个环境执行,断言 built 仍为一、evaluated 增加两次,结果分别记录对应版本。再让 capture 保存第一份环境,验证它仍记录第一版本。计数必须位于真实工厂和计算体,不能在测试外部手工模拟。

另一个修改是把计算结果改为 Either,负费率返回明确错误。断言构造阶段仍不执行校验,应用时才返回错误;同时比较显式参数版本。这样可以把延迟时点与错误通道一起验证,而不是只检查正常价格。

实验记录与依据

入口 scalaexamples.Chapter21,源码 examples/scala-lab/snippets/21/Chapter21.scala。最终证据使用 20261002-ch21-r3,正例与缺少环境反例见 RUN.md。构造计数位于普通 build 工厂,执行计数位于计算体。Plan 使用普通 final class,因为冻结版本不允许 case class 的元素是上下文函数。

  • 冻结上下文函数规则:核对函数类型、显式应用与期望类型下的包装。
  • 冻结 using 规则:核对参数补全与显式传递。构造时点、捕获对照和生命周期限制由独立程序及普通引用关系推导。

前置阅读:高阶类型与组合定律。

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