Kotlin与Java虚拟机七:空安全:把十亿美元的错误消灭在类型系统里

第 7 章 空安全:把十亿美元的错误消灭在类型系统里

本章目标

  1. 从类型论角度理解 T? 到底是什么(不是"加了个问号",而是和类型);
  2. 掌握处理 null 的七种工具及各自的使用场景与代价;
  3. 彻底搞懂平台类型 T!:它从哪来、什么时候检查、为什么是混编项目的头号风险;
  4. 用字节码 + JIT 原理回答"空安全检查的性能开销到底是多少"(答案会让你意外);
  5. 学会用类型设计消灭 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 把它编码进类型,于是编译器能强制检查。

这带来的连锁收益

  1. 函数签名变成契约 :fun find(id: String): User? 一眼看出"入参不能空、返回可能空"。
  2. 智能转换消除冗余判空:判过一次之后,后续代码直接用非空类型(第 6 章)。
  3. 重构安全 :把 String? 改成 String,编译器会列出所有需要处理的地方。
  4. 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 注册了信号处理器,它会:

  1. 检查出错的 PC 是否在 JIT 编译的代码里;
  2. 查该位置是否登记了"隐式空检查";
  3. 若是,转成 NullPointerException 抛出,栈信息完全正确。

🚀 收益:省掉一次比较和一次分支,代码更短、更快。这就是为什么 JVM 上的 NPE 开销"和正常代码一样"。

⚠️ 代价 :调试时 SIGSEGV 会让某些原生调试器(gdb)困惑;容器里若安装了信号处理器冲突的 agent 可能出问题。可用 -XX:-ImplicitNullChecks 关闭(几乎从不需要)。

结论与优化开关

空安全检查的运行时成本在热路径上可以忽略(< 1%)。它换来的是编译期的强保证和有信息量的运行时错误。这是一笔极其划算的交易。

📌 但如果你确实需要压榨最后一点性能(例如每秒亿次调用的库内部函数),可以关闭:

kotlin 复制代码
kotlin {
    compilerOptions {
        freeCompilerArgs.addAll(
            "-Xno-param-assertions",      // 去掉参数非空断言
            "-Xno-call-assertions",       // 去掉"平台类型赋给非空类型"的断言
            "-Xno-receiver-assertions",   // 去掉扩展函数接收者断言
        )
    }
}

⚠️ 强烈不建议全局关闭。理由:

  1. 收益 < 1%,风险是错误信息劣化(从"Parameter specified as non-null is null: method X, parameter y"退化成裸 NPE);
  2. 失去了 fail-fast,null 会传播更远才炸;
  3. 更好的做法:把热路径的内部函数声明为 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 年的最佳实践排序:

  1. 构造器注入(最优):
kotlin 复制代码
@Service
class OrderService(private val repo: OrderRepository)   // 依赖在构造时保证非空
  1. lazy(次优,用于昂贵计算):
kotlin 复制代码
private val config by lazy { loadConfig() }
  1. 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 的三个真实风险:

  1. 忘记初始化 → UninitializedPropertyAccessException(好消息:异常信息精确指出属性名);
  2. 多线程下的可见性 → lateinit 字段不是 volatile ,一个线程写入,另一个线程可能读到 null(虽然实践中 JIT 与内存屏障通常让它工作,但这是未定义行为);
  3. 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,
)

🚀 准则:

  1. JPA 实体用 var + 合理默认值 ,或用 lateinit(但注意 6.6 的可见性风险);
  2. 不要用 data class 做 JPA 实体 (equals/hashCode 与懒加载代理冲突);
  3. 优先用投影接口 / 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 本章小结

  1. T? 是和类型 T ∪ {null} ,把"可能为空"从文档搬进类型系统。这是 Java 因向后兼容而永远无法追溯实现的能力。
  2. 七种工具各有场景 :?.(链式)、?:(默认值/提前退出)、requireNotNull/checkNotNull(带 contract,优于 !! )、?.let(多语句块)、takeIf(条件转可空)、!!(最后手段)。
  3. 平台类型 T! 是 Kotlin 对 Java 无注解代码的妥协 ------编译器放弃检查,风险留给你。消灭它的最佳手段是给 Java 包加 @NullMarked(JSpecify)+ -Xjsr305=strict,投入产出比最高且零运行时风险。
  4. 空安全检查的运行时成本 < 1% :Kotlin 的 checkNotNullParameter 是一次完美预测的分支;而普通解引用在 JIT 后走 HotSpot 隐式空检查(SIGSEGV + 信号处理器) ,成本为零。不要为了这点性能关掉断言。
  5. lateinit 是普通可空字段 + getter 检查 ,不支持基本类型,非 volatile (多线程有风险)。优先级:构造器注入 > lazy > lateinit。
  6. 序列化是类型系统保证的漏洞 :Jackson 必须注册 KotlinModule;kotlinx.serialization 用编译器插件 + 位掩码,不依赖反射,是更安全更快的选择。
  7. 最高级的空安全是不写判空代码 :用密封类表达缺失原因、用 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 的非空约束是编译期的,运行时字段就是普通引用,没有强制。

防护:

  1. Jackson + jackson-module-kotlin :注册 KotlinModule 后,缺失非空参数会抛 MissingKotlinParameterException(因为它用 Kotlin 的主构造器 + KParameter 反射信息来构造,能识别非空约束)。⚠️ 手动 new ObjectMapper() 不会自动注册。
  2. kotlinx.serialization :编译器插件生成序列化器,直接调用主构造器;缺失且无默认值 → MissingFieldException。不使用反射,无法被绕过。
  3. 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 更好?

答题框架:

  1. 替代方案(优先级递减):局部 val 固定 + 智能转换;?.let;?: 提前返回/默认值;requireNotNull/checkNotNull;类型重设计(密封类、value class、NonEmptyList)。
  2. requireNotNull 更好的三个理由:
    • 带 contract ,调用后编译器知道非空 → 支持智能转换,!! 不支持;
    • 异常类型语义化 (IllegalArgumentException = 调用方错 / IllegalStateException = 状态错),!! 抛裸 NPE;
    • 可携带上下文信息(lambda 惰性构造消息,仅在失败时求值)。
  3. 工程手段: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>> 会产生多层平台类型;重构时无编译器保护。
  • 治理(三层):
    1. Java 侧加注解 :JSpecify @NullMarked 包级默认 + @Nullable 例外(最高 ROI,零运行时风险);
    2. Kotlin 侧配置 :-Xjsr305=strict + 细粒度包配置;
    3. 防腐层 :在边界显式声明 T?,用 requireNotNull 收敛,禁止平台类型流入领域层。
  • 加分:提到 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 链------这直接决定它的性能。

相关推荐
杨航 AI33 分钟前
JEV的用法
java·前端·数据库
知识搬运临时工1 小时前
JVM 删掉偏向锁之后,一次无竞争锁从 0.3 纳秒变成 5.4 纳秒
java
IT枫斗者枫哥1 小时前
用了BigDecimal还差一分?用四组Java代码查清原因
java·后端
一条泥憨鱼2 小时前
【消息队列】ACK确认了什么:消息队列可靠投递的完整链路
java·后端·kafka·消息队列·rabbitmq
从琳开始9892 小时前
网络原理入门(二)------OSI七层模型?TCP/IP五层(四层)模型原来是这样的
java·网络·tcp/ip·java-ee·php
文丑颜不良啊2 小时前
LeetCode1861~1880题目解析
java·leetcode
Flynn Liang2 小时前
AgentScope Java 深度解析:ReActAgent、HarnessAgent 与多智能体编排——JVM 上的自治 Agent 底座
java·大模型·架构设计·ai agent·spring ai·langchain4j·agentscope
Flynn Liang3 小时前
上线后怎么排查 AI 调用?Spring AI Observability:token/延迟/成本埋点与全链路追踪
java·人工智能·spring·监控·可观测性·observability·springai
文丑颜不良啊3 小时前
LeetCode1921~1940题目解析
java·leetcode