5.9 空安全

原文链接: https://kotlinlang.org/docs/null-safety.html

5.9 空安全

空安全是 Kotlin 的一项特性,旨在显著降低空引用(也被称为价值十亿美元的错误)的风险。

在许多编程语言(包括 Java)中,最常见的陷阱之一是访问空引用的成员会导致空引用异常。在 Java 中,这相当于 NullPointerException,简称 NPE。

Kotlin 在类型系统中显式支持可空性,也就是说你可以明确声明哪些变量或属性允许为 null。此外,当你声明非空变量时,编译器会强制这些变量不能持有 null 值,从而避免 NPE。

Kotlin 的空安全通过在编译期而非运行期捕获潜在的空相关问题来确保代码更安全。这一特性通过显式表达 null 值,提高了代码的健壮性、可读性和可维护性,让代码更易于理解和管理。

Kotlin 中可能产生 NPE 的原因只有:

提示: 除 NPE 外,另一个与空安全相关的异常是 UninitializedPropertyAccessException。当你尝试访问尚未初始化的属性时,Kotlin 会抛出该异常,从而确保非空属性在准备就绪之前不会被使用。这通常发生在 lateinit 属性上。

可空类型与非空类型

在 Kotlin 中,类型系统区分可以持有 null 的类型(可空类型)和不能持有 null 的类型(非空类型)。例如,普通的 String 类型变量不能持有 null:

1
2
3
4
5
6
7
8
fun main() {
    // 把非空字符串赋给变量
    var a: String = "abc"
    // 试图把 null 重新赋给非空变量
    a = null
    print(a)
    // Null 不能作为非空类型 String 的值
}

你可以安全地调用 a 上的方法或访问其属性。由于 a 是非空变量,可以保证不会引发 NPE。编译器确保 a 始终持有有效的 String 值,因此不存在它在 null 时被访问属性或方法的风险:

1
2
3
4
5
6
7
8
fun main() {
    // 把非空字符串赋给变量
    val a: String = "abc"
    // 返回非空变量的长度
    val l = a.length
    print(l)
    // 3
}

要允许 null 值,请在变量类型之后立即加上 ? 符号来声明变量。例如,你可以写成 String? 来声明可空字符串。这个写法让 String 成为可以接受 null 的类型:

1
2
3
4
5
6
7
8
fun main() {
    // 把可空字符串赋给变量
    var b: String? = "abc"
    // 成功把 null 重新赋给可空变量
    b = null
    print(b)
    // null
}

如果你尝试直接在 b 上访问 length,编译器会报错。这是因为 b 被声明为可空变量,可以持有 null 值。直接尝试访问可空对象上的属性会导致 NPE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    // 把可空字符串赋给变量
    var b: String? = "abc"
    // 把 null 重新赋给可空变量
    b = null
    // 试图直接返回可空变量的长度
    val l = b.length
    print(l)
    // 对 String? 类型的可空接收者,只允许安全调用 (?.) 或非空断言 (!!.) 调用
}

在上面的例子中,编译器要求你在访问属性或执行操作之前,先用安全调用检查可空性。有几种方式可以处理可空值:

关于 null 处理工具和技巧的细节与示例,请阅读接下来的各节。

用 if 条件检查 null

处理可空类型时,你需要安全地处理可空性以避免 NPE。其中一种方式是用 if 条件表达式显式检查可空性。

例如,先检查 b 是否为 null,然后访问 b.length:

1
2
3
4
5
6
7
8
fun main() {
    // 把 null 赋给可空变量
    val b: String? = null
    // 先检查可空性,然后访问 length
    val l = if (b != null) b.length else -1
    print(l)
    // -1
}

在上面的例子中,编译器执行了智能转换,把类型从可空的 String? 变为非空的 String。它还会跟踪你所执行的检查信息,并允许在 if 条件内部调用 length。

也支持更复杂的条件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fun main() {
    // 把可空字符串赋给变量
    val b: String? = "Kotlin"

    // 先检查可空性,然后访问 length
    if (b != null && b.length > 0) {
        print("String of length ${b.length}")
        // String of length 6
    } else {
        // 条件不满足时提供替代输出
        print("Empty string")
    }
}

注意,上面的例子只有在编译器能够保证 b 在检查与使用之间不会改变时才有效,这与智能转换的前提条件相同。

安全调用运算符

安全调用运算符 ?. 让你可以用更简短的形式安全地处理可空性。如果对象为 null,?. 运算符不会抛出 NPE,而只是返回 null:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun main() {
    // 把可空字符串赋给变量
    val a: String? = "Kotlin"
    // 把 null 赋给可空变量
    val b: String? = null

    // 检查可空性并返回长度或 null
    println(a?.length)
    // 6
    println(b?.length)
    // null
}

表达式 b?.length 检查可空性:如果 b 非空则返回 b.length,否则返回 null。该表达式的类型是 Int?。

在 Kotlin 中,?. 运算符可以用于 var 和 val 变量:

  • 可空的 var 可以持有 null(例如 var nullableValue: String? = null)或非空值(例如 var nullableValue: String? = "Kotlin")。如果它当前是非空值,你可以随时把它改为 null。
  • 可空的 val 可以持有 null(例如 val nullableValue: String? = null)或非空值(例如 val nullableValue: String? = "Kotlin")。如果它当前是非空值,之后就不能把它改为 null。

安全调用在链式调用中很有用。例如,Bob 是一名员工,他可能被分配到某个部门(也可能没有)。而这个部门又可能有另一位员工作为部门主管。要获取 Bob 的部门主管的姓名(如果存在),你可以这样写:

1
bob?.department?.head?.name

只要链中任何一个属性为 null,这条链就会返回 null。

你也可以把安全调用放在赋值语句的左侧:

1
person?.department?.head = managersPool.getManager()

在上面的例子中,如果安全调用链中的某个接收者为 null,赋值就会被跳过,右侧的表达式根本不会被求值。例如,如果 person 或 person.department 为 null,该函数就不会被调用。下面是同一个安全调用用 if 条件表达的等价写法:

1
2
3
if (person != null && person.department != null) {
    person.department.head = managersPool.getManager()
}

Elvis 运算符

处理可空类型时,你可以检查是否为 null 并提供替代值。例如,如果 b 不为 null,就访问 b.length;否则返回一个替代值:

1
2
3
4
5
6
7
8
fun main() {
    // 把 null 赋给可空变量
    val b: String? = null
    // 检查可空性。非空则返回长度,为 null 则返回 0
    val l: Int = if (b != null) b.length else 0
    println(l)
    // 0
}

你可以用 Elvis 运算符 ?: 更简洁地处理,而不必写出完整的 if 表达式:

1
2
3
4
5
6
7
8
fun main() {
    // 把 null 赋给可空变量
    val b: String? = null
    // 检查可空性。非空则返回长度,为 null 则返回非空值
    val l = b?.length ?: 0
    println(l)
    // 0
}

如果 ?: 左侧的表达式不为 null,Elvis 运算符就返回它;否则返回右侧的表达式。只有当左侧为 null 时,右侧表达式才会被求值。

由于 throw 和 return 在 Kotlin 中是表达式,你也可以把它们用在 Elvis 运算符的右侧。例如,在检查函数参数时这会很方便:

1
2
3
4
5
6
7
fun foo(node: Node): String? {
    // 检查 getParent()。非空则赋给 parent,为 null 则返回 null
    val parent = node.getParent() ?: return null
    // 检查 getName()。非空则赋给 name,为 null 则抛出异常
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

非空断言运算符

非空断言运算符 !! 会把任何值转换为非空类型。

当你对值不为 null 的变量使用 !! 运算符时,它会被安全地当作非空类型处理,代码正常执行。不过,如果该值为 null,!! 运算符会强制把它当作非空处理,从而导致 NPE。

当 b 不为 null 时,!! 运算符让它返回其非空值(在本例中是 String),于是可以正确访问 length:

1
2
3
4
5
6
7
8
fun main() {
    // 把可空字符串赋给变量
    val b: String? = "Kotlin"
    // 把 b 当作非空并访问其长度
    val l = b!!.length
    println(l)
    // 6
}

当 b 为 null 时,!! 运算符让它返回其非空值,于是发生 NPE:

1
2
3
4
5
6
7
8
fun main() {
    // 把 null 赋给可空变量
    val b: String? = null
    // 把 b 当作非空并尝试访问其长度
    val l = b!!.length
    println(l)
    // 线程 "main" 中的异常 java.lang.NullPointerException
}

当你确信某个值不为 null、不可能发生 NPE,但由于某些规则编译器无法保证时,!! 运算符特别有用。在这种情况下,你可以用 !! 运算符显式告诉编译器该值不为 null。

可空接收者

你可以使用接收者类型为可空类型的扩展函数,从而可以在可能为 null 的变量上调用这些函数。

通过在可空接收者类型上定义扩展函数,你可以在函数内部处理 null 值,而不必在每个调用处都检查 null。

例如,.toString() 扩展函数可以在可空接收者上调用。当在 null 值上调用时,它会安全地返回字符串 "null",而不会抛出异常:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    // 把 null 赋给存储在 person 变量中的可空 Person 对象
    val person: Person? = null

    // 对可空的 person 变量应用 .toString 并打印字符串
    println(person.toString())
    // null
}

// 定义一个简单的 Person 类
data class Person(val name: String)

在上面的例子中,即使 person 为 null,.toString() 函数也会安全地返回字符串 "null"。这对调试和日志记录很有帮助。

如果你希望 .toString() 函数返回可空字符串(要么是字符串表示,要么是 null),请使用安全调用运算符 ?.。?. 运算符只在对象不为 null 时调用 .toString(),否则返回 null:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun main() {
    // 把可空 Person 对象赋给变量
    val person1: Person? = null
    val person2: Person? = Person("Alice")

    // 如果 person 为 null 则打印 "null";否则打印 person.toString() 的结果
    println(person1?.toString())
    // null
    println(person2?.toString())
    // Person(name=Alice)
}

// 定义一个 Person 类
data class Person(val name: String)

?. 运算符让你在安全处理潜在 null 值的同时,仍然可以访问可能为 null 的对象的属性或函数。

let 函数

要处理 null 值并只对非空类型执行操作,你可以把安全调用运算符 ?. 与 let 函数结合使用。

这种组合适合对表达式求值、检查结果是否为 null,并只在结果不为 null 时执行代码,从而避免手动空检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    // 声明一个元素可空的字符串列表
    val listWithNulls: List<String?> = listOf("Kotlin", null)

    // 遍历列表中的每一项
    for (item in listWithNulls) {
        // 检查该项是否为 null,只打印非空值
        item?.let { println(it) }
        // Kotlin
    }
}

安全转换

用于类型转换的常规 Kotlin 运算符是 as。不过,如果对象不是目标类型,常规转换会抛出异常。

你可以使用 as? 运算符进行安全转换。它会尝试把值转换为指定类型,如果该值不属于该类型则返回 null:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun main() {
    // 声明一个 Any 类型的变量,它可以持有任意类型的值
    val a: Any = "Hello, Kotlin!"

    // 使用 'as?' 运算符安全转换为 Int
    val aInt: Int? = a as? Int
    // 使用 'as?' 运算符安全转换为 String
    val aString: String? = a as? String

    println(aInt)
    // null
    println(aString)
    // "Hello, Kotlin!"
}

上面的代码打印 null,因为 a 不是 Int,所以转换安全地失败了。它还会打印 "Hello, Kotlin!",因为它匹配 String? 类型,所以安全转换成功。

元素可空的集合

如果你有一个元素可空的集合,并且只想保留非空元素,请使用 filterNotNull() 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    // 声明一个包含 null 和非空整数值的列表
    val nullableList: List<Int?> = listOf(1, 2, null, 4)

    // 过滤掉 null 值,得到非空整数列表
    val intList: List<Int> = nullableList.filterNotNull()

    println(intList)
    // [1, 2, 4]
}

接下来学什么?