一次任务请求没有填备注,与传了 "note": null,服务端该不该按同一种操作处理?如果还允许 "note": "",仅凭 String? 已经不够区分三种输入。空安全解决“拿到的引用可否为 null”,却不会替业务决定“字段缺席”意味着保留原值、清空备注,还是拒绝请求。

本篇沿用 04 的合成任务输入,用一个可控 Map<String, String?> 模拟外部字段,不声称已经有 JSON 解析或 HTTP 接口。Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14 的行为与编译诊断分别留在累计工程的 05 证据目录。

可空类型只能回答“值可能是 null 吗”

String 的非空声明会拒绝 null;String? 则要求调用处考虑空值。Kotlin 空安全文档给出这条静态类型边界。独立的 verification/05-nonnull-rejection.kt 写了 val invalidNote: String = null,在冻结版本下 compileKotlin 退出 1,实际诊断为 Null cannot be a value of a non-null type 'String'.。这个样本刻意不在默认源码集里;否则正常工程也会一直构建失败。

但查表操作 fields["note"] 返回 String? 时,缺键和有键而值为 null 都得到 null。这里不能用一次 Elvis 给业务状态定案:

1
2
3
4
5
6
7
8
9
fun noteStatus(fields: Map<String, String?>): String {
if (!fields.containsKey("note")) return "missing"
val note: String? = fields["note"]
return when {
note == null -> "explicit-null"
note.isEmpty() -> "empty"
else -> "value:$note"
}
}

先检查键是否存在,再看值:emptyMap() 是 missing;mapOf("note" to null) 是 explicit-null;mapOf("note" to "") 是 empty。mapOf("note" to "hello") 得 value:hello。空格字符串 " " 仍算有值,不被这个函数偷偷 trim() 成空串。四个分支的选择是本实验的输入契约,不是 Kotlin 为所有 API 规定的含义;真正给用户更新备注时还需要明确每种状态对应的操作。

这里使用 Map 只是为了模拟键是否存在;06 才讨论集合只读视图与可变对象,09 以后才引入真实任务类。眼前的 noteStatus 不创建任务,也不修改持久数据。

安全调用和 Elvis 可能主动合并两种状态

另一个函数只服务于“显示长度”:

1
fun noteDisplayLength(note: String?): Int = note?.length ?: 0

按照安全调用 ?.,note 为 null 时整个 note?.length 为 null;按照 Elvis ?:,只有左侧为 null 时才取 0。因此 noteDisplayLength(null) 和 noteDisplayLength("") 都返回 0:前者通过回退得到 0,后者本来就是长度 0。这个结果适合某些显示场景,却丢失了“没有备注”和“刻意清空”的区别;不能反过来据此更新数据。"hello" 的结果为 5,证明正常值没有被默认值覆盖。

可以画出两个不同的类型问题:

1
2
3
4
字段存在性:    缺键 ───────┐
├─ 查表后都可能得到 String? = null
字段值: 有键且为 null ┘
有键且为 "" ────── String? != null,但 length == 0

如果接口把缺席与显式 null 合并,应该是经过讨论的接口契约,而不是 Map.get 结果碰巧让两个分支无法区分。这个模拟没有提供 JSON 的“缺字段/显式 null”反序列化保证;33 的序列化实验需要重新核对具体 DTO 与库版本。

!! 只是把风险移到运行时

unsafeNoteLength(note: String?): Int = note!!.length 编译通过,但当传入 null,!! 在运行时抛 NullPointerException;"hello" 可以返回 5。官方 not-null assertion 文档描述的正是这种“开发者自己保证非空”的断言。它没有校验字段是否存在,也不能把一个未经验证的外部值变成可信输入。

测试还在局部测试类中声明 lateinit var value: String,初始化前读取会抛 UninitializedPropertyAccessException,赋值 "ready" 后可以正常读取。Kotlin 属性文档的 lateinit 小节限定的是推迟初始化的规则;它不等于自动初始化或并发安全。本篇不在生产模型里引入 lateinit 备注,这段只用来暴露风险。

即使 Kotlin 自身写下 String,后续调用 Java API 时也不能直接推断 Java 值遵循相同保证。Java 互操作文档的平台类型小节指出 Java 声明可能以 platform type 进入 Kotlin;19 会用 Java 适配器实际验证。目前的 Map 是可控 Kotlin 实验,没有混编行为证据,也不能说 Kotlin 彻底消除了 NPE。

本章证据和未触达的路径

在仓库根目录执行 cd examples/kotlin-language && bash verification/05.sh:脚本记录 UTC 环境、JDK 与 Gradle;离线 clean test 退出 0,00–05 六组测试各 2/2,05 的断言包含缺键、显式 null、空串、非空值、空格、!! 和 lateinit 的失败路径。隔离编译拒绝退出 1。旧 CLI 的 submit alpha 退出 0、缺文本 submit 退出 1,但 CLI 尚未读取备注字段;这两条只检验旧路径没有回归。原始输出与逐项断言在 writing-plans/kotlin/verification/05/。

下载含Wrapper、源码与05证据的累计工程(SHA-256校验)。同名素材目录给出解包命令、版本与依赖前提;测试通过也不表示真实 JSON/HTTP/数据库或 Java 混编已完成。

练习与答案

练习一:noteDisplayLength(null) == noteDisplayLength(""),能否据此认定两种备注输入等价?答:不能;左侧走 ?: 0,右侧是字符串本身长 0。显示结果相同不代表更新语义相同,还要检查键是否存在、备注值是否为 null。

练习二:noteStatus(mapOf("note" to " ")) 的结果是什么?答:value: (冒号后保留空格)。本函数只检查 isEmpty(),没有检查 isBlank() 或删去空白;变更这个判断需同步改输入契约和断言。

练习三:既然 String 拒绝 null,为何仍要测 !! 和 lateinit?答:String? 可以明确地包含 null,!! 把处理责任放到调用者并可能运行时抛异常;lateinit 在赋值前没有可读取的值。非空声明不等于任意路径都已正确初始化,更不能代替对外部边界的检查。

下一篇检查另一个容易被“只读”一词遮蔽的边界:拿到 List 接口,是否意味着背后的对象不会再变化。