Kotlin:再见,空指针异常!

357 阅读5分钟

前言

空指针异常(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 代码块中的变量,只是全局变量传入时那一刻的副本,这个副本是一个不可变的局部变量,而不是外部的全局变量,保证了线程安全。