第 7 章 空安全:把十亿美元的错误消灭在类型系统里
本章目标
- 从类型论角度理解
T?到底是什么(不是"加了个问号",而是和类型);- 掌握处理 null 的七种工具及各自的使用场景与代价;
- 彻底搞懂平台类型
T!:它从哪来、什么时候检查、为什么是混编项目的头号风险;- 用字节码 + JIT 原理回答"空安全检查的性能开销到底是多少"(答案会让你意外);
- 学会用类型设计消灭 null,而不是到处判空。
7.0 一个数字
Tony Hoare 在 2009 年把 null 引用称为他的"十亿美元错误"(他在 1965 年为 ALGOL W 引入了 null)。
在真实的工程数据里:
- Google 的一项内部研究曾指出,约 1/3 的 Java 崩溃与 null 相关;
- 大量企业的线上 P2/P3 事故复盘中,
NullPointerException长期位居异常榜首; - 而 NPE 最可恶的地方不是"崩溃",而是崩溃的位置远离根因 :
null从 A 方法产生,穿过 B、C、D,在 E 方法里炸掉,堆栈指向 E,排查却要回溯到 A。
java
// 这行代码在哪里 NPE?四种可能
String city = user.getAddress().getCity().getName().toUpperCase();
// ①user ②getAddress() ③getCity() ④getName()
Java 的类型系统对此完全沉默。 这就是 Kotlin 要解决的第一号问题。
7.1 T? 的类型论本质
它是一个"和类型"(Sum Type)
r
T? ≡ T ∪ {null}
用 Kotlin 自己的类型体系表达:
sql
Any? ← 所有类型的根(含 null)
/ \
Any null ← Any 是非空类型的根
|
String, Int, List<T>, ...
🔬 关键关系:
T是T?的子类型 (String <: String?);Nothing是所有类型的子类型;Nothing?是恰好只含null一个值的类型;- 所以
T?可以理解为T | Nothing?。
为什么这个建模是根本性的改进
在 Java 里,String 这个类型的值域包含了 null,但类型系统不表达这一点:
java
String s = getSomething(); // s 可能是 null,类型系统一无所知
s.length(); // 编译器:完全合法 ✅ 运行时:💥
在 Kotlin 里:
kotlin
val s: String = getSomething() // 编译器保证非空
s.length // 永远安全 ✅
val s2: String? = getSomething() // 明确声明可能为空
s2.length // ❌ 编译错误:Only safe (?.) or non-null asserted (!!.) calls are allowed
差异的本质 :Java 把"可能为 null"这个信息丢在文档和人脑里 ;Kotlin 把它编码进类型,于是编译器能强制检查。
这带来的连锁收益
- 函数签名变成契约 :
fun find(id: String): User?一眼看出"入参不能空、返回可能空"。 - 智能转换消除冗余判空:判过一次之后,后续代码直接用非空类型(第 6 章)。
- 重构安全 :把
String?改成String,编译器会列出所有需要处理的地方。 - IDE 能给出精确提示:不是"这里可能有问题",而是"这里是错的"。
⚠️ 但要清醒 :这套保证只在纯 Kotlin 边界内成立。一旦跨越 Java 边界,就出现了"平台类型"这个妥协(见 7.4)。
7.2 处理 null 的七种工具
① 安全调用 ?.
kotlin
val len: Int? = name?.length // name 为 null 则整个表达式为 null
val city = user?.address?.city?.name // 链式,任何一环 null 就短路
🔬 字节码 :?. 编译成 if (x != null) ... else null,一次比较 + 一次分支,没有额外对象分配。
text
// name?.length
aload_0
ifnull L1
aload_0
invokevirtual String.length:()I
invokestatic Integer.valueOf:(I)Ljava/lang/Integer; ← ⚠️ 结果类型是 Int?,装箱!
goto L2
L1:
aconst_null
L2:
⚠️ 注意装箱 :Int?.length 的结果是 Int?,所以装箱了。如果 name 确定非空(用局部 val 固定),可以避免。
② Elvis 运算符 ?:
kotlin
val len = name?.length ?: 0 // 默认值
val n = name ?: return // 提前返回(右侧可以是 return/throw,类型是 Nothing)
val u = user ?: throw NotFoundException() // 抛异常
🔬 ?: 右侧可以是任何表达式,包括 return、throw、break、continue ------因为它们的类型是 Nothing,是任何类型的子类型。这个设计极其优雅:
kotlin
fun process(s: String?) {
val value = s ?: return // 无值就退出,后续代码 value 一定是 String
println(value.length) // ✅ 无需判空
}
③ 非空断言 !!
kotlin
val len = name!!.length // name 为 null 则抛 NullPointerException
⚠️ !! 是"我把责任从编译器手里抢过来" 。它唯一的合法用途是:你比编译器知道得更多(例如某个不变量在别处保证,但编译器无法证明)。
🚀 团队规则 :Detekt 开启 UnsafeCallOnNullableType,把 !! 视为需要 code review 特批的信号。
④ requireNotNull / checkNotNull(推荐替代 !!)
kotlin
val u = requireNotNull(user) { "user must be set before process()" }
val c = checkNotNull(config) { "config not initialized" }
区别在于语义与异常类型:
| 函数 | 抛出 | 语义 |
|---|---|---|
requireNotNull(x) |
IllegalArgumentException |
调用方的错(参数不合法) |
checkNotNull(x) |
IllegalStateException |
自己的错(状态不对,调用时序错误) |
x!! |
NullPointerException |
无信息量 |
error("msg") |
IllegalStateException |
不可达分支 |
🔬 额外收益:它们带 contract ,所以编译器知道调用成功后 x 非空:
kotlin
public inline fun <T : Any> requireNotNull(value: T?): T {
contract { returns() implies (value != null) }
return value ?: throwIllegalArgumentException()
}
kotlin
fun f(user: User?) {
requireNotNull(user)
println(user.name) // ✅ 智能转换生效!不需要 val u = requireNotNull(...)
}
这是 !! 做不到的(!! 不提供数据流信息)。
⑤ ?.let { }
kotlin
user?.let { u ->
println(u.name)
sendEmail(u.email)
}
适合"如果非空,执行一段多语句逻辑"。
⚠️ 一个著名的坑:
kotlin
var x: String? = "a"
x?.let { println(it) } // 通常安全
// 但在多线程环境:
class Holder { var name: String? = "a" }
val h = Holder()
h.name?.let {
// 另一线程在这里把 h.name 设为 null
println(it.length) // ✅ 安全!it 是局部 val,已被固定
}
let 是安全的(因为它把值传给了 lambda 参数),但 ?. 后面直接接属性访问在多线程下不安全:
kotlin
if (h.name != null) {
println(h.name!!.length) // ❌ 竞态!h.name 可能已被置 null
}
// ✅ 正确
h.name?.let { println(it.length) }
val n = h.name; if (n != null) println(n.length) // ✅
👉 这就是"智能转换对 var 属性失效"的深层原因(第 6 章):编译器无法证明多线程下值不变。
⑥ takeIf / takeUnless
kotlin
val positive = number.takeIf { it > 0 } // Int?
val invalid = input.takeUnless { it.isValid() }
把"条件判断"转成"可空值",方便接入 ?. / ?: 链:
kotlin
val result = input
.takeIf { it.isNotBlank() }
?.trim()
?.lowercase()
?: "default"
⑦ Optional 互操作(Java 侧)
kotlin
// Java 返回 Optional<User>
val user: User? = javaRepo.findById(id).orElse(null)
// Kotlin 提供给 Java
fun find(id: String): Optional<User> = Optional.ofNullable(repo.find(id))
⚠️ Kotlin 标准库没有内置 Optional 转换扩展 (历史上实验过 kotlin.jvm.optionals,📌 在较新版本中可用):
kotlin
import kotlin.jvm.optionals.getOrNull
import kotlin.jvm.optionals.orNull
val u: User? = optional.getOrNull()
val o: Optional<User> = user.toOptional() // kotlin.jvm.optionals
🚀 准则 :Kotlin 内部代码不要用 Optional (它设计用于 Java 的返回类型边界,有装箱开销且不支持 Optional<Int> 的原生化)。只在 Java 互操作边界使用。
7.3 平台类型 T!:混编项目的头号风险
它是什么
当 Kotlin 调用一个没有 nullability 注解 的 Java 方法时,返回类型是平台类型(platform type) ,在 IDE 里显示为 String!:
java
// Java
public class Legacy {
public String getName() { return maybeNull(); } // 没有注解
}
kotlin
val name = Legacy().getName() // 类型是 String!(平台类型)
println(name.length) // ✅ 编译通过!运行时可能 NPE 💥
🔬 平台类型的语义 :编译器放弃检查 ,把决定权交给开发者。你可以把它当 String 用(自己承担 NPE 风险),也可以当 String? 用(显式声明)。
kotlin
val a: String = Legacy().getName() // 隐式断言非空(编译器插入运行时检查)
val b: String? = Legacy().getName() // 显式承认可空 ✅ 更安全
⚠️ 第一种写法编译器会插入运行时检查:
text
invokevirtual Legacy.getName:()Ljava/lang/String;
invokestatic Intrinsics.checkNotNullExpressionValue:(Ljava/lang/Object;Ljava/lang/String;)V
如果 Java 返回 null,会抛 NPE,异常信息形如:
kotlin
java.lang.NullPointerException: Legacy.getName() must not return null
📌 这其实比 Java 的裸 NPE 更好 ------它精确指出了"哪个 Java 方法返回了 null"。这是 Kotlin 对混编项目的重要贡献:把模糊的 NPE 变成有信息量的 NPE。
平台类型的完整来源清单
| 来源 | 示例 | 类型 |
|---|---|---|
| Java 方法返回值(无注解) | legacy.get() |
T! |
| Java 字段(无注解) | legacy.field |
T! |
| Java 泛型参数(无注解) | List<String> |
(Mutable)List<String!>! |
| Java 数组元素 | String[] |
Array<(out) String!>! |
| 无注解的第三方库 | 大量老库 | T! |
⚠️ 最阴险的是嵌套的平台类型:
java
// Java
public Map<String, List<User>> getUsers(); // 无注解
kotlin
val users = legacy.getUsers()["admin"]!![0] // 四层可空点,全是平台类型
如何消灭平台类型(按投入产出排序)
方案 1:给 Java 代码加注解(最高 ROI,零运行时风险)
📌 2026 年的注解选择:
| 注解体系 | 包名 | 状态与推荐度 |
|---|---|---|
| JSpecify | org.jspecify.annotations |
✅ 新项目首选 。Google/Meta/JetBrains/Uber 联合制定,类型使用(TYPE_USE)注解 ,能表达 List<@Nullable String>;含 @NullMarked/@NullUnmarked 包级默认 |
| JetBrains | org.jetbrains.annotations |
✅ Kotlin 生态原生识别最好,kotlin-stdlib 已依赖 |
| Spring | org.springframework.lang |
✅ Spring 项目自带,Spring Framework 7 全面标注 |
| Android | androidx.annotation |
✅ Android 项目必备 |
| JSR-305 | javax.annotation |
🔶 历史遗留(FindBugs),已废弃维护 ,需 -Xjsr305 配置才生效 |
| Checker Framework | org.checkerframework.checker.nullness.qual |
🔶 强类型检查,配置复杂 |
| Eclipse | org.eclipse.jdt.annotation |
🔶 Eclipse 生态 |
JSpecify 的杀手特性------@NullMarked 包级默认:
java
// package-info.java
@NullMarked // ← 本包所有类型默认非空!
package com.example.legacy;
import org.jspecify.annotations.NullMarked;
java
// 之后只需标注少数可空的地方
public class UserRepo {
public User find(String id) { ... } // 默认非空 ✅
public @Nullable User findOrNull(String id) { ... } // 显式可空
public List<@Nullable User> findAll() { ... } // 元素可空,列表非空
}
🚀 迁移策略 :给一个包加 @NullMarked,然后逐个修复报出来的 @Nullable。这比逐方法加注解快 10 倍,且默认安全(新写的代码自动非空)。
Kotlin 侧配置:
kotlin
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
// 细粒度控制(可针对不同包用不同严格度):
"-Xjsr305=javax.annotation.Nonnull:strict",
"-Xjsr305=org.springframework.lang:strict",
"-Xjsr305=androidx.annotation:strict",
)
}
}
-Xjsr305 的三档:
ignore:完全忽略注解(默认对某些注解);warn:识别但只警告;strict:识别并作为错误处理。
📌 Kotlin 对 JSpecify 的支持在持续增强 :2.3.20 起识别 Vert.x 的 @Nullable,2.4.x 进一步完善。使用前用 kotlinc -Xjsr305=strict 实测确认你的注解体系被正确识别。
方案 2:在 Kotlin 侧建立"防腐层"(Anti-Corruption Layer)
kotlin
// ❌ 直接暴露平台类型
class UserService(private val legacy: LegacyApi) {
fun find(id: String) = legacy.getUser(id) // 返回 User!
}
// ✅ 在边界处收敛
class UserService(private val legacy: LegacyApi) {
fun find(id: String): User? = legacy.getUser(id) // 显式声明可空
fun findOrThrow(id: String): User =
requireNotNull(legacy.getUser(id)) { "legacy returned null for id=$id" }
}
方案 3:逐步 Kotlin 化(第 3 章的迁移路线)
7.4 空安全检查的运行时成本:几乎为零
这是本章最有价值的技术洞察。
Kotlin 插入了什么
kotlin
fun greet(name: String, age: Int) = "Hi $name, $age"
text
public static final String greet(@NotNull String name, int age) {
0: aload_0
1: ldc #11 // String "name"
3: invokestatic #13 // Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
6: new StringBuilder ...
}
Intrinsics.checkNotNullParameter 的实现(简化):
java
public static void checkNotNullParameter(Object value, String paramName) {
if (value == null) throwParameterIsNullNpe(paramName); // 冷路径
}
成本分析
| 组成部分 | 成本 |
|---|---|
aload_0 |
0(本来就要用这个参数) |
ldc "name" |
0(常量池引用,字符串已 intern,无分配) |
invokestatic |
会被 JIT 完全内联 → 消失 |
if (value == null) |
一次比较 + 一次分支 |
| 分支预测 | 几乎 100% 命中"非空"分支 → CPU 流水线不中断,成本 ≈ 0.1 ns |
| 消息构造 + 抛异常 | 只在冷路径(永不执行),JIT 会把它放到方法末尾的 cold section,不影响 I-cache |
🔬 更深一层:HotSpot 的隐式空检查(Implicit Null Check)
对于普通的解引用 (不是 Kotlin 的参数断言),JIT 甚至连 if (x == null) 都不生成:
css
源码: x.field
JIT 机器码: mov eax, [x + 12] ← 直接访问,没有判空!
如果 x 是 null,CPU 触发 SIGSEGV。HotSpot 注册了信号处理器,它会:
- 检查出错的 PC 是否在 JIT 编译的代码里;
- 查该位置是否登记了"隐式空检查";
- 若是,转成
NullPointerException抛出,栈信息完全正确。
🚀 收益:省掉一次比较和一次分支,代码更短、更快。这就是为什么 JVM 上的 NPE 开销"和正常代码一样"。
⚠️ 代价 :调试时 SIGSEGV 会让某些原生调试器(gdb)困惑;容器里若安装了信号处理器冲突的 agent 可能出问题。可用 -XX:-ImplicitNullChecks 关闭(几乎从不需要)。
结论与优化开关
空安全检查的运行时成本在热路径上可以忽略(< 1%)。它换来的是编译期的强保证和有信息量的运行时错误。这是一笔极其划算的交易。
📌 但如果你确实需要压榨最后一点性能(例如每秒亿次调用的库内部函数),可以关闭:
kotlin
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-Xno-param-assertions", // 去掉参数非空断言
"-Xno-call-assertions", // 去掉"平台类型赋给非空类型"的断言
"-Xno-receiver-assertions", // 去掉扩展函数接收者断言
)
}
}
⚠️ 强烈不建议全局关闭。理由:
- 收益 < 1%,风险是错误信息劣化(从"Parameter specified as non-null is null: method X, parameter y"退化成裸 NPE);
- 失去了 fail-fast,null 会传播更远才炸;
- 更好的做法:把热路径的内部函数声明为
private------Kotlin 对无法被 Java 调用的私有函数通常不生成参数断言。
🧪 实验 7-1:验证哪些函数会生成断言
kotlin
class Assertions {
fun publicFun(s: String) = s.length
private fun privateFun(s: String) = s.length
internal fun internalFun(s: String) = s.length
protected fun protectedFun(s: String) = s.length
}
fun topLevel(s: String) = s.length
private fun privateTopLevel(s: String) = s.length
bash
kotlinc Assertions.kt -d out && javap -c -p out/Assertions.class out/AssertionsKt.class | grep -B2 checkNotNullParameter
观察哪些方法有 checkNotNullParameter,哪些没有。这个实验能让你对"成本在哪"有精确认识。
7.5 Intrinsics 家族全景
Kotlin 标准库在 kotlin.jvm.internal.Intrinsics 里放了一整套运行时检查:
| 方法 | 何时插入 | 抛出的异常信息 |
|---|---|---|
checkNotNullParameter(Object, String) |
公开函数的非空参数 | Parameter specified as non-null is null: method X, parameter y |
checkNotNullExpressionValue(Object, String) |
平台类型表达式赋给非空类型 | X must not return null |
checkNotNullReturn(Object) |
平台类型 Lambda 返回非空 | ... must not return null |
checkNotNull(Object) |
!! 运算符 |
null cannot be cast to non-null type / NPE |
checkNotNullField(Object, String) |
非空字段读取(罕见) | |
checkReturnedValueIsNotNull(Object, String, String) |
重写方法的返回值检查 | |
areEqual(Object, Object) |
== 运算符 |
---(不是检查,是相等实现) |
stringPlus(String, Object) |
String + Any? |
|
throwUninitializedPropertyAccessException(String) |
访问未初始化的 lateinit |
lateinit property X has not been initialized |
throwNpe() / throwAssert() / throwIllegalState() |
各类断言 |
📌 历史变更 :Kotlin 1.4 起,参数空检查抛 NullPointerException 而非 IllegalArgumentException。理由是与 Java 行为一致,且 NPE 语义更准确(不是"参数值非法",而是"参数不该为 null")。
7.6 lateinit 深入
kotlin
class Controller {
lateinit var view: View
fun onCreate() { view = View() }
fun render() {
if (!::view.isInitialized) return // 检查
view.draw()
}
}
精确的约束
| 约束 | 说明 |
|---|---|
只能是 var |
val 用 lazy |
| 不能是基本类型 | Int/Long/Boolean 等不行 → 用 Delegates.notNull<Int>() |
| 不能有自定义 getter/setter | |
| 可以是顶层属性、类属性、局部变量 | 📌 1.2 起支持局部变量 |
不能是 @JvmField 之外的特殊修饰 |
🔬 字节码实现 :lateinit var view: View 就是一个普通的可空字段 private View view(初始为 null),getter 里插入检查:
text
public final View getView();
0: aload_0
1: getfield #8 // Field view:LView;
4: dup
5: ifnonnull L1
8: pop
9: ldc #12 // String "view"
11: invokestatic #14 // Intrinsics.throwUninitializedPropertyAccessException
L1:
...areturn
⚠️ 所以 lateinit 每次读取都有一次判空(但如前所述,成本可忽略)。
lateinit vs lazy vs 构造器注入
🚀 2026 年的最佳实践排序:
- 构造器注入(最优):
kotlin
@Service
class OrderService(private val repo: OrderRepository) // 依赖在构造时保证非空
lazy(次优,用于昂贵计算):
kotlin
private val config by lazy { loadConfig() }
lateinit(仅用于框架强制的字段注入或生命周期回调):
kotlin
// Android
lateinit var binding: ActivityMainBinding
override fun onCreate(...) { binding = ActivityMainBinding.inflate(layoutInflater) }
// JUnit 5
@MockK lateinit var repo: OrderRepository
@BeforeEach fun setup() { MockKAnnotations.init(this) }
// Spring 字段注入(不推荐,但存在)
@Autowired lateinit var service: SomeService
⚠️ lateinit 的三个真实风险:
- 忘记初始化 →
UninitializedPropertyAccessException(好消息:异常信息精确指出属性名); - 多线程下的可见性 →
lateinit字段不是 volatile ,一个线程写入,另一个线程可能读到 null(虽然实践中 JIT 与内存屏障通常让它工作,但这是未定义行为); - Detekt 的
LateinitUsage规则建议开启,强制 review。
7.7 集合与 null:四种组合必须分清
kotlin
val a: List<Int> // 列表非空,元素非空
val b: List<Int?> // 列表非空,元素可空 ← 常见
val c: List<Int>? // 列表可空,元素非空 ← 常见
val d: List<Int?>? // 都可空 ← 尽量避免
对应的 API
kotlin
val list: List<Int?> = listOf(1, null, 3)
list.filterNotNull() // List<Int> = [1, 3]
list.mapNotNull { it?.let { f(it) } }
list.firstOrNull() // Int?
list.getOrNull(0) // Int?
list.indexOfFirst { it == null }
list.count { it == null }
// 空集合判断
if (list.isEmpty()) { }
if (list.isNullOrEmpty()) { } // 处理 List? 的情况
list?.takeIf { it.isNotEmpty() }
// 字符串
if (s.isNullOrBlank()) { } // 可空接收者的扩展函数!
s?.ifBlank { "default" }
s?.ifEmpty { "default" }
🔬 isNullOrBlank() 的秘密:可空接收者的扩展函数
kotlin
public inline fun CharSequence?.isNullOrBlank(): Boolean {
contract { returns(false) implies (this@isNullOrBlank != null) }
return this == null || this.isBlank()
}
接收者本身是可空的 (CharSequence?),所以可以在 null 上调用!而且它带 contract------如果返回 false,编译器就知道接收者非空:
kotlin
val s: String? = getInput()
if (!s.isNullOrBlank()) {
println(s.length) // ✅ 智能转换生效!contract 的功劳
}
🚀 这是 Kotlin 空安全设计最精妙的部分之一:把"判空"这个操作本身做成了带契约的扩展函数。
安全处理嵌套集合
kotlin
// ❌ 危险
val names = users.map { it.profile.name }
// ✅ 逐层安全
val names: List<String> = users.mapNotNull { user ->
user?.profile?.name
}
// ✅ 或先收敛类型
val validUsers: List<User> = users.filterNotNull()
val names = validUsers.mapNotNull { it.profile?.name }
7.8 序列化/反序列化:null 的重灾区
⚠️ 这是一个能让"类型系统保证"彻底失效的场景 :反序列化框架用反射构造对象,绕过构造器,可以把 null 塞进非空字段。
场景 1:Jackson + Kotlin
kotlin
data class User(val id: String, val name: String)
json
{ "id": "1" } // 缺 name 字段!
没有 jackson-module-kotlin 时 :Jackson 用无参构造 + 反射赋值 → name 变成 null → 你拿到一个违反类型系统的 User → 后续 user.name.length NPE。
有 jackson-module-kotlin(必须注册):
kotlin
val mapper = jacksonObjectMapper() // 自动注册 KotlinModule
// 或
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
它会在缺失非空字段时抛 MissingKotlinParameterException(fail-fast)。✅
📌 Spring Boot 的 jackson-module-kotlin 已自动注册 (只要 classpath 上有),但手动 new ObjectMapper() 就不会------这是常见 bug。
场景 2:默认值与缺失字段
kotlin
data class Config(
val host: String = "localhost",
val port: Int = 8080,
val timeout: Duration? = null,
)
⚠️ Jackson 默认不使用 Kotlin 的默认参数值(它调用全参构造器)。要让默认值生效:
kotlin
KotlinModule.Builder()
.withReflectionCacheSize(512)
.nullIsSameAsDefault(false) // 默认 false
.build()
或者在字段上加 @JsonProperty(defaultValue = "..."),或用 kotlinx.serialization(原生支持默认值)。
场景 3:kotlinx.serialization(推荐)
kotlin
@Serializable
data class Config(
val host: String = "localhost",
val port: Int = 8080,
val timeout: Long? = null,
)
val c = Json.decodeFromString<Config>("""{"port":9090}""")
// ✅ host 用默认值 "localhost",port=9090,timeout=null
🔬 它是怎么做到的 :kotlinx.serialization 的编译器插件生成 $serializer 类,其中 deserialize 方法用一个**位掩码(seen)**追踪哪些字段出现过,最后调用合成的 write$Self / 带默认值的构造器(<init>(...; int seen; DefaultConstructorMarker))。完全不依赖反射,所以:
- 快(比 Jackson 快 2--5 倍);
- 支持 GraalVM Native Image(无反射元数据配置);
- 严格保持类型系统保证。
📌 注意 :kotlinx.serialization 要求显式注册 (Json {} 或 Spring 的 ContentNegotiation),且不支持多态默认 (需要 @JsonClassDiscriminator + SerializersModule)。
场景 4:数据库映射
kotlin
// ❌ 危险:JPA/Hibernate 用无参构造 + 反射赋值,可能违反非空约束
@Entity
class Order @JvmOverloads constructor(
@Id val id: String = "",
@Column(nullable = false) val amount: Long = 0,
)
🚀 准则:
- JPA 实体用
var+ 合理默认值 ,或用lateinit(但注意 6.6 的可见性风险); - 不要用
data class做 JPA 实体 (equals/hashCode与懒加载代理冲突); - 优先用投影接口 / DTO做读操作,把可空性收敛在映射层。
7.9 用类型设计消灭 null(进阶)
判空是"补丁",类型设计才是"根治"。
模式 1:用密封类表达"缺失的原因"
kotlin
// ❌ null 丢失了信息
fun findUser(id: String): User? = null // 是"不存在"还是"没权限"还是"服务挂了"?
// ✅ 类型携带信息
sealed interface FindResult {
data class Found(val user: User) : FindResult
data object NotFound : FindResult
data class Forbidden(val reason: String) : FindResult
data class Failed(val cause: Throwable) : FindResult
}
fun findUser(id: String): FindResult = ...
when (val r = findUser(id)) { // 穷尽性检查强制处理所有情况
is Found -> render(r.user)
is NotFound -> show404()
is Forbidden -> show403(r.reason)
is Failed -> alert(r.cause)
}
收益 :编译器强制 你处理每一种情况,不可能"忘记"。这是 null 永远做不到的。
模式 2:用 value class 表达"已验证的值"
kotlin
// ❌ 到处判空 + 到处校验
fun process(email: String?) {
if (email == null) return
if (!email.contains("@")) return
// ...
}
// ✅ 类型即保证
@JvmInline value class ValidEmail private constructor(val value: String) {
companion object {
fun of(raw: String?): ValidEmail? =
raw?.takeIf { it.contains("@") && it.length <= 254 }
?.let { ValidEmail(it) }
}
}
fun process(email: ValidEmail) {
// 到这里,email 一定非空且合法。无需任何检查。
send(email.value)
}
这叫 "parse, don't validate"(Alexis King):在边界处解析成强类型,内部代码就再也不需要检查。
模式 3:NonEmpty 集合
kotlin
@JvmInline value class NonEmptyList<out T>(private val list: List<T>) {
init { require(list.isNotEmpty()) { "list must not be empty" } }
val first: T get() = list.first()
val size: Int get() = list.size
companion object {
fun <T> of(first: T, vararg rest: T) = NonEmptyList(listOf(first) + rest)
fun <T> from(list: List<T>): NonEmptyList<T>? =
list.takeIf { it.isNotEmpty() }?.let { NonEmptyList(it) }
}
}
fun average(xs: NonEmptyList<Int>): Double = ... // 签名保证不会除零
模式 4:Optional 语义的 Kotlin 替代
kotlin
// Java 风格
Optional<User> find(String id);
// Kotlin 风格(更简单,无装箱)
fun find(id: String): User?
// 需要携带"为什么没有"时
fun find(id: String): Result<User>
sealed interface Lookup<out T> { data class Ok<T>(val v: T): Lookup<T>; data class Err(val why: String): Lookup<Nothing> }
7.10 本章小结
T?是和类型T ∪ {null},把"可能为空"从文档搬进类型系统。这是 Java 因向后兼容而永远无法追溯实现的能力。- 七种工具各有场景 :
?.(链式)、?:(默认值/提前退出)、requireNotNull/checkNotNull(带 contract,优于!!)、?.let(多语句块)、takeIf(条件转可空)、!!(最后手段)。 - 平台类型
T!是 Kotlin 对 Java 无注解代码的妥协 ------编译器放弃检查,风险留给你。消灭它的最佳手段是给 Java 包加@NullMarked(JSpecify)+-Xjsr305=strict,投入产出比最高且零运行时风险。 - 空安全检查的运行时成本 < 1% :Kotlin 的
checkNotNullParameter是一次完美预测的分支;而普通解引用在 JIT 后走 HotSpot 隐式空检查(SIGSEGV + 信号处理器) ,成本为零。不要为了这点性能关掉断言。 lateinit是普通可空字段 + getter 检查 ,不支持基本类型,非 volatile (多线程有风险)。优先级:构造器注入 >lazy>lateinit。- 序列化是类型系统保证的漏洞 :Jackson 必须注册
KotlinModule;kotlinx.serialization 用编译器插件 + 位掩码,不依赖反射,是更安全更快的选择。 - 最高级的空安全是不写判空代码 :用密封类表达缺失原因、用
value class表达已验证的值、用NonEmptyList表达非空集合------parse, don't validate。
7.11 练习题
7-1. 下列代码哪一行会抛异常?异常类型和信息是什么?
kotlin
class Repo {
// Java 侧:public String getName() 可能返回 null,无注解
private val legacy = LegacyApi()
fun a(): Int = legacy.name.length
fun b(): Int { val n: String = legacy.name; return n.length }
fun c(): Int? = legacy.name?.length
fun d(): Int = legacy.name!!.length
fun e(): Int = requireNotNull(legacy.name).length
}
答案要点
假设 legacy.name 返回 null:
- a() :
legacy.name是平台类型String!,直接.length→NullPointerException(裸的,无信息) 。因为编译器没有插入检查(你把平台类型当作非空使用,但没有赋值给显式非空类型的变量)。 ⚠️ 实际行为取决于编译器版本与上下文;Kotlin 通常不会 在平台类型.成员时插入检查,所以是标准 NPE,堆栈指向该行。 - b() :显式赋给
String(非空类型)→ 编译器插入checkNotNullExpressionValue→NullPointerException: legacy.getName() must not return null(有信息量!) - c() :安全调用 → 返回
null,不抛异常。 - d() :
!!→NullPointerException(Kotlin 的checkNotNull,信息为null)。 - e() :
requireNotNull→IllegalArgumentException(无 message,因为没提供 lambda)。推荐写requireNotNull(x) { "context" }。
关键教训 :b() 比 a() 好------它把"我假设这里非空"这个决定显式化 了,并换来有信息量的异常。这就是为什么应该总是显式声明类型而不是依赖平台类型推断。
7-2. 为什么 isNullOrBlank() 之后编译器能做智能转换,而 x == null || x.isBlank() 不行?
答案要点
isNullOrBlank() 是带 contract 的扩展函数:
kotlin
public inline fun CharSequence?.isNullOrBlank(): Boolean {
contract { returns(false) implies (this@isNullOrBlank != null) }
return this == null || this.isBlank()
}
contract 告诉编译器:"如果我返回 false,那么接收者一定非空"。所以在 if (!s.isNullOrBlank()) 分支里,s 被智能转换为非空。
而 x == null || x.isBlank() 是普通的布尔表达式 ,编译器的数据流分析能处理 x == null 的部分(在 || 右侧知道 x != null),但在整个 if (!(...)) 取反后,分析能力受限------它无法从"复合表达式的否定"推导出"x 非空"。
深层原因 :contract 是开发者向编译器提供的额外公理,让数据流分析能跨越函数边界。这是 Kotlin 独有的能力(Java 没有对应机制)。第 9 章详解 contract 的四种形式。
7-3. (🧪 动手)写一段代码验证:private 函数是否生成 checkNotNullParameter?internal 呢?inline 函数呢?
答案要点
见 7.4 的实验 7-1。预期结果(Kotlin 2.4.x,可能随版本变化):
| 可见性 | 是否生成断言 |
|---|---|
public |
✅ |
protected |
✅ |
internal |
✅(因为它编译成 public,Java 可调用) |
private(类成员) |
❌ 通常不生成(Java 无法调用) |
private(顶层) |
❌ |
inline public |
✅ 但断言被内联到调用点(每个调用点一份) |
| 局部函数 / lambda | ❌ |
验证 :javap -c -p 后 grep -c checkNotNullParameter。
实践含义 :性能敏感的内部函数声明为 private 可以自动获得"零断言",不需要 -Xno-param-assertions。
7-4. 你的团队有一个 10 万行的 Java 遗留模块,Kotlin 新模块要调用它。设计一个 6 周的空安全治理方案。
答案要点
第 1 周|测量
- 统计 Kotlin 侧出现的平台类型数量(IDEA 的
T!提示 / 自定义 Detekt 规则 /javap扫描无注解的 public 方法); - 统计过去半年的 NPE 事故,定位高发模块。
第 2 周|基础设施
- 引入 JSpecify 依赖;
- Kotlin 侧加
-Xjsr305=strict; - CI 加一条规则:新增的 Kotlin 文件禁止出现
T!类型(用 Detekt/自定义检查或allWarningsAsErrors)。
第 3--5 周|按包推进 @NullMarked
- 从被 Kotlin 调用最多的包开始(收益最大);
- 每个包:加
@NullMarked→ 编译 → 逐个给真正可空的地方加@Nullable→ 跑测试; - 每天 1--2 个包,配 code review;
- 不修改任何逻辑,纯注解,零运行时风险。
第 6 周|防腐层与验收
- 对无法加注解的第三方库,在 Kotlin 侧建 wrapper(显式声明
T?); - 度量:平台类型数量下降百分比、NPE 事故数、编译告警数;
- 把
@NullMarked覆盖率纳入团队指标。
长期 :新 Java 代码强制 @NullMarked;核心模块逐步 Kotlin 化。
7-5. 为什么 data class User(val name: String) 在 JSON 反序列化后 name 可能是 null?三种框架分别如何防护?
答案要点
根因 :JVM 上对象的创建可以绕过构造器(Unsafe.allocateInstance、ReflectionFactory.newConstructorForSerialization、Objenesis),字段默认值是 null。Kotlin 的非空约束是编译期的,运行时字段就是普通引用,没有强制。
防护:
- Jackson +
jackson-module-kotlin:注册KotlinModule后,缺失非空参数会抛MissingKotlinParameterException(因为它用 Kotlin 的主构造器 +KParameter反射信息来构造,能识别非空约束)。⚠️ 手动new ObjectMapper()不会自动注册。 - kotlinx.serialization :编译器插件生成序列化器,直接调用主构造器;缺失且无默认值 →
MissingFieldException。不使用反射,无法被绕过。 - Gson :历史上用
Unsafe创建实例 → 会绕过所有检查,产生违反类型系统的对象 。📌 新版 Gson(2.11+)对 Kotlin 有改进,但仍需谨慎。解决方案:改用 kotlinx.serialization / Moshi(带 KSP codegen,Moshi 的KotlinJsonAdapterFactory反射版也有类似问题,必须用 codegen 版)。
通用防线 :反序列化后立即做一次校验(requireNotNull 或 Bean Validation),或用带 init 块的类做运行时断言。
7.12 面试题精选
Q1:Kotlin 的空安全和 Java 的 Optional / @Nullable 有什么本质区别?
答题框架:
- 层次不同 :Kotlin 是类型系统级 (编译期强保证,不可绕过);
@Nullable是注解约定 (需工具配合,可绕过);Optional是库级包装 (只对返回类型有效,字段/参数不能用,且get()可绕过)。 - 覆盖面 :Kotlin 覆盖字段、参数、返回值、泛型参数(
List<String?>)、数组、接收者;Optional官方明确不推荐用于字段和参数。 - 成本 :Kotlin 的可空类型在非泛型位置零开销 (就是引用 + 判空);
Optional每次创建一个对象(16 字节)。 - 失败模式 :Kotlin 失败在编译期;
@Nullable失败在 CI(如果有静态检查);Optional失败在运行时(NoSuchElementException)。 - 加分:提到平台类型是妥协、
contract让isNullOrBlank支持智能转换、序列化框架可绕过类型系统。
Q2:!! 有什么替代方案?为什么 requireNotNull 更好?
答题框架:
- 替代方案(优先级递减):局部
val固定 + 智能转换;?.let;?:提前返回/默认值;requireNotNull/checkNotNull;类型重设计(密封类、value class、NonEmptyList)。 requireNotNull更好的三个理由:- 带 contract ,调用后编译器知道非空 → 支持智能转换,
!!不支持; - 异常类型语义化 (
IllegalArgumentException= 调用方错 /IllegalStateException= 状态错),!!抛裸 NPE; - 可携带上下文信息(lambda 惰性构造消息,仅在失败时求值)。
- 带 contract ,调用后编译器知道非空 → 支持智能转换,
- 工程手段:Detekt
UnsafeCallOnNullableType规则强制 review。
Q3:Kotlin 的空安全检查有性能开销吗?请从字节码和 JIT 两个层面回答。
答题框架:
- 字节码层面 :公开函数入口插入
Intrinsics.checkNotNullParameter(value, "name");平台类型赋给非空类型时插入checkNotNullExpressionValue。 - JIT 层面 :
Intrinsics方法是热点,会被完全内联 ,最终只剩if (x == null) goto cold_path;- 该分支几乎 100% 不跳转 → CPU 分支预测完美命中 → 成本 ≈ 0.1 ns;
- 抛异常的代码被放到方法的 cold section,不污染 I-cache;
ldc "name"只是常量池引用,无对象分配。
- 更进一步 :普通解引用(非 Kotlin 断言)JIT 会用 隐式空检查 ------直接生成访存指令,靠 SIGSEGV + 信号处理器转成 NPE,零判空指令。
- 结论 :整体开销 < 1%,远低于它避免的 bug 成本。可用
-Xno-param-assertions关闭但不推荐;更好的办法是把热路径函数设为private(通常不生成断言)。
Q4:什么是平台类型?它带来了什么风险?如何治理?
答题框架:
- 定义:Kotlin 调用无 nullability 注解 的 Java 代码时,类型为
T!(platform type)。编译器放弃检查 ,可当T或T?用。 - 风险:
T!的成员访问不报错,运行时 NPE;嵌套泛型Map<String, List<User>>会产生多层平台类型;重构时无编译器保护。 - 治理(三层):
- Java 侧加注解 :JSpecify
@NullMarked包级默认 +@Nullable例外(最高 ROI,零运行时风险); - Kotlin 侧配置 :
-Xjsr305=strict+ 细粒度包配置; - 防腐层 :在边界显式声明
T?,用requireNotNull收敛,禁止平台类型流入领域层。
- Java 侧加注解 :JSpecify
- 加分:提到 Kotlin 插入的
checkNotNullExpressionValue会给出"X must not return null"这样有信息量的异常,比 Java 裸 NPE 更易排查。
下一章预告 :第 8 章《控制流与 when》------when 不只是 switch。我们会讲:卫语句(guard conditions,📌 2.2 稳定)如何让你写出更精确的分支;数据流驱动的穷尽性检查(📌 2.3 稳定)如何捕捉"你以为覆盖全了其实没有"的 bug;以及 when 在字节码里到底变成了 tableswitch、lookupswitch 还是 if-else 链------这直接决定它的性能。