金额能调用 plus,就成了某个金额接口的子类吗

假设 Money 已经定义在共享模型中,订单模块希望写出 price.plus(fee),但不能修改原始类。扩展方法能提供这样的调用形式。接着另一个接口要求 Priced,开发者给 Money 增加一个同名 cents 扩展,试图把 Money 直接赋给 Priced,却仍然编译失败。

原因在于“能够解析某种方法调用”和“这个类型具有某个子类型关系”不是同一件事。扩展方法提供额外的静态调用规则,不会回到原类中增加继承声明,也不会让 JVM 对象在运行时成为另一个接口的实现。使用点语法不意味着方法就在接收者对象中。

本文固定 Scala 3.3.7、标准库 2.13.16、JDK 21,围绕金额求和验证扩展、成员优先、显式调用与 export。它不借助隐式转换包装对象,也不把扩展语法当作自动提供业务不变量的工具。

先把点语法还原成带接收者的函数

1
2
3
4
5
case class Money(cents: Long)
object Syntax:
extension (money: Money)
def plus(other: Money): Money =
Money(money.cents + other.cents)

plus 的实现需要两个值:接收者 money 和普通参数 other。扩展定义把第一个参数放在特殊位置,使调用方可以写 money.plus(other)。冻结规则把它解释为具有扩展标记的方法,接收者作为显式参数列表参与调用。

因此同一个操作也可以写成 Syntax.plus(Money(100))(Money(200))。正常程序对比显式形式与点形式,结果都是 Money(300)。这个对照让方法属于哪个模块、实际输入是什么变得清楚,不需要假设原类在运行时被修改。

返回新 Money 也不代表原值被原地改变。这里 case class 保存稳定 Long,plus 重新构造结果;若改成可变金额对象,扩展完全可以修改接收者字段。extension 只决定方法定义和解析方式,不强制不可变、纯函数或线程安全。

算术边界仍需独立设计。两份 Long 金额相加可能溢出,两个不同币种也不应直接合并。最小例子只使用同单位的小金额。需要币种安全时,可以在类型或字段中表达币种,并在公开操作中验证兼容,不能因为方法名是 plus 就推导所有输入合法。

可见范围决定调用能否找到扩展

调用点需要能找到相应扩展。可以通过导入扩展定义、在当前范围直接可见,或通过规则允许的相关作用域获得。对业务代码而言,清楚的导入通常更容易解释来源;把大量扩展散布到广泛通配导入中,会让方法解析依赖难以看见。

本章把实现放在 Syntax,再通过 PublicApi 有选择地转出 plus:

1
2
object PublicApi:
export Syntax.plus

调用方导入 PublicApi 的公开成员后,可以使用点语法。export 用来组织公开入口,不会复制一套新的业务算法。实现仍应只有一个可追踪来源,避免不同门面各自维护一份看似相同的金额逻辑。

如果未来需要重新组织内部模块,门面可以帮助减少调用点导入变化,但它不自动保证所有源码与二进制兼容。公开名称、参数类型、擦除后的签名仍可能变化。把 export 当作模块组织工具更准确,不宜写成无条件升级兼容机制。

扩展来源还可能影响审阅和排错。同样一行 money.plus(fee),不同导入下可能选择不同可用定义,或者产生歧义。重要业务操作应尽量有可辨识的命名与稳定入口。若看到一个陌生点调用,先定位扩展所属对象和导入,再分析方法体,而不是只查接收者类。

已存在且适用的成员优先

实验给 Money 本身定义 label,同时在 Syntax 里定义同名扩展:

1
2
3
4
5
6
case class Money(cents: Long):
def label: String = s"member:$cents"

object Syntax:
extension (money: Money)
def label: String = s"extension:${money.cents}"

点调用 result.label 选择成员,得到 member:300。显式调用 Syntax.label(result) 则直接指向扩展定义,得到 extension:300。正常程序分别断言两条结果,避免把同名定义当作覆盖原成员的方法。

编译器对这个刻意的碰撞还会给出说明扩展不能用点语法选中的警告,日志保留该诊断。它是教学反例的一部分,真实 API 不应靠制造永久不可达的点扩展来提供替代实现。需要第二种显示策略时,采用不同名称或显式策略参数更清楚。

“成员优先”也不应被无限泛化成忽略参数适用性的口号。重载、参数列表和具体可应用条件可能影响解析,排错时应查看实际签名与诊断。本章刻意使用完全相同的无参数 label,保证比较聚焦于已存在的适用成员。

这条规则对依赖升级很有影响。原类新增一个与已有扩展同名的适用成员后,调用点可能开始解析到新成员。即使代码仍然编译,行为也可能变化。公开扩展命名应考虑碰撞风险,依赖升级回归也应检查关键调用结果,不只看编译状态。

扩展不能补出名义子类型关系

1
2
3
4
5
6
trait Priced:
def cents: Long
case class Money(value: Long)
extension (m: Money) def cents: Long = m.value

val bad: Priced = Money(100)

Money 没有声明实现 Priced,增加同名扩展不改变这一点。编译反例要求实际类型为 Money、所需类型为 Priced 的诊断。它验证的是名义子类型边界,不是扩展方法是否写得可调用。

如果接口确实需要一个 Priced,可以让受控的适配器实现该接口并包装 Money,或者在能够修改类时显式继承。若目的只是为不同类型提供价格读取行为,也可以使用带类型参数的行为接口,由调用方传入对应实例。不同方法有不同分配、所有权和调用模式,应按需求选择。

结构类型和动态调用也不是这个简单扩展的自然结果。不能因为两个类型都能通过某种语法得到 cents,就认为它们可互换到任意要求该行为的接口。类型系统允许什么关系,要看明确声明与语言规则,而不是人眼看到的方法名相同。

这个区分能减少接口误设计。业务只需要一个计算函数时,不必建立继承层次;业务需要统一运行时接口时,也不能指望导入扩展就完成适配。点语法让函数调用更贴近数据,类型关系仍应独立表达。

泛型扩展要把需要的能力写出来

可以对一组类型定义扩展,例如为列表增加按字段求和的操作。此时方法体并不会因为元素类型参数存在,就自动知道它支持加法或编码。需要的能力必须来自类型边界、显式函数参数或上下文参数。

这与普通泛型函数完全一致。扩展接收者只是参数的一种组织方式,不能让任意 A 都拥有任意成员。如果方法需要把 A 映射为金额,可以接受 A => Money;如果需要统一行为实例,后续类型类章节会把这种依赖写成 Encoder[A] 一类接口。

把能力要求显式放进签名也有助于避免“万能扩展”。一个对所有 Any 都可见的业务方法,可能在不适当类型上也出现,看起来方便却缺少清楚语义。接收者越精确,可调用范围就越接近业务含义,错误越容易在编译时暴露。

扩展中的异常与失败同样应体现在接口上。若金额格式化可能拒绝未知币种,可以返回 Either;若公开方法总返回 String 却在内部任意抛异常,调用方仍需知道边界。extension 不提供特别的异常处理协议,和普通方法一样需要清楚契约。

两种策略不要靠导入先后决定业务

同一金额可以有简洁展示与审计展示。若两个模块都提供 render 扩展,同时导入后出现歧义,修复方式不应是反复调整 import 顺序,直到编译器恰好接受。业务要哪一种格式应该在代码中可见。

可以给方法取不同名称,例如 compactText 与 auditText;也可以显式调用所属模块;如果有许多泛型调用点,再引入命名策略实例。选择哪一种取决于复用范围,但目标一致:把业务选择与编译器搜索规则分开,不让一个遥远导入隐含决定外部协议。

扩展也可以成为可选语法层。核心算法保持显式函数,调用者需要时导入语法入口,就能兼顾发现性与依赖透明。调试时可临时改成显式形式,确认接收者、参数和目标定义,不改变业务实现。

对于公共库,广泛 export 所有内部扩展可能把本来只供实现使用的方法变成长期兼容负担。选择性公开让维护者能说明哪些操作是稳定接口。本文只转出 plus,没有把冲突实验 label 作为推荐门面成员,这个选择与真实 API 的可用性相符。

扩展与领域类型的组合仍需检查透明区域

给 opaque 金额增加运算很常见,但在定义透明区域内,底层类型的成员可能优先于同名扩展。若底层是整数,一个符号在模块内部解析为整数运算,在外部却解析为领域扩展,就需要特别小心单位和含义。

因此,公开业务操作最好有清晰命名,并在边界处保留小程序验证。不要仅凭源码外观看到相同运算符,就认为模块内外采用完全相同实现。必要时使用显式所属对象调用,让实际目标明确。

本章 Money 使用普通 case class,避免把 opaque 可见性与成员碰撞混成同一个失败原因。上一章的抽象边界与本章的解析边界可以组合,但验收时仍应分别隔离。每个最小反例只证明一条规则,集成后再验证共同效果。

可发现性应与公开承诺一致

编辑器能否补全一个扩展方法,通常受导入与类型信息影响。使用者若需要先导入一个不相关的大模块才能看到金额操作,说明语法入口与业务模块关系可能不够清楚。一个小而明确的 Syntax 或公开门面可以降低发现成本,也能让代码审阅直接看到新增依赖。

但补全可见不等于方法适合当前业务。格式化、舍入、换汇和金额相加看起来都属于金额操作,却可能依赖不同地区规则与时间数据。需要外部上下文的方法应把依赖写进参数,不能藏在全局单例中。扩展让调用形式更紧凑,越紧凑的接口越应清楚说明其输入边界。

手算与修改练习

手算:成员 label 返回 member:300,扩展 label 返回 extension:300,同时两者都存在,点调用得到哪一个?答案是适用成员。若要指定扩展,用 Syntax.label(result);这不会修改对象上已经存在的成员实现。

修改练习:给 Money 增加 discount(percent: Int),只接受零到一百,返回 Either[String, Money]。正常断言一千分九折得到九百分,非法百分比返回错误;原金额保持不变。先用不同方法名避免碰撞,再故意增加同名成员并观察点调用变化,把这次变化作为 API 升级风险测试。

第二项修改是定义一个实现 Priced 的适配器,包装 Money 并读取其金额。编译正例应通过适配器进入要求 Priced 的函数,原始 Money 直接赋值的负例应继续失败。这样能区分语法扩展与真实接口实现,而不是通过强制转换隐藏边界。

实验记录与依据

入口 scalaexamples.Chapter17,源码在 examples/scala-lab/snippets/17/Chapter17.scala,结果与碰撞警告见 RUN.md。

前置阅读:opaque 与领域类型。

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