kotlin入门(七)空指针检查(关于判空的操作)

1,920 阅读12分钟

我之前看过某国外机构做的一个统计,Android系统上崩溃率最高的异常类型就是空指针异常(NullPointerException)。相信不只是Android,其他系统上也面临着相同的问题。若要分析其根本原因的话,我觉得主要是因为空指针是一种不受编程语言检查的运行时异常,只能由程序员主动通过逻辑判断来避免,但即使是最出色的程序员,也不可能将所有潜在的空指针异常全部考虑到。

我们来看一段非常简单的Java代码:

//java写法
public void doStudy(Study study) {
    study.readBooks();
    study.doHomework();
}

这是我们在kotlin入门(五)编写过的一个doStudy()方法,我将它翻译成了Java版。这段代码没有任何复杂的逻辑,只是接收了一个Study参数,并且调用了参数的readBooks()和doHomework()方法。

这段代码安全吗?不一定,因为这要取决于调用方传入的参数是什么,如果我们向doStudy()方法传入了一个null参数,那么毫无疑问这里就会发生空指针异常。因此,更加稳妥的做法是在调用参数的方法之前先进行一个判空处理,如下所示:

//java写法
public void doStudy(Study study) {
    if (study != null) {
        study.readBooks();
        study.doHomework();
    }
}

这样就能保证不管传入的参数是什么,这段代码始终都是安全的。

由此可以看出,即使是如此简单的一小段代码,都有产生空指针异常的潜在风险,那么在一个大型项目中,想要完全规避空指针异常几乎是不可能的事情,这也是它高居各类崩溃排行榜首位的原因。

然而,Kotlin却非常科学地解决了这个问题,它利用编译时判空检查的机制几乎杜绝了空指针异常。虽然编译时判空检查的机制有时候会导致代码变得比较难写,但是不用担心,Kotlin提供了一系列的辅助工具,让我们能轻松地处理各种判空情况。下面我们就逐步开始学习吧。

1 可空类型系统

还是回到刚才的doStudy()函数,现在将这个函数再翻译回Kotlin版本,代码如下所示:

fun doStudy(study: Study) {
    study.readBooks()
    study.doHomework()
}

这段代码看上去和刚才的Java版本并没有什么区别,但实际上它是没有空指针风险的,因为Kotlin默认所有的参数和变量都不可为空,所以这里传入的Study参数也一定不会为空,我们可以放心地调用它的任何函数。如果你尝试向doStudy()函数传入一个null参数,则会提示如下图一所示的错误:

图1.png

也就是说,Kotlin将空指针异常的检查提前到了编译时期,如果我们的程序存在空指针异常的风险,那么在编译的时候会直接报错,修正之后才能成功运行,这样就可以保证程序在运行时期不会出现空指针异常了。

看到这里,你可能产生了巨大的疑惑,所有的参数和变量都不可为空?这可真是前所未闻的事情,那如果我们的业务逻辑就是需要某个参数或者变量为空该怎么办呢?不用担心,Kotlin提供了另外一套可为空的类型系统,只不过在使用可为空的类型系统时,我们需要在编译时期就将所有潜在的空指针异常都处理掉,否则代码将无法编译通过。

那么可为空的类型系统是什么样的呢?很简单,就是在类名的后面加上一个问号。比如,Int表示不可为空的整型,而Int?就表示可为空的整型(加了一个?);String表示不可为空的字符串,而String?就表示可为空的字符串。

回到刚才的doStudy()函数,如果我们希望传入的参数可以为空,那么就应该将参数的类型由 Study改成Study?,代码见下图:

图1.png

可以看到,现在在调用doStudy()函数时传入null参数,就不会再提示错误了。然而你会发现,在doStudy()函数中调用参数的readBooks()和doHomework()方法时,却出现了一个红色下滑线的错误提示,这又是为什么呢?

其实原因也很明显,由于我们将参数改成了可为空的Study?类型,此时调用参数的readBooks()和doHomework()方法都可能造成空指针异常,因此Kotlin在这种情况下不允许编译通过。

那么该如何解决呢?很简单,只要把空指针异常都处理掉就可以了,比如做个判断处理,如下所示:

fun doStudy(study: Study?) {
    //后面还会介绍,我们知道对象不会为空的情况
    //通过加一个 !
    if (study != null) {
        study.readBooks()
        study.doHomework()
    }
}

现在代码就可以正常编译通过了,并且还能保证完全不会出现空指针异常。

其实学到这里,我们就已经基本掌握了Kotlin的可空类型系统以及空指针检查的机制,但是为了在编译时期就处理掉所有的空指针异常,通常需要编写很多额外的检查代码才行。如果每处检查代码都使用if判断语句,则会让代码变得比较啰嗦,而且if判断语句还处理不了全局变量的判空问题。为此,Kotlin专门提供了一系列的辅助工具,使开发者能够更轻松地进行判空处理,下面我们就来逐个学习一下。

2 判空辅助工具

首先学习最常用的?.操作符。这个操作符的作用非常好理解,就是当对象不为空时正常调用相应的方法,当对象为空时则什么都不做。比如以下的判空处理代码:

if (a != null) {
    a.doSomething()
}

这段代码使用?.操作符就可以简化成:

a?.doSomething()

了解了?.操作符的作用,下面我们来看一下如何使用这个操作符对doStudy()函数进行优化,代码如下所示:

fun doStudy(study: Study?) {
    fun doStudy(study: Study?) {
        study?.readBooks()
        study?.doHomework()
    }
}

可以看到,这样我们就借助?.操作符将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?) = text?.length ?: 0

这里我们将?.和?:操作符结合到了一起使用,首先由于text是可能为空的,因此我们在调用它的length字段时需要使用?.操作符,而当text为空时,text?.length会返回一个null值,这个时候我们再借助?:操作符让它返回0。怎么样,是不是觉得这些操作符越来越好用了呢?不过Kotlin的空指针检查机制也并非总是那么智能,有的时候我们可能从逻辑上已经将空指针异常处理了,但是Kotlin的编译器并不知道,这个时候它还是会编译失败。

观察如下的代码示例:

var content: String? = "hello"
fun main() {
    if (content != null) {
        printUpperCase()
    }
}
fun printUpperCase() {
    val upperCase = content.toUpperCase()
    println(upperCase)
}

这里我们定义了一个可为空的全局变量content,然后在main()函数里先进行一次判空操作,当content不为空的时候才会调用printUpperCase()函数,在printUpperCase()函数里,我们将content转换为大写模式,最后打印出来。

看上去好像逻辑没什么问题,但是很遗憾,这段代码一定是无法运行的。因为printUpperCase()函数并不知道外部已经对content变量进行了非空检查,在调用toUpperCase()方法时,还认为这里存在空指针风险,从而无法编译通过。

在这种情况下,如果我们想要强行通过编译,可以使用非空断言工具,写法是在对象的后面加上!!,如下所示:

fun printUpperCase() {
    val upperCase = content!!.toUpperCase()
    println(upperCase)
}

这是一种有风险的写法,意在告诉Kotlin,我非常确信这里的对象不会为空,所以不用你来帮我做空指针检查了,如果出现问题,你可以直接抛出空指针异常,后果由我自己承担。

虽然这样编写代码确实可以通过编译,但是当你想要使用非空断言工具的时候,最好提醒一下自己,是不是还有更好的实现方式。你最自信这个对象不会为空的时候,其实可能就是一个潜在空指针异常发生的时候。

最后我们再来学习一个比较与众不同的辅助工具——let(后面会经常用到,因为太方便了)。let既不是操作符,也不是什么关键字,而是一个函数。这个函数提供了函数式API的编程接口,并将原始调用对象作为参数传递到Lambda表达式中。示例代码如下:

obj.let { obj2 ->
// 编写具体的业务逻辑
}

可以看到,这里调用了obj对象的let函数,然后Lambda表达式中的代码就会立即执行,并且这个obj对象本身还会作为参数传递到Lambda表达式中。不过,为了防止变量重名,这里我将参数名改成了obj2,但实际上它们是同一个对象,这就是let函数的作用。

let函数属于Kotlin中的标准函数,在下一章中我们将会学习更多Kotlin标准函数的用法。你可能就要问了,这个let函数和空指针检查有什么关系呢?其实let函数的特性配合?.操作符可以在空指针检查的时候起到很大的作用。

我们回到doStudy()函数当中,目前的代码如下所示:

fun doStudy(study: Study?) {
    study?.readBooks()
    study?.doHomework()
}

虽然这段代码我们通过?.操作符优化之后可以正常编译通过,但其实这种表达方式是有点啰嗦的,如果将这段代码准确翻译成使用if判断语句的写法,对应的代码如下:

//将?.展开后
fun doStudy(study: Study?) {
    if (study != null) {
        study.readBooks()
    }
    if (study != null) {
        study.doHomework()
    }
}

也就是说,本来我们进行一次if判断就能随意调用study对象的任何方法,但受制于?.操作符的限制,现在变成了每次调用study对象的方法时都要进行一次if判断。

这个时候就可以结合使用?.操作符和let函数来对代码进行优化了,如下所示:

fun doStudy(study: Study?) {
    study?.let { stu ->
        stu.readBooks()
        stu.doHomework()
    }
}

我来简单解释一下上述代码,?.操作符表示对象为空时什么都不做,对象不为空时就调用let函数,而let函数会将study对象本身作为参数传递到Lambda表达式中,此时的study对象肯定不为空了,我们就能放心地调用它的任意方法了。

另外还记得Lambda表达式的语法特性吗?当Lambda表达式的参数列表中只有一个参数时,可以不用声明参数名,直接使用it关键字来代替即可,那么代码就可以进一步简化成:

fun doStudy(study: Study?) {
    study?.let {
        it.readBooks()
        it.doHomework()
    }
}

在结束本篇内容之前,我还得再讲一点,let函数是可以处理全局变量的判空问题的,而if判断语句则无法做到这一点。比如我们将doStudy()函数中的参数变成一个全局变量,使用let函数仍然可以正常工作,但使用if判断语句则会提示错误,如下图所示:

图1.jpg

之所以这里会报错,是因为全局变量的值随时都有可能被其他线程所修改,即使做了判空处理,仍然无法保证if语句中的study变量没有空指针风险。从这一点上也能体现出let函数的优势。

好了,最常用的Kotlin空指针检查辅助工具大概就是这些了,只要能将本篇的内容掌握好,你就可以写出更加健壮、几乎杜绝空指针异常的代码了。