空安全的边界:平台类型与 !! 的正当用法
「Kotlin 有空安全」这句话,你说过一百遍。但它有一个洞,而且这个洞恰好开在你每天都要穿过的地方 —— 和 Java 交界的那道门。门上写着 String!,编译器路过时会礼貌地移开视线:它不知道这东西可不可空,于是放弃检查,把责任还给你,一声不吭。这一章就干一件事:把空安全的边界画出来。边界之内,你可以放心地信任类型;边界之外,你必须自己动手。顺便判决三个被滥用的关键字:!!、lateinit、by lazy。
空安全到底买到了什么
先把功劳记清楚。Kotlin 做的事其实很简单:把「可不可空」编进了类型。String 和 String? 是两个不同的类型,编译器不许你把后者当前者用。
就这么一条,换来了三样东西:① 空检查从「运行时的祈祷」变成了「编译期的证明」;② API 的契约写在签名里 —— fun find(id: String): User? 一眼就知道会找不到;③ 第 2 章看到的那个 Intrinsics.checkNotNullParameter,让 NPE 炸在边界上并指名道姓,而不是在三层调用之后炸在一个莫名其妙的地方。
这是 Kotlin 相对 Java 最实在的一笔收益 —— 也是 Java 到今天都补不上的(Optional 只能包返回值,且自己也能是 null)。
然后是那个洞:平台类型
问题在于:JVM 上有几十亿行不知道空安全为何物的 Java 代码。Kotlin 必须和它们共存。
于是设计者做了一个务实但危险的决定:当 Kotlin 看到一个没有空值注解的 Java 类型时,它既不当作 String,也不当作 String?,而是标记成 String! —— 平台类型。含义是:「我不知道,你看着办。」
平台类型的行为很特别:你可以把它当非空用(不检查,出事算你的),也可以当可空用(检查,安全)。编译器两种都放行,一句话都不说。
下面这台机器里放了四个真实场景。猜猜哪几个会在运行时炸,再点开看。
凡是从「外面」进来的值,第一时间钉死类型。外面包括:Java 库、Android 框架的老 API、JSON 解析结果、数据库查询、Intent extras、SharedPreferences。
// ✗ 让平台类型渗进你的代码 val token = api.findToken() // String! —— 定时炸弹开始滴答 // ✓ 在边界上做一次决定,之后全程有编译器保护 val token: String? = api.findToken() // 现在编译器逼你处理 null val safe = token ?: return // 或者 ?: throw、?: "default"
写类型标注在这里不是啰嗦,是设防。一行 : String? 把整条调用链后面的所有 NPE 都堵死了。
把边界修好:注解你自己的 Java 代码
如果你的项目里还有 Java 模块(大多数有年头的 Android 项目都有),那道门是可以修的:给 Java 侧加空值注解,Kotlin 就不再生成平台类型。
// 加了注解之后,Kotlin 侧看到的是 String? 和 String,不是 String!
@Nullable public String findToken() { ... }
@NonNull public String getName() { ... }
Kotlin 认识十几种注解体系(JetBrains、Android 的 androidx.annotation、JSR-305、以及现在的行业统一方案 JSpecify)。最省力的做法是包级别声明:在 package-info.java 上打一个 @NullMarked(JSpecify),整个包默认非空,只有标了 @Nullable 的才可空 —— 一次搞定几百个方法。
这也解释了一个你可能一直有的感觉:现代 AndroidX 的 API 用起来比十年前的老 SDK 安全得多。不是 Kotlin 变强了,是 Google 把注解补齐了。
三个关键字的判决书
!! —— 不是禁用词,是「断言」
网上流行的说法是「见到 !! 就该重构」。这话八成情况下是对的,但它让人失去了判断力。!! 的真实含义是:「我向编译器保证这里不是 null,如果我错了,请立刻炸在这一行。」
关键在于「立刻炸在这一行」。这其实是个特性 —— 它是一个断言,把错误暴露在离原因最近的地方。真正的问题从来不是 !! 本身,而是用它来敷衍一个你没想清楚的可空性。
| 写法 | 判决 | 为什么 |
|---|---|---|
view!!.text = x(图省事) | ❌ 有罪 | 你没有回答「它为什么可能是 null」。崩溃时你会一脸茫然 |
val id = intent.getStringExtra("id")!! | ⚠ 缓刑 | 意图是「调用方必须传 id」—— 但错误信息是一句冷冰冰的 NPE。改用 requireNotNull(x) { "缺少 id" },同样会炸,但告诉你为什么 |
测试代码里的 result!!.name | ✓ 无罪 | 测试就是要炸。断言失败即测试失败,这正是你要的 |
刚做完 if (x != null) 却还要 x!!(因为 x 是 var 属性) | ✓ 无罪但有更好的写法 | 这是智能转换的限制(第 4 章)。正解:x?.let { ... } 或先赋给局部 val |
一条可以写进团队规范的规则:允许 !!,但要求它旁边有一条注释解释「为什么这里不可能是 null」。写不出那条注释的人,会自己把它改掉 —— 这比禁令有效得多。
lateinit —— 你签字放弃了空安全
lateinit var 的意思是:「这个变量声明时没值,但我保证在第一次读它之前一定会被赋值。」编译器接受这个承诺,代价是不再检查 —— 读一个未初始化的 lateinit,抛 UninitializedPropertyAccessException。
它换来的只有一样东西:不用写 ?.。所以判据很简单 —— 问「谁保证它会被赋值,以及它会不会被抽走」:
- ✓ 该用:依赖注入的字段(Dagger/Hilt 保证在
onCreate前注入)、Activity 里在onCreate中初始化且此后一直有效的东西、测试里的@Before中构造的 subject。共同点:赋值之后就一直有效,直到对象死亡。 - ❌ 不该用:
Fragment的 ViewBinding。这是最经典的误用。Fragment 的视图生命周期比 Fragment 本身短 —— 视图被销毁后 Fragment 还活着,此时任何异步回调打回来碰到binding,直接崩溃(或者更糟:内存泄漏,因为 lateinit 字段还持有着已销毁的视图)。正解是可空 var + 在onDestroyView里置 null,或者用一个自动清理的委托(第 8 章会教你亲手写一个)。
另外两条硬限制:lateinit 不能用于基本类型(Int/Boolean…,因为它靠 null 来表示「还没赋值」),也不能用于 val。想检查有没有初始化:if (::binding.isInitialized) —— 但如果你需要频繁地检查这个,那说明你从一开始就该用可空类型。
by lazy —— 和 lateinit 是两码事
很多人把这两个当成「延迟初始化」的两种写法,其实它们解决的是不同问题:
lateinit var | val x by lazy { } | |
|---|---|---|
| 谁负责赋值 | 外部(框架、你自己) | 它自己(第一次读时执行 lambda) |
| 可变性 | var,能被改 | val,算一次就定了 |
| 忘了赋值 | 运行时崩溃 | 不可能发生 —— 它自己会算 |
| 线程安全 | 无 | 默认 SYNCHRONIZED(加锁,保证只算一次) |
| 典型场景 | DI 注入、onCreate 里赋值 | 贵的对象、只在需要时才构造(Retrofit service、SharedPreferences、正则表达式) |
规则:只要这个值能自己算出来,就用 by lazy,永远不要用 lateinit —— 因为前者不可能出错。lateinit 只留给「必须由外部注入」的情况。
by lazy 的默认加锁by lazy { } 默认是 LazyThreadSafetyMode.SYNCHRONIZED —— 每次读都要走一次同步块(第一次之后 JIT 会优化掉大部分开销,但不是零)。
如果你确定只在主线程访问(绝大多数 Android UI 相关的 lazy 都是这样),可以用 lazy(LazyThreadSafetyMode.NONE) { },省掉锁。但别把这当成默认优化 —— 除非你在 profiler 里真的看见它了。写错了,你会得到一个「偶尔初始化两次」的幽灵 bug,而这类 bug 的调试成本远高于那点锁开销。
更隐蔽的一层:泛型里的 null
平台类型会渗进泛型参数。一个 Java 方法返回 List<String>,Kotlin 看到的是 (Mutable)List<String!>! —— 列表本身可能是 null,元素也可能是 null,两层都不设防。
这类崩溃最难查:栈顶在你自己写的 map { it.length } 里,而错误的源头在三个模块之外的一个 Java 方法。解法还是那一条 —— 在边界上钉死类型:
val tags: List<String?> = api.tags // 承认它可能有 null 元素 val clean: List<String> = tags.filterNotNull() // 一次性净化,之后全程安全
还有一个更隐蔽的场景:泛型类型参数默认包含 null。
class Box<T>(val value: T) // T 的上界是 Any?,所以 Box<String?> 完全合法 class Box2<T : Any>(val value: T) // 这样才能排除 null
写库时这条尤其重要 —— 你以为你的 Cache<K, V> 不接受 null value,其实它接受。
Java 8 的 Optional<T> 常被拿来对标 Kotlin 的可空类型,但它们不是一个量级的东西:
- Optional 自己可以是 null。
Optional<String> o = null;完全合法 —— 空安全的漏洞原封不动地回来了。 - 它是一个真实的包装对象,有分配开销;Kotlin 的
String?在字节码里就是一个普通的String引用,零开销。 - 它只覆盖返回值。Java 官方明确不建议把 Optional 用在字段和参数上,于是「这个参数能不能传 null」这个问题在 Java 里依然没有答案。
- 它不是强制的。没人拦着你
o.get()。
Kotlin 的做法是把可空性做进类型系统,覆盖每一个位置、零开销、编译器强制。这是本书里 Kotlin 赢 Java 最干净的一局 —— 而 Java 想追上,需要修改语言本身(JSpecify 是他们目前最好的努力,但它只是注解,不是类型)。
一份边界清单
把你的项目按「信任度」分成三层,这是本章最实用的产出:
| 层 | 包含什么 | 纪律 |
|---|---|---|
| 核心区(可信) | 你自己的 Kotlin 代码:domain、model、纯逻辑 | 类型说什么就是什么。这里出现 !! 或 lateinit 都应该被 review 质问 |
| 边界层(设防) | Java 库调用、JSON 解析、DB 查询、Intent/Bundle、Android 老 API | 显式标注类型;把平台类型立刻转成 T?;用 requireNotNull(x) { "为什么" } 带信息地崩溃;集合走一次 filterNotNull() |
| 外部(不可信) | 网络返回的 JSON、用户输入、第三方 SDK 回调 | 假设一切都可能是 null 或缺失。解析即校验(第 6 章的智能构造子)—— 校验一次,之后类型作保 |
1. 全项目搜 !!。给每一处做判决:能不能改成 ?.let / ?: / requireNotNull(x) { "..." }?剩下那些真正合理的,加一行注释说明「为什么这里不可能是 null」。数一数被你删掉了多少 —— 这个数字就是你的项目里「没想清楚的可空性」的数量。
2. 搜 lateinit。逐个问:赋值它的是谁?有没有东西会把它抽走(视图销毁、连接断开)?如果有,它就是一颗定时炸弹。Fragment 的 ViewBinding 是重灾区。
3. 如果项目里还有 Java 模块:挑一个被 Kotlin 频繁调用的 Java 类,给它加上 @Nullable/@NonNull(或者直接上 JSpecify 的 @NullMarked 包注解)。然后回到 Kotlin 侧看编译器有没有开始报警 —— 那些报警就是你之前根本不知道的隐患。
4. 找一处从 JSON / Intent 解析出来的数据,检查它有没有在边界上被钉死类型。没有的话,现在钉。
空安全的边界画完了。下一章处理一个相邻的挫折:你明明已经判了空,编译器却还是不让你用 —— 智能转换的规则,以及怎么用 contract 亲手教会编译器。