主题
第 5 章 Null 安全 —— Kotlin 最杀手锏的特性
学习目标:理解 Kotlin 是怎么从类型系统层面消除 NPE 的;熟练使用
?.、?:、!!、as?、let、requireNotNull等空安全工具;知道 Java 互操作时的"平台类型"陷阱。
5.1 NPE:Java 的"十亿美元错误"
5.1.1 故事
null 这个概念是 1965 年 Tony Hoare 发明的,他后来在 2009 年的演讲中说:
"I call it my billion-dollar mistake." (我管它叫"我的十亿美元错误"。)
为什么是"十亿美元"?因为几十年来 NullPointerException 给软件行业造成的 bug 修复成本、数据丢失、宕机损失 —— 加起来远不止十亿。
5.1.2 Java 里 NPE 长啥样
java
String name = user.getProfile().getName();
System.out.println(name.length());
// 如果 user 是 null → NPE
// 如果 profile 是 null → NPE
// 如果 name 是 null → NPE
// 编译器一句话不说,生产环境直接炸📌 Java 的根本问题:所有引用类型都"可能是 null",但编译器无法区分。每个变量都得"心怀畏惧"地用。
5.1.3 Kotlin 的解决思路:把可空性写进类型
Java Kotlin
──── ──────
String name = "Alice" → val name: String = "Alice" // 不能为 null
val name: String? = null // 可以为 null
// ↑
// 多了一个问号
// 编译器据此把代码分两类对待5.2 可空类型:T vs T?
5.2.1 基本规则
kotlin
val name: String = "Alice" // 非空类型,永远不能为 null
val nick: String? = null // 可空类型,可以为 null
// 互不兼容
val s: String = nick // ❌ 不能把可空赋给非空
val s2: String? = name // ✅ 可以把非空赋给可空(更宽容)📌 类比:
T像「保证装着东西的快递盒」,拆了一定有内容T?像「可能是空的快递盒」,拆之前要确认
5.2.2 用可空类型时的"强制确认"
kotlin
val nick: String? = readLine() // 用户可能直接回车 → null
// ❌ 编译报错:nick 是 String?,不能直接 .length
println(nick.length)
// 编译器告诉你:要么 ?.(安全调用),要么 !!(强制非空),要么先判断5.3 五大空安全武器
5.3.1 ?. 安全调用(Safe Call)
kotlin
val nick: String? = null
val length = nick?.length // null 时整个表达式返回 null,不抛异常
println(length) // null链式调用特别好用:
kotlin
val city = user?.address?.city // 任何一环为 null,整个链返回 null📌 生活化类比:
?.就像「先敲门再进去」 —— 没人应答(null)就掉头走,不硬闯(不抛异常)。
5.3.2 ?: Elvis 操作符(猫王操作符)
名字来源是「?: 旋转 90 度像猫王 Elvis 的发型」🎸
kotlin
val nick: String? = null
val display = nick ?: "(无昵称)" // null 时用右边的默认值
println(display) // "(无昵称)"
// 跟 Java 三元的对比
// Java: String d = nick == null ? "无" : nick;
// Kotlin: val d = nick ?: "无" ← 简洁多了经典组合:?. + ?:
kotlin
val length = nick?.length ?: 0Elvis + return / throw
kotlin
fun parse(input: String?): Int {
val s = input ?: return -1 // null 直接返回
val n = s.toIntOrNull() ?: throw IllegalArgumentException("not int")
return n
}5.3.3 !! 非空断言(自杀式操作符)
kotlin
val nick: String? = null
val length = nick!!.length // 我保证不为 null —— 错了立即抛 NPE⚠️
!!是 Kotlin 里"反 Kotlin 哲学"的操作符。它告诉编译器"我比你聪明,别管我"。能不用就不用!每次写
!!都要问自己:"如果这里真是 null,我能接受立即崩溃吗?"
5.3.4 as? 安全类型转换
kotlin
val obj: Any = "hello"
val n: Int = obj as Int // ❌ 抛 ClassCastException
val n: Int? = obj as? Int // ✅ 转不成返回 null
println(n) // null5.3.5 智能转型(Smart Cast)
kotlin
fun foo(s: String?) {
if (s != null) {
println(s.length) // ✅ 编译器知道 s 在这个分支非空
}
}
// 也支持 Elvis 风格
fun foo(s: String?) {
val safe = s ?: return
println(safe.length) // ✅ safe 是非空的 String
}📌 Smart Cast 失效的地方:详见第 3 章的 Q9。简单说:
var和开放属性不能 smart cast,要复制到本地val。
5.4 安全调用 + Lambda:?.let { }
kotlin
val nick: String? = "Alice"
// 老 Java 风格
if (nick != null) {
println("nick = $nick, length = ${nick.length}")
}
// Kotlin 风格
nick?.let {
println("nick = $it, length = ${it.length}")
}📌
?.let { }的妙处:
- 块里的
it是非空类型- 不污染外层作用域
- 可以链式
?.let { }?.also { }?.run { }
真实场景:链式可空判断
kotlin
fun showCity(user: User?) {
user?.address?.city?.let { city ->
println("城市:$city")
} ?: println("未填写城市")
}5.5 工具函数:requireNotNull 与 checkNotNull
kotlin
fun process(input: String?) {
val name = requireNotNull(input) { "input 不能为空" }
// 之后 name 就是非空的 String,编译器认账
println(name.length)
}| 函数 | 用途 | 失败时抛 |
|---|---|---|
requireNotNull(x) | 检查参数 | IllegalArgumentException |
checkNotNull(x) | 检查内部状态 | IllegalStateException |
error(msg) | 不可能到达的分支 | IllegalStateException |
5.6 容器与 null
5.6.1 集合元素的可空
kotlin
val list1: List<String> = listOf("a", "b") // 元素全部非空
val list2: List<String?> = listOf("a", null, "b") // 元素可空
val list3: List<String>? = null // 列表本身可空
val list4: List<String?>? = null // 全可空5.6.2 过滤掉 null
kotlin
val mixed: List<String?> = listOf("a", null, "b", null, "c")
val noNull: List<String> = mixed.filterNotNull()
println(noNull) // [a, b, c]5.7 Java 互操作:平台类型陷阱
5.7.1 平台类型是什么
Java 不区分可空/非空(除非加 @Nullable / @NotNull 注解),所以 Kotlin 看到 Java 类型时无法决定它是 T 还是 T?,只好不强制检查。
kotlin
// 假设 Java 里:public String getName() { ... }
val name = javaObj.getName() // 类型是 String! (平台类型)
println(name.length) // 不报错 —— 但运行时如果 getName 返回 null,仍 NPEString! 是"既能当 String 用、又能当 String? 用"的尴尬类型。平台类型只在 Kotlin 推断时存在,你不能显式声明 String!。
5.7.2 防御性写法
kotlin
// 调 Java API 时,明确声明你认为它是不是可空
val name: String = javaObj.getName() // 你认为它非空 —— 错的话立即 NPE,但比"埋雷晚炸"好
val name: String? = javaObj.getName() // 你认为它可空 —— 强迫自己处理 null5.7.3 Kotlin 该如何告诉 Java "我返回了非空"
kotlin
fun greet(name: String): String = "Hello, $name"Kotlin 编译后会给 Java 看到 @NotNull String 注解:
java
// Java 调用方收到的字节码签名
public static @NotNull String greet(@NotNull String name) { ... }
// Java 端如果传 null 给 name,会立即抛
// java.lang.IllegalArgumentException: Parameter specified as non-null is null5.8 实战:解析 JSON 数据
假设我们从 API 拿到一个 user JSON:
json
{
"name": "Alice",
"address": {
"city": "Beijing",
"zipcode": null
}
}kotlin
data class Address(val city: String?, val zipcode: String?)
data class User(val name: String, val address: Address?)
fun displayUserInfo(user: User?) {
val display = buildString {
append("姓名:${user?.name ?: "(未知)"}\n")
append("城市:${user?.address?.city ?: "(未填写)"}\n")
append("邮编:${user?.address?.zipcode ?: "(未填写)"}\n")
}
println(display)
}
fun main() {
val u1 = User("Alice", Address("Beijing", null))
val u2 = User("Bob", null)
val u3: User? = null
displayUserInfo(u1)
displayUserInfo(u2)
displayUserInfo(u3)
}输出:
姓名:Alice
城市:Beijing
邮编:(未填写)
姓名:Bob
城市:(未填写)
邮编:(未填写)
姓名:(未知)
城市:(未填写)
邮编:(未填写)注意:用 Kotlin 写出来这段代码,根本不可能抛 NPE。这就是空安全的威力。
5.9 章末小结
★ 第 5 章核心知识图谱 ★
│
┌─────────────────┼─────────────────┐
│ │ │
┌──▼──┐ ┌──▼───┐ ┌──▼───┐
│ 类型 │ │ 操作符│ │ 工具 │
├─────┤ ├──────┤ ├──────┤
│T 非空│ │ ?. │ │let │
│T? 可空│ │ ?: │ │require│
│T! 平台│ │ !! │ │filter │
│ │ │ as? │ │NotNull│
└─────┘ └──────┘ └──────┘记忆口诀:
?.—— 小心走(null 就掉头)?:—— 给后路(null 就用默认)!!—— 赌一把(null 就崩)as?—— 试试看(不行返回 null)let—— 进屋办事(块内非空)
🎤 5.10 章末面试题(10 道)
Q1. Kotlin 是怎么避免 NPE 的?
答:核心是把"是否可空"编进类型系统。
String表示非空,编译器禁止赋 nullString?表示可空,访问时编译器强制处理 null 的情况
NPE 这个"运行时炸弹"被提前到编译期发现,从源头消除。
Q2. ?.、?:、!! 三个操作符各是什么?
答:
| 操作符 | 名字 | 行为 |
|---|---|---|
?. | 安全调用 | 左边为 null 时整个表达式返回 null,不抛异常 |
?: | Elvis | 左边为 null 时返回右边的值,否则返回左边 |
!! | 非空断言 | 强制当成非空,为 null 时立即抛 NPE |
经典组合:val len = name?.length ?: 0(name 为 null 则 len 为 0)
Q3. !! 危险吗?什么时候才该用?
答:99% 的场景都不该用。!! 等于把 null 检查关掉,跟 Java 一样裸奔。
合理使用场景:
- Java 互操作时确信非空但类型推断不出来
- 测试代码里,故意让 null 立即崩出栈跟踪
- 本地变量临时绕过编译器(很少需要)
💡 在 IDE 里把"
!!警告"开成 ERROR 级别,能强制团队重视。
Q4. 什么是"平台类型"(Platform Type)?
答:Kotlin 调 Java 代码时,因为 Java 不区分可空/非空(除非加注解),Kotlin 不知道这个 Java 返回值是不是会 null,只好把类型标为 String!。
String! 既能当 String 也能当 String? 用 —— 灵活但不安全。
实际写代码时不能直接用 String!,应该明确声明:
kotlin
val name: String = javaApi.getName() // 我赌它非空(错就 NPE)
val name: String? = javaApi.getName() // 我假设它可空(更安全)Q5. as 和 as? 有啥区别?
答:
as:强制转换,转不成抛ClassCastExceptionas?:安全转换,转不成返回null
kotlin
val obj: Any = "hello"
val n1: Int = obj as Int // ❌ ClassCastException
val n2: Int? = obj as? Int // ✅ null实战中 as? 配合 ?: 用得最多:
kotlin
val n: Int = (obj as? Int) ?: 0 // 转不成就用 0Q6. 为啥写 if (s != null) { s.length } 编译能过,但 if (obj.s != null) { obj.s.length } 不能?
答:Smart Cast 的限制。
s 是局部 val,编译器能保证 "判完 != null 到使用之间没人改 s" → 可以 smart cast。
obj.s 是开放属性(或 var),编译器无法保证两次访问之间没有别的线程或代码改动它,所以拒绝 smart cast。
解决办法:复制到本地 val
kotlin
val s = obj.s
if (s != null) { println(s.length) } // ✅Q7. ?.let { } 跟 if (x != null) { } 有什么区别?
答:
| 维度 | if (x != null) { } | x?.let { } |
|---|---|---|
| 是否表达式 | 否(语句) | 是(有返回值) |
| 块内变量名 | 还是 x | it(或自定义) |
| 链式调用 | 不优雅 | 完美:a?.b?.let { ... }?.also { ... } |
| 智能转型 | 依赖 smart cast,对 var/属性失效 | 保证非空 |
| 写法风格 | 命令式 | 函数式 |
经验法则:单层判断用 if,多层链式用 ?.let { }。
Q8. Kotlin 里有"双问号变量" String?? 吗?
答:没有。? 不是符号叠加。String? 已经是"String 或 null"的完整含义,再加问号没意义。
但泛型场景里你会看到 T? 嵌套:
kotlin
fun <T> List<T>.firstOrNullSafe(): T? = firstOrNull()
// 如果 T 本身是 String?,那 T? 实际上还是 String?Q9. 集合里的元素可以是 null 吗?
答:可以,看类型声明:
kotlin
val a: List<String> // 元素全部非空
val b: List<String?> // 元素可以是 null
val c: List<String>? // 列表本身可以是 null
val d: List<String?>? // 双重可空实用方法:
kotlin
listOf("a", null, "b").filterNotNull() // 过滤掉 null → List<String>
mapOf("k" to null).getOrElse("k") { "default" } // 取不到给默认值Q10. 为啥 Kotlin 标准库里不用 Optional?
答:因为 T? 已经够用,而且更轻。
Java 8 引入 Optional<T> 也是为了显式表达可空性,但:
- 每用一次都要装箱(额外对象)
- 调用要
opt.map().orElse(),啰嗦 - 不能强制让旧 API 全用 Optional
Kotlin 用类型系统解决,零运行时开销:String? 在 JVM 字节码里就是 String,"可空性"信息只存在于编译期。
下一章 → 第 6 章 · 类与对象 →
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
kotlin
/**
* 第 5 章 · Null 安全 — 综合示例
*
* 包含:
* - 可空类型与非空类型
* - ?. 安全调用
* - ?: Elvis 操作符
* - !! 非空断言(演示其危险性)
* - as? 安全转换
* - ?.let { } 链式
* - 集合的可空处理
* - 平台类型陷阱
*/
data class Address(val city: String?, val zipcode: String?)
data class User(val name: String, val nickname: String?, val address: Address?)
fun main() {
println("=== 1. 可空 vs 非空 ===")
val name: String = "Alice"
val nick: String? = null
println("name = $name (非空)")
println("nick = $nick (可空)")
// val s: String = nick // ❌ 编译报错:Type mismatch
println("\n=== 2. ?. 安全调用 ===")
println("nick?.length = ${nick?.length}") // null
println("name?.length = ${name?.length}") // 5
println("\n=== 3. ?: Elvis 默认值 ===")
val displayNick = nick ?: "(无昵称)"
println("displayNick = $displayNick")
val len = nick?.length ?: 0
println("len = $len")
println("\n=== 4. !! 危险演示 ===")
val safeName: String? = "Bob"
println("safeName!!.length = ${safeName!!.length}") // 5
try {
val nullName: String? = null
println("nullName!!.length = ${nullName!!.length}")
} catch (e: NullPointerException) {
println("⚠️ 捕获到 NPE: ${e.message ?: "no message"}")
println(" → !! 是自杀式操作符,能不用就别用!")
}
println("\n=== 5. as? 安全转换 ===")
val obj: Any = "hello"
val asInt: Int? = obj as? Int // null
val asString: String? = obj as? String // "hello"
println("obj as? Int = $asInt")
println("obj as? String = $asString")
val n: Int = (obj as? Int) ?: -1
println("用默认值: $n")
println("\n=== 6. Smart Cast ===")
fun describe(x: Any?) = when {
x == null -> "null"
x is String -> "字符串,长度 ${x.length}" // 自动当 String 用
x is Int -> "整数,平方 ${x * x}"
else -> "其他类型 ${x::class.simpleName}"
}
listOf(null, "hello", 5, 3.14).forEach {
println("${it} → ${describe(it)}")
}
println("\n=== 7. ?.let { } 链式 ===")
val users = listOf(
User("Alice", "alice123", Address("Beijing", "100000")),
User("Bob", null, Address("Shanghai", null)),
User("Charlie", "charlie", null),
)
users.forEach { user ->
// 老 Java 风格
if (user.address != null && user.address.city != null) {
// ↑ 这里 smart cast 不一定成功(开放属性问题),实际可能要本地变量
// println("城市: ${user.address.city}")
}
// Kotlin 风格
user.address?.city?.let { city ->
println("${user.name} 住在 $city")
} ?: println("${user.name} 没填城市")
}
println("\n=== 8. 集合的 null 处理 ===")
val mixed: List<String?> = listOf("a", null, "b", null, "c")
println("原始: $mixed")
println("过滤掉 null: ${mixed.filterNotNull()}")
println("用空字符串代替 null: ${mixed.map { it ?: "" }}")
println("非 null 元素长度之和: ${mixed.sumOf { it?.length ?: 0 }}")
println("\n=== 9. requireNotNull / checkNotNull ===")
fun process(input: String?) {
val name = requireNotNull(input) { "input 不能为空" }
println("处理: $name (长度 ${name.length})")
}
process("hello")
try {
process(null)
} catch (e: IllegalArgumentException) {
println("⚠️ 捕获: ${e.message}")
}
println("\n=== 10. 实战:解析嵌套结构 ===")
val u = User("David", null, Address(null, null))
val info = buildString {
append("姓名:${u.name}\n")
append("昵称:${u.nickname ?: "(未设置)"}\n")
append("城市:${u.address?.city ?: "(未填写)"}\n")
append("邮编:${u.address?.zipcode ?: "(未填写)"}")
}
println(info)
}