把任务文本传进构造函数,就能立刻把对象交给其他代码使用吗?还要看属性初始值、init、自定义访问器和懒加载分别在什么时候执行。尤其危险的是在父类构造期间调用可被子类覆盖的方法:此时方法已经可能分派到子类,子类的字段却还没初始化。本篇用一个最小 Task 记录创建路径,再用隔离在测试中的派生类证明这个反例。

沿用 08 的任务输入与标签变换,09 才首次出现真正的 Task 类。它仍只是内存对象的初始化实验:之前的 submit CLI 没有创建这个类,Task 也不执行、排队或持久化。工具链仍是 Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14。

构造参数、属性和 init 不在同一个时间点

Task 接受原始文本与一个仅供实验记录顺序的列表。核心声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Task(rawText: String, private val events: MutableList<String>) {
val text: String = normalizeTaskText(rawText, events)

init {
events.add("init:$text")
require(text.isNotEmpty()) { "Task text must not be blank" }
}

var displayName: String = text
set(value) {
require(value.isNotBlank()) { "Display name must not be blank" }
field = value.trim()
events.add("set:$field")
}

lateinit var owner: String

val label: String by lazy {
events.add("lazy:$text")
"task:$text"
}
}

按照类的构造器和初始化块文档,属性初始化器与 init 按源码声明顺序执行。传入 " alpha ",normalizeTaskText 先调用 trim() 并记录 "normalize:alpha",然后 init 记录 "init:alpha"。构造后 text 与 displayName 均为 "alpha"。测试此时只看到这两条事件:属性声明时给 displayName 的初始值没有当成一次对外的 setter 赋值,也没有触发 label 的 lazy 求值。

构造 Task(" ", events) 则在 init 抛 IllegalArgumentException("Task text must not be blank");测试见到的事件仍是 "normalize:"、"init:"。失败意味着调用方没有拿到正常构造完成的 Task,但传入的外部 events 列表已经被改动了。把日志、文件或外部副作用放在构造过程里,需要另外考虑异常路径;不能从“对象构造失败”推断外部世界自动回滚。

访问器校验了赋值,不能替初始化和后续状态建模

属性 displayName 的 setter 使用 field 写入 backing field;Kotlin backing field 文档解释了这个特殊标识符的用途。构造完成后执行 task.displayName = " visible ",得到值 "visible",事件再增加 "set:visible"。随后试图赋空白字符串,setter 的 require 抛异常,原值仍保持为 "visible"。这里只是局部赋值校验,不是事务或真实任务状态转移。

owner 用 lateinit var 专门演示“类型上非空,但初始化可延后”的风险。对尚未赋值的 task.owner 读取会抛 UninitializedPropertyAccessException,赋成 "worker" 后才可正常读。官方 lateinit 说明并没有承诺它帮调用方自动完成赋值。真实请求的可选字段更适合先从 05 的可空/字段存在性边界建模,而不是把外部缺失值藏进未初始化的属性。

label 使用 lazy 委托。构造完成时还没有 "lazy:alpha";第一次读取返回 "task:alpha" 并新增一次事件,第二次读取返回同样结果,事件不再增加。本章所有事件都在同一测试线程顺序执行;不从这个结果推断并发发布、初始化锁或任务计算生命周期。

父类构造期不要依赖子类已经准备好

只看 Task 的普通构造,仍看不出可继承类的一个陷阱。测试文件另外定义 BaseTaskTrace:在基类 init 调用可覆盖的 description();DerivedTaskTrace 覆盖它,返回自己的字段 detail,声明初始值为 "ready"。实际事件顺序是:

1
2
base:null
derived:ready

先执行基类初始化,此时动态分派已经进入子类的覆盖方法,但子类属性初始值尚未执行。在本次冻结的 Kotlin/JVM 工具链上,覆盖方法读到的字段仍是 null;子类初始化以后才是 "ready"。Kotlin 派生类初始化顺序明确提醒不要在基类构造路径中调用开放成员。这里的 "base:null" 是本次运行观察,不是要求所有平台和未来生成字节码必须逐字输出同一事件的语言保证。生产 Task 没有在构造器内调用 open 成员;危险写法只留在测试反例。

这个边界与 lateinit 的失败不同:前者是尚未执行的子类初始化与动态分派相撞;后者是显式推迟初始化却过早读取。两者都不能用“属性声明了非空 String”来证明对象任意时点均已满足完整不变量。

命令和证据的实际范围

仓库内执行 cd examples/kotlin-language && bash verification/09.sh。脚本保存 UTC 时间、JDK/Gradle 环境与原始输出,离线 clean test 退出 0,00–09 十组各 2/2。新增 TaskTest 分别断言构造、setter、lateinit、lazy 与开放成员的负例;旧 CLI submit alpha 退出 0、缺文本退出 1,不代表 CLI 已创建 Task。完整断言和测试 XML 在 writing-plans/kotlin/verification/09/。

下载含Wrapper、完整累计工程与09证据的快照(SHA-256校验)。素材目录提供独立解包命令;本篇尚无任务服务、状态机、数据库或真实网络链路。

练习与答案

练习一:构造 Task(" alpha ", events) 后尚未读取 label,events 有哪些项?答:按顺序只有 normalize:alpha 与 init:alpha;setter 不因属性声明的初值触发,lazy 要等首次读取。

练习二:Task(" ", events) 抛异常后,能否认定没有副作用?答:不能。构造未成功返回 Task,但调用方传来的可变 events 已记录 normalize: 和 init:。构造失败与撤销外部副作用是两个问题。

练习三:为何子类明明写了 private val detail = "ready",基类却记录 base:null?答:基类初始化先于子类属性初始化,却调用了可覆盖的方法;动态分派进入子类时 detail 尚未赋初值。不要在基类构造过程依赖派生对象已经完整。

下一篇研究 data class 的生成成员、值比较和 copy():即使 Task 类拥有了属性,也不能把对象复制默认理解为“所有内部对象都独立了”。