炉 II · 熔炼CH 03进度 03/26

空安全的边界:平台类型与 !! 的正当用法

「Kotlin 有空安全」这句话,你说过一百遍。但它有一个洞,而且这个洞恰好开在你每天都要穿过的地方 —— 和 Java 交界的那道门。门上写着 String!,编译器路过时会礼貌地移开视线:它不知道这东西可不可空,于是放弃检查,把责任还给你,一声不吭。这一章就干一件事:把空安全的边界画出来。边界之内,你可以放心地信任类型;边界之外,你必须自己动手。顺便判决三个被滥用的关键字:!!lateinitby lazy

平台类型!!lateinitJSpecify

空安全到底买到了什么

先把功劳记清楚。Kotlin 做的事其实很简单:把「可不可空」编进了类型StringString? 是两个不同的类型,编译器不许你把后者当前者用。

就这么一条,换来了三样东西: 空检查从「运行时的祈祷」变成了「编译期的证明」; 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 varval 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 的 Optional 为什么不算解药

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 亲手教会编译器。