Kotlin:空指针检查

Kotlin相比Java,一个很大改进就是空指针检查。

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

public void doStudy(Study study) {
    
    
    study.readBooks();
    study.doHomeWork();
}

上述代码没有任何复杂的逻辑,只是接收了一个Study参数,并且调用了参数的readBooks()和doHomeWork()方法。

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

public void doStudy(Study study) {
    
    
   if (study!=null) {
    
    
       study.readBooks();
       study.doHomeWork(); 
    }
  }

这么简单一小段代码,都有产生空指针异常的潜在风险。如果是大型项目,想要完全避免空指针几乎是不可能的事情。

1. 可空类型系统

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

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

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

这段代码看上去和刚刚的Java代码并没有什么区别,但实际上它是没有空指针风险的,因为Kotlin默认所有的参数和变量都不可为空,所以这里传入的Study参数也一定不会为空,我们可以放心地调用它的任何函数。如果你尝试向doStudy函数传入一个null参数,则会提示如下图所示错误:
在这里插入图片描述
也就是说,Kotlin将空指针异常的检查提前到了编译时期,如果我们的程序存在空指针异常的风险,那么在编译的时候会直接报错,修正之后才能成功运行,这样就可以保证程序在运行时期不会出现空指针异常了。

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

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

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

test.doStudy(null)

这里可以传入null了,但发现在调用readBooks、doHomeWork方法时,却出现了一个红色波浪线的错误提示。如下:
在这里插入图片描述
这是为什么呢?

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

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

   fun doStudy(study: Study?) {
    
    
        if (study != null) {
    
    
            study.readBooks()
            study.doHomeWork()
        }
    }

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

其实除了添加if判断,Koltin专门提供了一系列的辅助工具,使开发者能够更轻松地进行判空处理。

2. 判空辅助工具

2.1 ?. 操作符

首先学习最常见的 ?. 操作符。

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

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

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

a?.doSomeThing()

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

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

可以看到,这样我们就借助 ?. 操作符将if 判断语句去掉了。可能你会觉得使用if语句来进行判空处理也没什么复杂的,那是因为目前的代码还比较简单,当以后我们开发的功能越来越复杂,需要判断的对象也越来越多的时候,你就会感受到 ?. 操作符特别好用了。

2.2 ?: 操作符

?: 这个操作符的左右两边都接收一个表达式,如果左边表达式的结果不为空就返回左边表达式的结果,否则就返回右边表达式的结果。

观察如下代码:

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"

override fun onCreate(savedInstanceState: Bundle?) {
    
    
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)

  if (content != null) {
    
    
      printUpperCase()
  }
}

fun printUpperCase() {
    
    
   val toUpperCase = content.toUpperCase()
   print(toUpperCase)
}

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

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

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

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

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

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

2.3 辅助工具 let

let不是操作符,也不是关键词.而是一个函数.

let 的示例代码如下:

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

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

那,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判断语句则会提示错误.

var study: Study? = null

if (study != null) {
    
    
    study.readBooks()
    study.doHomework()
}

在这里插入图片描述

Smart cast to 'xxx' is impossible, because 'xxx' is a mutable property that could have been changed by this time

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

至此,Kotlin 空指针检查辅助工具学习到此结束。

猜你喜欢

转载自blog.csdn.net/gaolh89/article/details/105906061