两个 Long 写反了,编译器为什么没有意见

查询订单的函数接收 Long,调用方手里同时有订单号与商品号。两个值都能编译通过,只有运行时查不到记录,甚至碰巧查到另一条有效记录,才暴露参数误传。把变量命名得更清楚可以帮助阅读,却不能让类型检查拒绝错误调用。

普通别名也不解决这个问题。type OrderId = Long 主要提供一个名称,仍然与底层 Long 等价;ProductId 若也只是 Long 别名,两者仍能互换。需要的是对外保持区别,对内继续使用同一个表示,而不是只增加注释。

opaque type 提供这种抽象边界。本文固定 Scala 3.3.7、标准库 2.13.16 和 JDK 21,既验证错误标识不能互传,也检查数量受控构造与实际字节码。对于“零成本”的说法,本文只报告所观察到的方法描述符和装箱,不推广成任何使用方式都不分配。

定义区域内透明,区域外保持抽象

1
2
3
4
5
object Domain:
opaque type OrderId = Long
opaque type ProductId = Long
def orderId(raw: Long): OrderId = raw
def productId(raw: Long): ProductId = raw

在 Domain 内,定义实现能够把底层 Long 当作对应类型使用,所以两个工厂直接返回 raw。在外部,OrderId 和 ProductId 是两个不同的抽象类型,调用方不能因为知道实现写的是 Long,就随意把一个当作另一个。

这个边界属于静态可见范围,不是运行时给每个数贴了可检查标签。类型检查发生时决定哪些表示等价,生成到 JVM 后可以使用底层值表示。通过反射或强制转换绕过静态接口,不属于这个机制提供的领域安全保证。

定义位置因此很重要。对象成员的 opaque 类型在该对象实现中透明;顶层 opaque 定义有相应文件范围规则;定义在类实例中的类型还可能具有路径依赖。不能把“模块外不透明”理解成所有包、文件、内部对象都采用相同边界。本文选择一个明确对象,避免把这些可见范围差异混在初次实验里。

如果把业务校验和所有调用代码都放进同一个透明区域,类型区别对那些内部代码就可能不再形成预期保护。合理模块边界应让少量实现接触表示,让多数业务调用者通过公开接口使用。opaque 并不是把所有代码包进一个巨大对象之后自动获得隔离。

对外函数签名必须保留领域类型

1
def lookup(id: Domain.OrderId): Long = id.rawOrder

这个签名要求 OrderId,误传 ProductId 会编译失败。如果为了方便把签名重新改回 Long,再在方法内部解释为订单号,类型区分就失去关键作用。领域类型需要沿调用链保留,只有进入数据库、协议编码等表示边界时才显式取出底层值。

公开解包操作可以用扩展方法表示。本例为两个标识分别提供 rawOrder 与 rawProduct,名称刻意区分,便于观察边界。解包后得到普通 Long,自然又可以传给任何接收 Long 的接口,因此接口越早解包,剩余链路的保护越少。

两个标识底层数值恰好都为七,不影响静态类型区别。业务身份包括“这是什么种类的标识”,而不只是数字内容。对外接口如果把种类信息删除,就不能期待编译器继续识别。网络和数据库中的原始列值也需要在进入领域层时恢复对应的类型角色。

编译反例使用独立 Domain 定义,在其外部把 product(7) 交给 lookup。目标诊断同时包含实际 ProductId 与所需 OrderId。仅看到非零退出码不足以证明领域类型有效;错误必须发生在这个预期边界,而不是未导入对象或拼写错误。

数量类型把校验与构造放在同一入口

1
2
3
4
5
object Domain:
opaque type Quantity = Int
def quantity(raw: Int): Either[String, Quantity] =
if raw > 0 then Right(raw) else Left("quantity")
extension (q: Quantity) def count: Int = q

调用方不能直接把负一赋给 Quantity,而必须通过公开构造方法获取值。方法在成功时证明当前输入大于零,并把通过检查的整数交给后续业务。正常程序断言二成功、零失败;另一个编译反例验证外部原始整数不能直接冒充 Quantity。

保证仍依赖实现自己遵守规则。Domain 内能够制造任何底层 Int 对应的 Quantity,因此如果新增 unsafe 构造器或错误分支返回零,opaque 不会阻止透明区域内部写错逻辑。类型边界把需要审阅的构造点集中起来,但不替这些点证明业务谓词。

字段操作也需要维护不变量。两个正数量相减可能得到零或负数,若公开 minus 直接返回 Quantity,就必须规定结果是否合法;固定宽度加法溢出也可能破坏正数条件。构造时验证一次,不代表任意后续操作都自动封闭在合法集合内。

一种做法是对可能失败的操作继续返回 Either 或 Option,只有满足条件时才产生领域值;另一种做法是把不同结果范围建成不同类型。选择取决于业务常见操作,不应为所有算术运算机械生成扩展方法。每个公开操作都相当于新的构造路径,需要独立验收。

与 case class 包装及普通别名比较

普通别名保留底层类型等价,适合改善名称表达,不负责阻止同表示角色互传。case class 包装引入一个有字段的对象类型,能携带更多数据和行为,也具有自己的构造、匹配与运行时形状。opaque 主要提供表示隐藏与静态区分,选择时应从这些需求出发。

如果订单标识需要同时保存租户、校验位和来源信息,一个 Long 表示可能已经不足。把更多字段塞进位运算以维持某种“零包装”目标,可能比一个清楚的值对象更难维护。表示优化不应倒过来决定领域含义。

运行时需要区分两个标识种类时,也不能只依赖 opaque 名称。它们可能都落成 Long 或装箱 Long,反射看不到编译时的业务角色。若协议需要显式标签,应在编码格式中保存;若日志需要区分字段,应该打印字段名。静态区别与运行时标签是不同机制。

模式匹配同样不能从任意 Any 中可靠恢复 opaque 类型的领域来源。把原始对象强转成 OrderId 只会绕过保证,不是经过验证的反序列化。边界解析应该读取实际表示、检查业务条件,再调用受控构造函数。

字节码显示直接路径与泛型路径不同

本章对编译后的 Chapter16 执行 javap。lookup 的 JVM 方法参数与返回值为 long,对应描述符 (J)J。这说明这条直接路径没有要求一个独立的 OrderId 包装类作为参数;源码中的领域类型没有在该位置变成额外包装对象。

程序另把 OrderId 放进 List,并观察转换到 Any 后的运行时类名为 java.lang.Long。JVM 泛型与对象边界会涉及装箱,这个结果足以反驳“使用 opaque 就绝不会装箱”。它并不测量 JIT 最终是否消除某些分配,也不测量全部对象数量。

两种观察不冲突。直接调用与泛型容器采用不同表示要求,编译器和运行时优化还会继续处理。准确说法应限定到路径:本例直接方法签名使用原始 long,显式对象观察路径看到装箱 Long。把其中任意一条推广成全程序性能保证都缺少证据。

字节码只展示某阶段产物。实际延迟、吞吐、分配和 GC 还受热点编译、逃逸分析与调用方式影响。本文没有基准测试,因此不报告比 case class 快多少,也不把没有看到某个包装类等同于所有执行都没有额外成本。

在边界恢复类型,在内部保留类型

一条典型调用链是:外部字符串解析成长整数,检查标识格式,得到 OrderId,再传给订单仓储接口;仓储执行 SQL 前解包成长整数。中间业务层不需要知道底层如何存储,也不需要反复验证它是不是商品号。

如果将来标识改为字符串或组合对象,理想情况下大多数业务函数仍使用 OrderId。需要修改的是构造、公开操作和外部编码边界。但这不自动承诺二进制兼容:JVM 方法描述符可能随表示变化,已编译调用方是否兼容必须另行验证。

错误输入也不应通过隐式转换自动接受。若任何 Long 都能无检查转换成 Quantity,就重新打开了不合法值入口。受控构造之所以有用,是因为调用方必须处理成功与失败;省去这一步语法,可能恰好省掉了最重要的业务判断。

对于数据库历史数据,不能因为列类型是整数就跳过领域校验。历史错误、外部写入和迁移都可能产生不符合当前规则的值。读取边界可以返回结构化错误,让调用者决定修复、拒绝或隔离,而不是把所有读出的整数当作已经验证。

抽象边界还需要保留运算单位

金额、比例和数量即使都能放进整数,也不是同一种数学对象。若只为标识增加领域类型,却继续把百分比、基点和金额分都写成裸整数,乘法与除法仍可能因单位混淆而出错。领域类型可以减少这种误传,但每个运算的结果单位仍要由接口说明。例如数量乘单价得到金额,两个订单标识相加却通常没有业务含义。

因此,公开方法不应照搬底层数值的全部运算。只暴露合法的领域操作,能够避免调用方根据底层表示自行拼出奇怪结果。如果业务确实需要排序标识,也应说明排序是数字顺序、创建时间还是协议顺序;它们未必一致。类型隐藏提供了控制这些操作的机会,设计者仍需决定哪些操作值得承诺。

测试可以围绕这份公开操作集合展开。构造边界检验哪些原始值可接受,参数边界检验角色不能互传,运算边界检验不变量是否保留,编码边界检验往返后仍有相同领域含义。四类证据彼此补充,任何单一的编译失败都不足以证明整个领域模型正确。

手算与修改练习

手算:OrderId 与 ProductId 都由数字七构造,解包后的 Long 可以相等吗?可以。两种领域类型静态不同,与它们底层数值相等并不矛盾。若先解包再比较,只是在比较表示,不能因此证明它们代表同一个业务实体。

修改练习:给 Quantity 增加受控加法,使用更宽中间计算或显式溢出检查,超过允许范围返回错误。至少断言普通相加成功、接近上界失败、原数量保持可用。不要在对象外通过 asInstanceOf 创建测试值;应提供合法边界构造,确保反例验证的是运算规则。

第二项修改是给查询函数增加 ProductId 参数,用不同顺序两个标识调用。编译正例应传对角色,负例应交换位置并命中类型不匹配。这样可以把“变量名容易看错”转换成明确的构建期保护,而不依赖代码审阅者每次都发现数字角色。

实验记录与依据

源码在 examples/scala-lab/snippets/16/Chapter16.scala,入口 scalaexamples.Chapter16。正常、两个负例及 javap 输出见 RUN.md 和 examples/scala-lab/evidence/20261002-ch16/。

  • 冻结 opaque 规则:核对透明范围与公开操作。
  • opaque 细则:核对抽象边界。字节码与装箱结论仅来自本章固定程序,不把文档中的性能概括扩成任意场景保证。

前置阅读:错误短路与错误累积。

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