前言
空指针异常(NullPointerException)可以说是 Android 系统上崩溃率最高的异常类型了,其根本原因在于它是一个运行时异常,并不是编译时异常。在编译代码时,编译器并不会提示我们,所以我们只能通过逻辑判断来避免。
比如下面这段 Java 代码:
public static void printMessage(String message) {
System.out.println("处理前");
// 如果 message 是 null,这一行就会抛出 NullPointerException
String messageUpperCase = message.toUpperCase();
System.out.println("处理后:" + messageUpperCase);
}
这段代码非常简单,只是接收了一个字符串类型的参数,然后调用了参数的 toUpperCase() 方法,并打印了结果。
但是这段代码并不安全,当外部传入 null 时,就会出现空指针异常。因此,我们可以在调用参数的 toUpperCase() 方法之前,进行一个判空操作,如下所示:
public static void printMessage(String message) {
if (message == null) {
System.out.println("message 参数为空!");
return;
}
System.out.println("处理前");
String messageUpperCase = message.toUpperCase();
System.out.println("处理后:" + messageUpperCase);
}
这样代码就安全了,即使传入了 null,会直接返回,不会调用到参数的 toUpperCase() 方法。
可以看到即使是这么小的一段代码,都有可能产生空指针异常,这就是空指针异常崩溃率最高的原因。
可空类型系统
判空检查
而 Kotlin 却没有这种担忧,它利用编译时判空检查的机制几乎根源上就杜绝了空指针异常。
回到刚刚的 printMessage() 方法,并转换为 Kotlin 版本:
fun printMessage(message: String) {
println("处理前")
val messageUpperCase = message.uppercase(Locale.getDefault())
println("处理后:$messageUpperCase")
}
这段代码绝对安全,没有空指针异常的风险。因为在 Kotlin 中,默认所有的参数和变量都不可为空。如果你在调用 printMessage() 函数时传入了 null,编译器会直接报错:Null can not be a value of a non-null type String。
也就是说,Kotlin将空指针异常的检查提前到了编译时期。如果代码中存在空指针异常的风险,就会在编译的时候会直接报错,这样就保证了程序在运行时,不会出现空指针异常了。
可为空的类型
那有些场景下,参数就是要允许为空,该怎么办?
Kotlin 提供了可为空的类型系统。不过在使用可为空的类型系统时,需要在编译时期处理所有潜在的空指针异常,否则代码编译不过。
我们先来看可为空的类型系统是什么。
很简单,就是在类型后面加上一个问号?。比如String表示不可为空的字符串,String?就表示可为空的字符串。
回到刚刚的 printMessage() 函数,如果希望参数 message 可为空(可接收 null),只需将 String 类型改为 String? 类型,像这样:
fun printMessage(message: String?) {
println("处理前")
val messageUpperCase = message.uppercase(Locale.getDefault())
println("处理后:$messageUpperCase")
}
这样我们调用 printMessage() 函数时,传入null也不会报错了。但这时你会发现,在调用 uppercase() 方法时却报错了。这是因为 message 参数可为空,直接调用 uppercase() 方法可能出现空指针异常,所以Kotlin在这种情况下不允许编译通过。
怎么解决呢?
像前面一样,对 message 参数进行判空操作:
fun printMessage(message: String?) {
if (message == null){
println("message 参数为空!")
return
}
println("处理前")
val messageUpperCase = message.uppercase(Locale.getDefault())
println("处理后:$messageUpperCase")
}
这样就可以编译通过了,不会出现空指针异常。但这样需要编写很多额外的检查代码,并且如果每处检查代码都使用if判断语句,会使代码看起来比较繁琐,而且if判断语句还处理不了全局变量的判空问题。
在多线程并发的场景中,全局变量的判空和使用会有一个时间差。在你判断全局变量不为空后,转头全局变量就有可能被其他线程修改为了 null,这时你还认为全局变量不为空,继续使用着已经变为null的全局变量,导致抛出
NullPointerException。
所以Kotlin专门提供了一系列的判空辅助工具,让我们能够更轻松、更简洁地进行判空处理。
判空辅助工具
安全调用操作符 ?.
首先是?.操作符,它的逻辑是:只有当对象不为空的时候,才会调用其方法;否则,会什么也不做,整个表达式返回 null。
比如下面这段判空处理的代码:
if (a != null) {
a.doSomething()
}
可以使用?.操作符简化为:
a?.doSomething()
我们可以使用?.操作符对之前的printMessage() 函数进行优化:
fun printMessage(message: String?) {
println("处理前")
val messageUpperCase = message?.uppercase(Locale.getDefault())
println("处理后:$messageUpperCase")
}
这样我们就成功将 if 判断语句给去除了,代码更加简洁。现在你可能会觉得,使用 if 语句来进行判空处理也不复杂啊。这是因为当前的代码还比较简单,当需要判空的对象越来越多时,你就会知道 ?. 操作符很好用了。
空合并操作符 ?:
另外还有 ?: 操作符。它的左右两边都会接收一个表达式:如果左边表达式的结果不为空,就返回左边表达式的结果;否则就返回右边表达式的结果。
比如下面这段代码:
val c = if (a ! = null) {
a
} else {
b
}
使用?:操作符可以简化为:
val c = a ?: b
举个例子,现在我们要编写一个函数用来获得一段文本的长度,你可以这样写:
fun getTextLength(text: String?): Int {
if (text != null) { // 文本不为空
return text.length // 返回文本的长度
}
return 0 // 否则文本为空返回 0
}
使用 ?: 操作符可以简化:
fun getTextLength(text: String?): Int {
return text?.length ?: 0
}
怎么样,你想到了吗?
我们将 ?. 和 ?: 操作符结合在一起使用。由于 text 可为空,所以你获取 length 长度属性时,要使用 ?. 操作符。而当 text 为空时,也不用担心,text?.length会返回一个null,通过 ?: 操作符最终会返回 0。
非空断言操作符 !!
有时,我们可能在逻辑上确保了某个变量不为空,但编译器是不知道的,它还是会编译失败。例如:
var content: String? = "hello"
fun main() {
if (content != null){
printMessage()
}
}
fun printMessage() {
val messageUpperCase = content.uppercase(Locale.getDefault())
println(messageUpperCase)
}
这段代码并不能运行成功,即使 content 变量一定是非空的,但编译器还是认为这里可能存在空指针异常的风险,从而无法编译成功。
这时,为了强行通过编译,我们可以使用 !! 非空断言操作符。只需在对象的后面,方法调用的前面加上 !!,像这样:
fun printMessage() {
val messageUpperCase = content!!.uppercase(Locale.getDefault())
println(messageUpperCase)
}
这种写法有风险,因为这相当于在告诉编译器,我保证这个对象不为空,你不用帮我做空指针检查,即使出现了问题(抛出空指针异常),也由我自己来承担这个后果。
所以在使用 !! 之前,最好想想有没有更好的实现方式,因为在你自信对象不为空时,往往就是潜在的空指针异常发生的时候。
let 函数
let 是一个标准函数,提供了函数式API的编程接口,会将原始调用对象作为参数传递到 Lambda 表达式中。比如:
obj.let { obj2 -> // obj2 和 obj 其实是同一个对象
// 只有 obj 不为 null 时,才会执行这个代码块
}
那这有什么用?又和空指针检查有什么关系?
其实 let 函数是用来配合 ?. 操作符的,当我们想对一个可空对象执行多个操作时,就可以用它。
比如我们可以将这段代码:
fun printMessage(message : String?) {
val messageUpperCase = message?.uppercase()
println(messageUpperCase)
val messageLowercase = message?.lowercase()
println(messageLowercase)
val messageTrimIndent = message?.trimIndent()
println(messageTrimIndent)
}
通过使用?.操作符和let函数,将代码简化为:
fun printMessage(message: String?) {
message?.let {
val messageUpperCase = it.uppercase()
println(messageUpperCase)
val messageLowercase = it.lowercase()
println(messageLowercase)
val messageTrimIndent = it.trimIndent()
println(messageTrimIndent)
}
}
?.操作符在对象为空时会什么都不做,对象不为空时就会调用let函数,而let函数会将message调用对象本身作为参数传递到Lambda表达式中,那么我们在 Lambda 表达式中拿到的message对象就肯定不为空了,我们就能放心地调用它的任意方法了。
最后let函数是可以处理全局变量的判空问题的,而if判断语句无法做到这一点。
比如这样会报错:
var content: String? = "hello"
fun main() {
if (content != null){
printMessage()
}
}
fun printMessage() {
if (content != null){
val messageUpperCase = content.uppercase(Locale.getDefault())
println(messageUpperCase)
}
}
而改为使用let函数就不会报错了:
fun printMessage() {
content?.let {
val messageUpperCase = it.uppercase(Locale.getDefault())
println(messageUpperCase)
}
}
if 判断之所以无法做到,我们前面已经说过了。
而 let 函数之所以能解决全局变量的判空问题,是因为传入 let 代码块中的变量,只是全局变量传入时那一刻的副本,这个副本是一个不可变的局部变量,而不是外部的全局变量,保证了线程安全。