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 代码。

kotlinnull-safetybest-practicesjava-interop