Kotlin 中的空安全:?、!!、let 和 Elvis 运算符
1. 概述
NullPointerException(NPE)是每个 Java 开发者的梦魇。Kotlin 通过将 null 安全特性集成到类型系统中,在编译时就解决了这个问题。本文帮你理解四个核心工具:用 ? 声明可空类型、安全调用 ?.、Elvis 运算符 ?:,以及 let 函数。读完本文,你将知道何时使用哪个工具,并且避免那个经典陷阱:滥用 !! 后仍然遭遇 NPE。
2. 可空类型和 ? 符号
在 Kotlin 中,所有类型默认都是非空的。如果要让变量能够存储 null,你必须在类型后面显式添加 ? 符号。
class Customer(val name: String, val email: String?)
fun main() {
val customer = Customer("An", null)
println(customer.email) // null,没有错误,因为 email 是 String?
}
name: String— 永远不会是null,编译器保证这一点。email: String?— 可以是null,编译器会强制你在使用它的任何地方处理这种情况。
如果你尝试直接在 email 上调用方法,比如 customer.email.length,编译器会立即报错:
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
这是 Kotlin 最大的优势之一:错误在编译时被捕获,而不是在 production 应用运行时。
3. 安全调用 ?.
?. 仅在接收者不为 null 时调用方法或访问属性。如果接收者为 null,整个表达式返回 null,不会抛出异常。
class Order(val id: Long, val customer: Customer?)
fun printCustomerName(order: Order) {
val length = order.customer?.name?.length
println("名字长度:$length")
}
order.customer?.name— 如果customer为null,整个表达式会短路并返回null,不需要继续执行.length。- 结果
length的类型是Int?,因为链中的每一步都可能返回null。
名字长度:null // 当 customer == null 时
名字长度:2 // 当 customer.name = "An" 时
提示: 如果
?.链超过 3-4 步,通常说明你的领域模型设计还不够好。考虑重构函数或使用 sealed class 而不是让可空类型滥用。
3.1. 安全调用结合函数调用
?. 也适用于函数,不只是属性:
fun notifyCustomer(order: Order) {
order.customer?.let { sendEmail(it) }
}
fun sendEmail(customer: Customer) {
println("发送邮件给 ${customer.email}")
}
这行 order.customer?.let { ... } 只有在 customer 不为 null 时才会执行 let 块。这直接涉及第 5 部分的 let 函数。
4. Elvis 运算符 ?:
Elvis 运算符在左侧表达式为 null 时提供默认值。其名字来源于符号 ?: 看起来像 Elvis Presley 侧向的发型。
fun getDisplayEmail(customer: Customer): String {
return customer.email ?: "未提供邮箱"
}
- 如果
customer.email != null,返回该值本身。 - 如果为
null,返回后备字符串"未提供邮箱"。
Elvis 运算符结合 return 或 throw 时威力更大,让代码能够简洁地进行提前返回:
fun validateOrder(order: Order): Customer {
val customer = order.customer ?: throw IllegalStateException("订单 ${order.id} 缺少客户信息")
return customer
}
这行 ?: throw IllegalStateException(...) 使得编译器之后可以推断 customer 的类型是非空的 Customer,而不再是 Customer?。这是在函数中提前“解包”可空类型的一种非常常见的技巧。
4.1. Elvis 与默认的安全函数
fun calculateDiscount(order: Order): Double {
val tier = order.customer?.name?.length ?: 0
return if (tier > 5) 0.1 else 0.0
}
这里,安全调用链和 Elvis 运算符结合使用:如果任何一步返回 null,最终结果会回退到 0。
5. let 函数处理非空值
let 是一个作用域函数:它接收接收者,执行块,返回块的结果。结合 ?.,它成为一种经典的模式,用来仅在值不为 null 时执行代码。
fun processPayment(amount: Double?) {
amount?.let {
println("处理付款:$it")
chargeCard(it)
}
}
fun chargeCard(value: Double) {
println("从卡中扣费 $value")
}
amount?.let { ... }— 块内的代码仅在amount != null时执行。- 块内,
it的类型是Double(非空),不是Double?,所以你可以放心地调用chargeCard(it),无需!!或额外检查。
与写 if (amount != null) { ... } 相比,使用 let 能够限制临时变量的作用域,防止变量泄漏到块外,并且代码流更像一条“管道”。
5.1. 结合 let 和 Elvis 返回值
fun formatAmount(amount: Double?): String {
return amount?.let { "金额:%.2f".format(it) } ?: "暂无金额"
}
这行代码读起来很自然:“如果有 amount,就格式化它,否则返回后备值”。这是实际 Kotlin 代码中非常常见的习语,特别是在将数据从 DTO 映射到领域模型时。
6. 非空断言 !! — 何时使用?
!! 强制编译器把一个可空类型视为非空。如果实际值为 null,它会立即抛出 NullPointerException。
fun getCustomerNameUnsafe(order: Order): String {
return order.customer!!.name
}
order.customer!!— 如果customer为null,这行会抛出一个相当清晰的 NPE:
Exception in thread "main" java.lang.NullPointerException
at MainKt.getCustomerNameUnsafe(Main.kt:2)
注意:
!!本质上是把 Java 的 NPE 重新引入 Kotlin。仅在你100% 确定值在那一刻不为null时使用(比如已经在之前的步骤中验证过),最好附加注释说明原因。
什么时候可以接受使用 !!:
- 与旧 Java 代码的互操作,API 返回
@Nullable但你知道业务逻辑确保不为 null。 - 测试代码,当你想在设置错误时快速失败。
什么时候不应该使用 !!:
- 处理来自用户、HTTP 请求、数据库的输入 — 总有可能意外为 null。
- 代替验证逻辑。使用
requireNotNull()或带异常的 Elvis 运算符更清晰。
fun getCustomerName(order: Order): String {
val customer = requireNotNull(order.customer) { "订单 ${order.id} 必须有客户信息" }
return customer.name
}
requireNotNull 既解包可空类型,又提供比光秃秃的 !! 清晰得多的错误消息 — 这是应该优先选择的方式。
7. 四个工具快速对比
| 工具 | 何时使用 | 遇到 null 时的行为 |
|---|---|---|
? |
声明可能为 null 的类型 | N/A,只是类型注解 |
?. |
安全访问,不确定是否为 null | 返回 null,不抛出异常 |
?: |
需要后备值或提前返回 | 执行右侧表达式 |
let |
仅在有值时执行逻辑 | 不执行块 |
!! |
100% 确定不为 null | 抛出 NullPointerException |
8. 为空值处理函数编写测试
为了确保 null 安全逻辑正确工作,要为两个分支都编写测试:有值和为 null。
import org.junit.jupiter.api.Test
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
class OrderServiceTest {
@Test
fun `getDisplayEmail 当邮箱为 null 时返回后备值`() {
val customer = Customer(name = "An", email = null)
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("未提供邮箱")
}
@Test
fun `getDisplayEmail 当有值时返回邮箱`() {
val customer = Customer(name = "An", email = "an@example.com")
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("an@example.com")
}
@Test
fun `validateOrder 当客户为 null 时抛出异常`() {
val order = Order(id = 1L, customer = null)
assertThatThrownBy { validateOrder(order) }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("缺少客户信息")
}
}
- 第一个测试检查当
email == null时 Elvis 运算符正确返回后备值。 - 第二个测试检查另一边 — 不丢失实际值。
- 第三个测试检查
?: throw是否正确抛出异常和预期的消息,使用 AssertJ 的assertThatThrownBy。
如果使用 Gradle Kotlin DSL,需要的依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
9. 常见错误
滥用 !! 来“让编译器闭嘴”。 当编译器报告可空错误而你的反射性反应是输入 !! 时,停下来 — 这是你还没完全理解数据流的信号。问问自己:这个值真的可能为 null 吗?如果可能,用 ?: 或 let 处理。
机械地用 ?.let 替代 if-else。 对于复杂逻辑有多个分支时,if (x != null) { } else { } 有时比嵌套的 let 更清晰。别为了用 Elvis/let 而强行用,如果代码更难读的话。
忘记平台类型来自 Java 且没有 null 安全。 当调用没有 @Nullable/@NonNull 注解的 Java 方法时,Kotlin 把它视为平台类型(String!),编译器不会强制你检查 null。在这种情况下 NPE 仍然可能在运行时发生 — 在使用 Java 互操作时要手动添加 ?. 或进行检查。
// Java: public String getLegacyName() { return null; } // 无注解
val name = legacyService.getLegacyName() // 推断类型为 String!,Kotlin 不警告
println(name.length) // 如果 name 为 null,可能运行时 NPE
10. 总结
Kotlin 的 null 安全不是“魔法”来完全消除 NPE,而是一个工具能更早地捕获错误 — 在编译时而不是运行时。? 声明空值的可能性,?. 进行安全访问,?: 提供后备值或提前返回,let 在有值时限制逻辑的作用域。而 !! 是一个应急出口,仅在你确定且准备好在出错时承担 NPE 后果时才使用。掌握这四个工具,相比老旧的 Java 风格 null 检查,你将写出安全得多的 Kotlin 代码。