同一笔金额,省略费率之后用了哪一个

金额一千分,费率百分之十,费用是一百分。显式传入费率时,调用关系很容易看懂;把费率变成上下文参数后,调用只剩 fee(1000),仍然需要解释那份费率从哪里来。若当前范围同时出现百分之十与百分之二十两个候选,编译器不应该替业务随意选择。

given 与 using 的核心仍是参数传递。using 声明一个允许编译器补全的参数列表,given 声明可供搜索的实例。省略实参触发的是编译期搜索与选择,并不等同于运行时到全局注册表里按名字查询对象。

本文冻结 Scala 3.3.7、标准库 2.13.16 和 JDK 21,逐步比较显式传参、伴生范围、专用导入、更深嵌套以及同层歧义。Scala 2 的 implicit 搜索存在不同规则,本章不把旧教程中的优先级直接套入,也不声称已经执行两版本迁移对照。

先写一个完全显式的调用

1
2
3
4
5
6
case class Rate(percent: Int)
def fee(cents: Int)(using rate: Rate): Int =
cents * rate.percent / 100

val value = fee(1000)(using Rate(5))
assert(value == 50)

这里 using 实参明确给出 Rate(5),所以没有费率选择歧义。方法体像普通参数一样读取 rate.percent,不需要知道调用方是显式传入,还是由编译器找到。理解这一点后,就能把复杂搜索先还原成普通依赖传递。

上下文参数并不自动成为不可变配置。Rate 在本章是不可变值对象,但若它保存可变字段、文件句柄或网络客户端,参数传递也会传递那些对象引用。编译器只检查候选类型与规则,不保证实例线程安全、资源未关闭或业务值有效。

费率百分比范围也不由 using 保证。本例五、十、二十、三十、四十都在测试范围内,生产接口仍需决定是否允许负费率、怎样舍入和怎样避免乘法溢出。把一个参数标成上下文,不会提高它内部数据的可信程度。

伴生对象提供相关类型的默认实例

1
2
3
4
object Rate:
given default: Rate = Rate(10)

def companion(): Int = fee(1000)

调用点没有显式传参,也没有局部 Rate 候选,搜索可以从相关类型的隐式作用域找到 Rate 伴生对象中的实例。本章断言得到一百分,验证这条默认路径实际成立。

这里的“默认”是业务命名,不是 given 关键字赋予了某个全局优先级。编译器根据搜索范围和候选适用性决定能否使用它。若有更直接可见的上下文,结果可能不同;如果多个同等候选都适用,也可能产生歧义。应避免把完整规则简化成一个所有场景通用的顺序口诀。

伴生默认实例适合较稳定且公认的行为,例如某个类型的规范编码或排序。随活动变化的业务费率往往需要在应用装配点选择,放进全局伴生默认值可能使调用点看不见业务决策。即使机制上能够找到,设计上仍需判断是否应该默认。

隐式作用域也不是任意包中的所有 given。冻结规则通过类型及相关锚点确定范围,Scala 3 的包前缀规则与旧版本有差异。把实例移到同名包附近,不保证它仍然能被自动找到。重构后应保留一个最小调用测试,验证真正的可见路径。

given 导入明确增加候选

1
2
3
4
5
6
object Campaign:
given special: Rate = Rate(20)

def imported(): Int =
import Campaign.given
fee(1000)

这条调用得到二百分。导入让活动费率在当前调用点可见,正常程序与伴生默认路径放在不同方法中,避免测试之间互相污染。每个方法只有目标候选来源,结果才容易解释。

Scala 3 的普通星号导入不等于专门导入新式 given。本章另建一个没有伴生默认实例的反例,仅写 import Rates.*,随后调用需要 Rate 的函数,编译器报告缺少实例。去掉伴生默认值很关键:若仍存在备用实例,错误导入可能被默认行为掩盖,测试反而看不出活动策略没有生效。

可以按类型导入 given,或者显式引用一个有名字的实例。选择性导入比把一个大型模块的所有上下文都带入更容易审阅。需要多个同类型业务策略时,显式 using 往往最清楚,不必为了让自动搜索通过而重新组织整个包结构。

给实例起名仍然有价值。诊断可以列出候选,调用方也可以写 Campaign.special 明确选择;匿名实例适合简单标准行为,但业务策略的名字能帮助解释为什么选它。名字不会自行解决歧义,类型相同且规则不能区分时仍需明确处理。

更深嵌套可以影响 Scala 3 的选择

实验还定义一个有外层 using Rate 的方法,再在内部方法中声明局部 Rate(30)。调用外层时显式传入 Rate(40),内部 fee 仍得到三百分。这个结果对应冻结规则对嵌套深度的考虑。

这里不是运行时“最后赋值覆盖前值”。两个实例都可以作为普通值存在,编译器在内部调用点选择适用上下文。离开该词法范围后,另一处调用重新按自己的可见范围搜索,不存在把外层对象永久改成三十的操作。

因此,把一段调用代码搬进更深局部块可能改变搜索结果。重构看似只调整结构,若引入新的上下文,行为可能变化。关键计算可以在测试中显式传参,再单独测试装配层的自动搜索,分别保障算法与依赖选择。

也不能用一个嵌套例子概括所有候选优先级。候选类型特化、继承关系、所需上下文和歧义传播都可能参与判断。本文保留相同 Rate 类型的最小例子,目的是验证一个明确规则;复杂搜索需要逐步缩小候选集合并查看实际诊断。

同层两个费率应该明确报歧义

1
2
3
4
object Example:
given first: Rate = Rate(10)
given second: Rate = Rate(20)
val bad = fee(1000)

这里两个实例同层、同类型,不能根据当前规则选出唯一目标,编译反例要求出现 Ambiguous given instances,并同时包含 first 与 second。这样验证的不只是“编译失败”,而是失败确实来自两个预期候选。

修复可以在调用处明确写 using first,也可以只导入所需一个候选,或把两个策略放到不同装配范围。选哪种应根据业务选择发生在哪里决定。若每笔订单都可能选择不同活动费率,显式参数通常比词法导入更能表达动态业务条件。

不应为了消除歧义而随意删除一个业务策略。编译错误揭示的是调用点缺少决定,不意味着其中一个策略天然错误。也不要通过无关的继承层次让某个候选碰巧更具体;那会把业务优先级编码成难以解释的类型关系。

如果候选自身还需要其他上下文,搜索会继续递归。深处的歧义可能影响外层结果,不能把它简单当作某候选不存在后自动尝试任意备用。冻结隐式搜索文档明确记录了与 Scala 2 不同的歧义处理,排错时应查看完整候选链。

搜索发生在编译期,实例行为发生在运行期

编译器选择了一个实例,并不代表该实例的值在所有运行中一样。given 可以由方法或对象初始化产生,实例内部也可以读取配置。静态搜索确定传递什么表达式或实例来源,运行期执行决定它实际做什么。

这一区分对测试很有用。算法测试显式传 Rate(5),保证输入可控;搜索测试在受控作用域里省略参数,验证来源;实例实现测试检查它怎样读取配置或计算费率。三种测试覆盖不同错误,不能用“自动找到一个 Rate”证明费率业务正确。

上下文参数也不是隐式转换。前者补全一个已经声明需要的参数,后者可能改变表达式可用的类型或操作。把两者都叫“隐式魔法”会妨碍排错,因为缺少给定实例与找不到成员属于不同路径。本章不引入 Conversion,保持机制单一。

同样,上下文不是线程局部变量。词法可见范围决定编译器如何补全,不会因为任务切换线程就自动重新搜索一个新实例。对象引用之后怎样被保存或捕获,是普通参数与闭包行为;异步执行环境的问题需要单独实验。

用候选清单定位错误

遇到错误时,先写出需要的完整类型,例如 Rate 或 Encoder[List[Order]]。再列调用点显式传入、局部 using、局部 given、导入和相关伴生范围。每次只移除或添加一个候选,重新编译最小样例,就能看出是哪条路径造成缺失或歧义。

缺少实例与存在多个实例的修复方向相反。缺少时检查导入、类型参数与实例依赖;歧义时缩小范围或显式选择。不要一看到错误就广泛 import 所有 givens,这可能把缺失修成更难理解的歧义。

还应核对实例类型是否过宽。若多个不相关业务配置都用裸 Int 上下文,搜索无法区分费率、超时和重试次数。使用 Rate、Timeout 等领域类型能把角色进入类型系统,减少意外候选。上下文语法越省略参数名,类型名就越需要清楚。

对 Scala 2 旧代码,应在独立冻结工程中验证 import 与搜索差异,再迁移写法。本章只列出这条边界,不给出未经运行的迁移成功结论。已有源码规则说明了需要关注哪里,最终兼容性仍要由目标版本构建证明。

配置变更不应依赖重新猜测搜索路径

如果费率来自配置中心,可以在应用边界读取配置并构造策略,再显式交给业务入口。入口内部继续使用上下文参数减少重复传递,但一笔请求究竟采用哪份配置仍有可追踪的来源。若每个深层方法自行读取当前全局配置,同一订单的多步计算可能使用不同版本,问题就不再是编译器候选选择,而是业务快照一致性。

这类需求可以把配置版本号与费率一起放进上下文值,测试同一订单各步骤收到同一版本。上下文机制只帮助传递这份值,不负责创建快照或锁定配置。设计时先决定参数应该在请求开始、步骤开始还是实际执行时取得,再选择传递形式,才能避免把时间相关业务规则藏进省略参数的语法中。

手算与修改练习

手算:调用 fee(1000)(using Rate(5)) 的同时,当前作用域还有两个 Rate given,会因它们产生该参数的歧义吗?不会,因为该 using 参数已经显式提供;仍可能存在方法内部其他搜索,但不能把它与当前费率参数混同。

修改练习:增加节假日费率 Rate(15),与活动费率放在不同对象中。分别用选择性导入和显式 using 得到一百五十分、二百分,再在同一范围导入两者构造歧义反例。目标诊断必须指出这两个候选,修复后再验证原来的伴生默认路径仍为一百分。

进一步把 Rate 改为包含计算方法的策略接口。算法测试显式传一个固定策略,搜索测试仍按三条范围路径运行。这样可以确认业务实现变化没有被误当成搜索规则变化。

实验记录与依据

正常程序 examples/scala-lab/snippets/18/Chapter18.scala,入口 scalaexamples.Chapter18。两个隔离反例分别验证同层歧义与星号导入缺失,实际记录见 RUN.md。

前置阅读:extension 与 API 设计。

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