两种顺序决定不同的问题

订单金额处理器同时混入“结果加一”和“结果翻倍”两个 trait。两种写法只交换 with 后的名字,输入同样的整数 10,结果却可能分别是 22 和 21。构造时哪个 trait 先执行,与调用方法时先进入哪个实现,也不能靠同一条“从左到右”口诀回答。

这里用整数作为顺序实验的载体,不建立金额计算模型。真实订单金额仍须声明币种、精度与舍入规则。实验的核心问题是:一个组合对象上执行的 super.total 到底连接哪个实现,以及执行那个实现时对象的字段是否已经赋值。

Scala 的线性化给继承图排出一个确定序列,用于成员解析和 trait 中的 super 连接。初始化还要遵守父类构造与 trait 初始化规则。两者有关,却回答不同问题;继承次序正确,不能推出对象构造期间读取的字段已就绪。Scala 3.3.7 规范中的类线性化与模板求值

本篇冻结 Scala 3.3.7、Scala CLI 1.9.1、Amazon Corretto JDK 21.0.11。实验状态为 LAB_VERIFIED:Scala CLI 与 sbt 两条入口均运行了本篇断言,两个隔离反例也按预期拒绝。批次 20261002-batch01 的实际命令、退出码和输出位于 examples/scala-lab/evidence/20261002-batch01/,其中 all.log、sbt.log 保存成功运行,negative-05-safe-init.log、negative-05-trait-argument.log 保存拒绝诊断;同名 JSON 记录命令与退出码。完整源码位于仓库的 examples/scala-lab/snippets/05/Chapter05.scala,运行说明在同名素材目录的 RUN.md。

从一次调用推导线性化

Base.total 返回输入,AddOne 对 super.total 的返回值加一,DoubleTotal 对返回值乘二。删去事件记录后,核心定义如下;完整实验还为进入、退出与初始化分别记录事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base:
def total(value: Int): Int = value

trait AddOne extends Base:
abstract override def total(value: Int): Int =
super.total(value) + 1

trait DoubleTotal extends Base:
abstract override def total(value: Int): Int =
super.total(value) * 2

final class First extends Base with AddOne with DoubleTotal
final class Second extends Base with DoubleTotal with AddOne

暂时省略共同的底部基类后,两个对象的相关线性化为:

1
2
First  → DoubleTotal → AddOne      → Base
Second → AddOne → DoubleTotal → Base

推导 First 时,先保留最终类自身,再从最右侧父类型开始合并祖先序列。DoubleTotal、AddOne 都继承 Base,合并时不能让 Base 出现两次,也不能把 Base 放到它的子类型前面。对这个简单结构,结果就是 First, DoubleTotal, AddOne, Base。规范的合并运算保留右操作数中的重复祖先,因此完整规则比“把所有名字倒过来”更严格。

first.total(10) 沿这个序列先找到 DoubleTotal.total。该实现中的 super.total 接到 AddOne.total,后者再接到 Base.total。基类返回 10,返回过程依次得到 11、22。第二个对象的链条换成 AddOne → DoubleTotal → Base,返回过程得到 20、21。

1
2
3
4
5
First 的进入:DoubleTotal → AddOne → Base
First 的返回:10 → 11 → 22

Second 的进入:AddOne → DoubleTotal → Base
Second 的返回:10 → 20 → 21

super 的含义由最终组合决定。在 trait 定义处,虽然 DoubleTotal 写着 extends Base,也不能据此把它的 super.total 永久理解成直接调用 Base.total。这正是可叠加修改能够工作的条件:同一份 trait 实现放入不同组合,可以接续不同的实现。

这也解释了为什么实验同时断言结果和事件。两个修改若恰好都做加法,交换它们可能仍得到相同数值;仅检查总额会漏掉调用顺序变化。加一与乘二不交换,配合进入、退出事件,可以分别观察组合结果与委派链条。

abstract override 表达一个可叠加的覆盖实现:它依赖后续 super 链提供适当的实现,并不能独立替代链条终点。这里 Base.total 是终点。若把基类方法改成抽象方法,还必须在具体组合中提供实现。super 链也不会自动遍历所有同名方法;某个覆盖实现直接返回、没有调用 super,后续实现便不会因“混入过”而自动执行。

迁移到重试、事务或日志拦截器时,应先写出进入和返回的完整顺序,再决定 trait 排列。重试包在事务外部,意味着每次尝试可能重新开启事务;事务包在重试外部,则可能把多次尝试置于一次事务边界。语言只确定调用链,业务上的幂等性、回滚和重试条件仍需单独设计。

构造事件不能照抄方法进入顺序

完整实验在 Base、两个 trait 和最终类的模板体中各写一条 init: 事件。对 new First,预期记录如下:

1
init:Base,init:AddOne,init:DoubleTotal,init:First

对象首先执行父类构造,然后执行尚需初始化的混入部分,最后执行最终类体。在这个结构里,trait 初始化顺序与方法查找序列中的 trait 次序相反。调用 first.total(10) 产生的另一组事件是:

1
enter:DoubleTotal,enter:AddOne,call:Base,exit:AddOne,exit:DoubleTotal

构造事件表示代码什么时候执行过;方法事件表示这次调用沿哪些实现进入并返回。两组事件必须分开记录。若在构造器内顺手调用 total,同一个日志中就会穿插初始化事件与动态分派事件,不标明事件种类会把两套规律混在一起。

“trait 总是按照源码从左到右初始化”只能作为本例的观察,不能替代一般规则。复杂继承图里,某个 trait 可能已由父类初始化,共享祖先还须去重。推理顺序应是先列最终类线性化,再确定父类构造负责的那一段,最后找出当前模板还需要初始化的部分。

维护一个带副作用的 trait 时,还要检查它的初始化体做了什么。注册回调、访问外部服务、把 this 保存到共享对象,都可能在最终类尚未构造完时发生。把逻辑写进 trait 不会自动推迟执行;只有方法体等延迟到调用的代码,才不因混入动作直接执行。

一种更容易验证的设计是让构造阶段只接收已准备好的依赖,注册与启动由构造完成后的显式步骤触发。这样可以分别测试“对象创建成功”和“服务启动成功”,也能让异常归属于明确的操作。若所有工作仍塞在构造器中,某个依赖初始化失败就可能留下已经注册、却无法使用的对象引用。

trait 参数在什么时候求值

Scala 3 允许 trait 声明参数。参数适合表达初始化时就需要的数据,避免先声明抽象字段,再等待子类稍后赋值。参数表达式在对应 trait 初始化前求值;这并不意味着最终类体已经执行。Scala 3.3.7 trait 参数参考

1
2
3
4
5
6
7
8
trait Label(val label: String):
events += s"init:Label:$label"

final class Named extends Label({
events += "arg:Label"
"ready"
}):
events += "init:Named"

这里的 events 是完整程序在创建对象前已准备好的缓冲区。预期顺序为 arg:Label → init:Label:ready → init:Named。label 能在 trait 体中使用,是因为实参求值和参数传递已经发生。若参数表达式转而读取一个尚未赋值的子类字段,参数语法本身无法修复那个依赖循环。

谁给参数也有约束。当一个类首次通过自身继承引入带参数 trait,它负责传入参数;若父类已引入该 trait,子类不能再次用另一组参数初始化同一份 trait。trait 自身则不能向父 trait 传构造实参,因此下面的写法应被拒绝:

1
2
trait Label(val label: String)
trait BadLabel extends Label("ready")

反例位于 negative/05/trait-argument/。诊断应指向 trait 向父 trait 传构造参数这一限制,而不是把任意编译失败都视为成功。修复方式是让中间 trait 保留参数需求,由最终类明确提供实参。这种约束也让共享祖先的参数来源可以确定,避免两个混入分支分别传入不一致配置。

对 Scala 2 代码迁移而言,trait 参数可以替代一部分“抽象配置成员加子类覆盖”的设计,但不应机械替换所有 def。按需计算且依赖构造后状态的成员,仍有延迟到调用时求值的理由。选择参数、严格字段还是方法,取决于值在哪个时刻已经可用,以及它是否需要反映后续状态。

父构造读取子类 val 的反例

下面的类型关系合法,默认编译也可能接受,但构造时序会产生错误快照:

1
2
3
4
5
6
7
8
9
10
abstract class UnsafeParent:
val name: String
val snapshot: String = name

final class UnsafeChild extends UnsafeParent:
val name: String = "ready"

val child = new UnsafeChild
assert(child.snapshot == null)
assert(child.name == "ready")

UnsafeParent 的抽象 val name 最终由子类实现。父构造器计算 snapshot 时,通过访问器读到的是子类相应字段;但子类体中的 name = "ready" 尚未执行。JVM 上引用字段此时仍是默认值 null,父类把这个值保存进自己的严格字段。子类随后完成赋值,不会让父类的 snapshot 自动重新计算。Scala 官方初始化顺序 FAQ

这个反例没有调用 name.length,所以能够观察对象构造完以后两个字段不同的状态。若父类立即对读到的引用调用方法,故障可能提前表现为空指针异常。两种症状来自同一依赖:父类初始化需要子类尚未提供的状态。把异常吞掉或者给快照加兜底字符串,会掩盖而不会消除这个依赖。

把抽象 val 改成 def 也未必有效。假设子类实现 def name = storedName,而 storedName 仍在子类体稍后赋值,父类调用 name 仍会读到未初始化状态。判断应追到方法体真正读取的字段,不能只检查成员声明使用了哪个关键词。

一个直接修复是把必需输入交给父构造器,同时禁止该访问器再次被覆盖:

1
2
3
4
class SafeParent(final val name: String):
val snapshot: String = name

final class SafeChild extends SafeParent("ready")

父类在计算 snapshot 前已经取得构造参数,且 final 阻止子类用未就绪字段覆盖同名访问器。完整实验断言安全对象的快照为 ready。这里的 final 不是装饰:若设计允许覆盖并在父构造中动态读取那个成员,原来的时序风险仍可能重新出现。

lazy val 也需要按首次访问时间分析。只在对象完成构造后首次读取的派生值,可以避开某些提前读取;若父构造期间已经触发它,依赖未初始化字段的问题仍须逐项检查。业务对象的基础身份、配置和必要依赖,通常更适合显式构造参数,而不是依赖调用者恰好足够晚地读取。

用独立开关验证初始化诊断

Scala 3.3.7 的安全初始化检查使用 -Ysafe-init。冻结提交的编译器选项定义与同版本参考页都使用这一名字;当前滚动文档展示的 -Wsafe-init 不能直接替换本篇的命令。3.3.7 选项定义

从仓库根目录执行默认运行:

先将 JAVA_HOME 指向冻结的 JDK 21.0.11,并使 PATH 中的 Java 与其一致;--jvm system 使用这一 JDK。已完成第 00 篇工具引导时,也可直接运行 python3 examples/scala-lab/run.py chapter 05 --run-id local-05,由 SCALA_LAB_JAVA_HOME 指定 JDK 并自动记录日志。

1
2
3
scala-cli run examples/scala-lab/snippets/05/Chapter05.scala \
--server=false --scala 3.3.7 --jvm system \
--main-class scalaexamples.Chapter05

这条路径有意包含未初始化快照反例,由断言确认其实际状态。它的验收条件是退出码为零,且两组调用事件、trait 参数事件与快照断言全部符合预期。不能给整章强制加上“告警即错误”,再期待危险示例仍作为正例编译成功。

初始化检查使用隔离副本:

1
2
3
scala-cli compile examples/scala-lab/negative/05/safe-init \
--server=false --scala 3.3.7 --jvm system \
--scalac-option -Ysafe-init --scalac-option -Werror

这条命令的预期是非零退出,并指出对未初始化 name 的访问链。-Ysafe-init 产生初始化诊断,-Werror 将告警提升为编译失败;两者作用不能混称为“Scala 默认拒绝”。单独删除 -Werror 后,诊断与进程退出状态也必须分别观察。

检查器是静态分析,官方同版本文档还列出 Java、Scala 2 父类以及全局对象初始化的覆盖限制。它适合作为额外的构造安全检查,不能替代资源生命周期、并发发布和外部调用的测试。本篇没有启用显式空值模式,也没有验证其他 Scala 补丁版本的诊断细节。3.3.7 安全初始化参考

修改继承结构时的验证方法

改动混入顺序之前,先固定最终对象上的调用事件,而不是只为每个 trait 独立测试。trait 中 super 的实际目标依赖组合,独立测试不能覆盖组合变化。实验中的 First、Second 就是同一组实现对应两种结果的最小对照。

涉及构造字段时,把依赖写成“读取者 → 被读取成员”的关系。沿访问器、方法和回调继续追踪,直到找到实际字段的赋值位置。若这条关系从父构造跨到子类体,优先改为显式参数,或者把操作移到构造之后。移动代码时保留失败场景,避免只是将 null 换成另一个看似正常的默认值。

从旧代码迁移还应逐个检查初始化副作用的异常后果。比如 trait A 已注册监听器,trait B 随后抛异常,最终对象没有正常返回,却可能仍被监听器持有。解决这种问题需要明确启动与关闭流程;线性化只能说明 A、B 的执行顺序,不能回滚已经发生的注册。

修改入口 需要固定的观察 对应处理
交换行为 trait 进入、返回事件及最终结果 按最终类推导 super 链
增加构造字段 字段首次读取与赋值时刻 显式参数或推迟操作
使用 trait 参数 参数求值事件与参数来源 在合法类层提供实参
启用初始化检查 诊断语义和退出码 固定版本,区分告警与错误

练习与答案

练习一:保持 First 的混入顺序,将 DoubleTotal.total 改成直接返回 value * 2,不调用 super。输入 10 时,哪些方法事件还会出现?

答案:调用只进入 DoubleTotal,结果为 20;AddOne.total 和 Base.total 不会因混入关系自动执行。构造阶段仍按原有初始化关系执行,因此先前产生的 init:AddOne 并不意味着之后调用过 AddOne.total。

练习二:把 UnsafeParent.snapshot 改成 def snapshot: String = name,只在 new UnsafeChild 完成后读取。它与原来的严格 val 有什么差别?

答案:方法在每次调用时读取 name,不会在父构造期间预先保存那个 null。本题限制读取发生在构造之后,预期得到 ready;如果父构造中调用这个方法,仍须按当时状态分析。修复成立的前提是访问时刻改变,而不是 def 永远安全。

练习三:将 SafeParent 参数上的 final 删除,并允许子类覆盖 name,能否继续把安全性作为父类的独立保证?

答案:不能。父类又允许构造阶段的读取被分派到子类实现,需要重新检查覆盖成员的数据依赖。可以保留不可覆盖的父类私有输入参与快照计算,把对外可覆盖的展示方法放到构造之后使用,两种职责不必共享同一个访问器。

参考资料与系列导航

语言规则以固定提交 07335bb6431fd21eae49628be6a55c8660d06ba6 中的规范与参考文档为依据。本文对编译器源码的核验只覆盖初始化开关定义,没有声称完整验证初始化分析器或字节码降级实现。

系列入口:00 工具链与可复现实验 · 上一篇:04 类对象与值相等 · 下一篇:06 泛型边界与型变

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