全面解析 Kotlin 可空性处理

1,771 阅读9分钟

初识可空性

Kotlin 和 Java 的类型系统之间第一条也可能是最重要的一条区别就是,kotlin 对可空类型的显示的支持。如果一个变量可以为 null,对变量的方法的调用就是不安全的,因为这样会导致 NullPointerException 空指针异常。Kotlin 不允许这样的调用,因而可以阻止许多可能的异常。

首先我们看一个 java 实现的函数:

int getLength(String str){
    return str.length();
}

这个函数是安全的吗?如果在调用这个函数的时候,传入一个 null 类型的实参,就会抛出 NullPointerException,那么是否需要添加类型的检查呢?这就取决于此函数的用途了。下面我们用 kotlin 来重写这个函数:

fun getLength(str:String)=str.length

使用可能为 null 的实参调用这个函数是不允许的,在编译期就被标记成错误:

image.png 这个函数中的参数被声明为 String 类型,在 kotlin 中这表示它必须包含一个 String 实例,这一点是由编译期强制实施,所以不能传给它一个 null 的实参。这样就保证了该函数永远不会在运行时抛出 NullPointerException。

如果你允许调用这个方法的时候传给它所有可能的实参,需要显式的在类型名称后面加上问号来标记它。

fun getLength(str:String?)=...

问号可以加在任何类型后面表示这个类型的变量可以存储 null 引用。 一旦你有一个可空类型的值,那么能对其进行的操作也将受到限制,如图

image.png

我们需要做些什么呢?最重要的就是和 null 进行比较,一旦进行了比较,编译器就会记住,并且在这个比较发生的作用域内把这个值当做非空来对待。

fun getLength(str:String?) = if (str!=null) str.length else 0

这样进行比较后,该操作就是一个正确的操作了。但是如果 if 检查是唯一处理可空性的工具,那么我们的代码将变得过于冗长。幸运的是,kotlin 提供了很多工具帮我们更简洁的处理可空性。下面就来一一介绍。

Kotlin 带来的便利

安全调用运算符:“?.”

该运算符允许你把一次 null 检查和一次方法调用合并成一个操作。例如

s?.toUpperCase()

就等同于下面这种繁琐的写法:

if (s!=null){
    s.toUpperCase()
}else{
    null
}

也就是说,如果你试图调用一个非空值的方法,这次方法调用会被正常的执行。但如果是 null 值,这次调用不会发生,而整个表达式的值为 null。

安全调用不光可以调用方法,也能用来访问属性。

class Employee(val name:String,val manager:Employee?)

fun getManagerName(employee: Employee):String? = employee.manager?.name

如果你的对象中有多个可空类型的属性,通常可以在同一个表达式中方便地使用多个安全调用。使用该运算符,不需要额外的检查,就可以在一行代码中访问到更深层次的属性。如下:

class Address(val streetAddress:String,val code:Int,val city:String)
class Company(val name:String,val address: Address?)
class Person(val name:String,val company: Company?)

fun Person.cityName():String{
    val city = this.company?.address?.city
    return if (city != null) city else "unKnown"
}

但是以上代码仍然有不必要的重复代码:用一个值和 null 比较,如果这个值不为空就返回这个值,否则返回其他值。接下来我们就使用工具将这些重复代码删除。

Elvis 运算符:“?:”

kotlin 有方便的运算符来提供代替 null 的默认值,这被称作 Elvis 运算符。我们看一下它是如何使用的:

fun foo(s:String?){
    val str:String = s?:""
}

Elvis 运算符接收两个运算数,如果第一个运算数不为 null,运算结果就是第一个运算数,否则运算结果就是第二个运算数。

Elvis 运算符经常和安全调用运算符一起使用,用一个值代替对 null 对象调用方法时返回的 null。下面就对之前的代码进行简化:

fun Person.cityName():String{
    val city = this.company?.address?.city
    return city ?: "unKnown"
}

在 kotlin 中有一种场景 Elvis 运算符非常适合,像 return 和 throw 这样的操作其实是表达式,因此可以把它们写在 Elvis 运算符的右边。这种情况下,如果 Elvis 运算符左边的值为 null,函数就会立即返回一个值或者抛出一个异常。

fun printPersonName(person: Person){
    val name = person.name ?: throw IllegalArgumentException("no name")
    print(name)
}

安全转换 “as?”

as 是用来转换类型的 Kotlin 运算符,和 java 类型转换一样,如果被转换的值不是你试图转换的类型,就会抛出 ClassCastException 异常。

as? 运算符尝试把值转换成指定的类型,如果值不是合适的类型则返回 null。我们可以将该运算符与 Elvis 结合使用,如实现 Person 类的 equals 方法:

class Person(val name:String,val company: Company?) {

    override fun equals(other: Any?): Boolean {
        // 如果不匹配,则返回 false
        val otherPerson = other as? Person ?: return false
        return otherPerson.name == name && other.company == company
    }
}

使用这种模式,可以很容易的检查实参是否是适当的类型,转换它,并在它的类型不正确的时候返回 false,这些操作全部在一个表达式中完成。

非空断言 “!!”

非空断言,是 kotlin 提供的最简单直接的处理可空类型值的工具。使用双感叹号表示,可以把任意值转换成非空类型。如果对 null 做非空断言,则会抛出异常。

fun ignoreNull(s:String){
    val notNull = s!!
    println(notNull.length)
}

如果该函数中 s 为 null 会发生什么?会在运行时抛出一个异常,但是抛出异常的位置是非空断言所在的那一行,而不是接下来试图使用那个值的那一行。

这种方式有些简单而粗暴,大部分情况是不适用这种方式的。

当我们使用这种方式时,异常调用栈跟踪的信息只表明异常发生在哪一行,而不会表明异常发生在哪一个表达式,为了让跟踪信息更加明确,最好避免在同一行中使用多个 !! 断言。

person.company!!.address!!.streetAddress

如果上面的代码发生异常,我们不能分辨出是 company 为 null 还是 address 为 null。

let 函数

let 函数让处理可空表达式变得更加容易,和安全调用运算符一起使用,它允许你对表达式求值,检查求值结果是否为 null,并把结果保存为一个变量。所有的这些操作都在同一个简单的表达式中。

let 函数所作的事情就是把一个调用它的对象变成 lambda 表达式的参数,结合安全调用语法,能有效的把调用 let 函数的可空对象,转变成非空类型。换句话说,安全调用的 let 只在表达式不为 null 时才会执行 lambda。

s?.let { 
    print(s.length)
}

我们看一下其对应的 java 代码就明白了

public static final void ignoreNull(@Nullable String s) {
   if (s != null) {
      boolean var2 = false;
      boolean var3 = false;
      int var5 = false;
      int var6 = s.length();
      boolean var7 = false;
      System.out.print(var6);
   }

}

延迟初始化属性

Kotlin 通常要求在构造方法中初始化所有属性,如果某个属性时非空类型,就必须提供非空的初始化值。否则就必须使用可空类型,如果这样做,该属性的每一次访问都需要 null 检查或者 !! 运算符。

class MyService{
    fun performAction():String = "foo"
}

class MyTest{
    private var myService:MyService?=null
    
    fun test(){
        myService!!.performAction()
    }
}

这样当我们反复使用这个属性的时候,就会很难看。为了解决这个问题,可以将 myService 属性声明为可以 延迟初始化 的,使用 lateinit 修饰符来完成这样的声明。

class MyService{
    fun performAction():String = "foo"
}

class MyTest{
    // 声明一个不需要初始化器的非空类型的属性
    private lateinit var myService:MyService

    fun test(){
        myService.performAction()
    }
}

注意:延迟初始化的属性都是 var,因为需要在构造方法之外修改它的值,而 val 属性会被编译成必须在构造方法中初始化的 final 字段。尽管这个属性时非空类型,但是不需要在构造方法中初始化它。

可空类型的扩展

为可空类型定义扩展函数是一种更强大的处理 null 的方式。可以允许接收者为 null 的扩展函数的调用,并在该函数中处理。

Kotlin 标准库中定义的 String 的两个扩展函数 isEmpty 和 isBlank 就是这样的例子。第一个函数判断字符串是否是一个空的字符串,第二个函数判断它是否是空的或者他只包含空白字符。

fun  verifyInput(input:String?){
    if (input.isNullOrBlank()){
        print("it is not ok")
    }
}

不需要安全访问,可以直接调用可空接收者声明的扩展函数,这个函数会处理可能的 null 值。我们看一下源码中这个函数是如何编写的。

@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean {
    contract {
        returns(false) implies (this@isNullOrBlank != null)
    }

    return this == null || this.isBlank()
}

当我们为一个可空类型定义扩展函数时,这意味着你可以对可空的值调用这个函数,并且函数体中的 this 可能为 null,所以你需要显示的检查。

类型参数的可空性

Kotlin 中所有泛型类和泛型函数的类型参数默认都是可空的,这种情况下,使用类型参数作为类型的声明都允许为 null,尽管类型参数 T 没有用问号结尾。

fun <T> printSomething(t:T){
    // 因为 t 可能为 null,所以必须使用安全调用
    print(t?.hashCode())
}

// 传入 null 进行调用
printSomething(null)

在该方法中,类型参数 T 推导出的类型是可空类型 Any? ,因此尽管没有使用问号结尾,实参 t 仍然允许使用 null。

要是类型参数非空,必须要为它指定一个非空的上界,那样泛型会拒绝可空值作为实参。

// 这样的话,T 就不是可空的
fun <T:Any> printSomething1(t:T){
    print(t.hashCode())
}

当我们再次使用 null 调用时

printSomething1(null)

编译器就会给提示错误Null can not be a value of a non-null type TypeVariable(T)

总结

Kotlin 针对空值,为我们提供了很多的便利,合理的使用这些方式,可以为我们的开发带来很多好处,希望通过本文可以让你在实际的开发中,处理空值更加游刃有余。