Kotlin教程(四)可空性

4,347 阅读15分钟

写在开头:本人打算开始写一个Kotlin系列的教程,一是使自己记忆和理解的更加深刻,二是可以分享给同样想学习Kotlin的同学。系列文章的知识点会以《Kotlin实战》这本书中顺序编写,在将书中知识点展示出来同时,我也会添加对应的Java代码用于对比学习和更好的理解。

Kotlin教程(一)基础
Kotlin教程(二)函数
Kotlin教程(三)类、对象和接口
Kotlin教程(四)可空性
Kotlin教程(五)类型
Kotlin教程(六)Lambda编程
Kotlin教程(七)运算符重载及其他约定
Kotlin教程(八)高阶函数
Kotlin教程(九)泛型


这一章实际上在《Kotlin实战》中是第六章,在Lambda之后,但是这一章的内容实际上是Kotlin的一大特色之一。因此,我将此章的内容提到了前面汇总。

可空性

可空性是Kotlin类型系统中帮助你避免NullPointerException错误的特性。

可空类型

如果一个变量可能为null,对变量的方法的调用就是不安全的,因为这样会导致NullPointerException。例如这样一个Java函数:

int strLen(String s) {
    return s.length();
}

如果这个函数被调用的时候,传给它的是一个null实参,它就会抛出NullPointerException。那么你是否需要在方法中增加对null的检查呢?这取决与你是否期望这个函数被调用的时候传给它的实参可以为null。如果不可以的话,我们用Kotlin可以这样定义:

fun strLen(s: String) = s.length

看上去与Java没有区别,但是你尝试调用strLen(null) 就会发现在编译期就会被标记成错误。因为在Kotlin中String 只能表示字符串,而不能表示null,如果你想支持这个方法可以传null,则需要在类型后面加上?

fun strLen(s: String?) = if(s != null) s.length else 0

? 可以加在任何类型的后面来表示这个类型的变量可以存储null引用:String?Int?MyCustomType?等。

一旦你有一个可空类型的值,能对它进行的操作也会受到限制。例如不能直接调用它的方法:

    val s: String? = ""
//    s.length  //错误,only safe(?.) or non-null asserted (!!.) calls are allowed
    s?.length   //表示如果s不为null则调用length属性
    s!!.length  //表示断言s不为null,直接调用length属性,如果s运行时为null,则同样会crash

也不能把它赋值给非空类型的变量:

    val x: String? = null
//    val y: String = x  //Type mismatch

也就是说,加? 和不加可以看做是两种类型,只有与null进行比较后,编译器才会智能转换这个类型。

fun strLen(s: String?) = if(s != null) s.length else 0  

这个例子就与null进行比较,于是String? 类型被智能转换成String 类型,所以可以直接获取length属性。

Java有一些帮助解决NullPointerException问题的工具。比如,有些人会使用注解(@Nullable和@NotNull)来表达值得可空性。有些工具可以利用这些注解来发现可能抛出NullPointerException的位置,但这些工具不是标准Java编译过程的一部分,所以很难保证他们自始至终都被应用。而且在整个代码库中很难使用注解标记所有可能发生错误的地方,让他们都被探测到。

Kotlin的可空类型完美得解决了空指针的发生。 注意,可空的和非空的对象在运行时没有什么区别:可空类型并不是非空类型的包装。所有的检查都发生在编译器。这意味着使用Kotlin的可空类型并不会在运行时带来额外的开销。

安全调用运算符:"?."

Kotlin的弹药库中最有效的一种工具就是安全调用运算符:?. ,它允许你爸一次null检查和一次方法调用合并成一个操作。例如表达式s?.toUpperCase() 等同于if (s != null) s.toUpperCase() else null 。 换句话说,如果你视图调用一个非空值得方法,这次方法调用会被正常地执行。但如果值是null,这次调用不会发生,而整个表达式的值为null。因此表达式s?.toUpperCase() 的返回类型是String?

安全调用同样也能用来访问属性,并且可以连续获取多层属性:

class Address(val street: String, val city: String, val country: String)

class Company(val name: String, val address: Address?)

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

fun Person.countryName(): String {
    val country = this.company?.address?.country  //多个安全调用链接在一起
    return if (country != null) country else "Unknown"
}

Kotlin 可以让null检查的变得非常简洁。在这个例子中你用一个值和null比较,如果这个值不为空就返回这个值,否则返回其他的值。在Kotlin中有更简单的写法。

Elvis运算符:"?:"

if (country != null) country else "Unknown" 通过Elvis运算符改写成:

country ?: "Unknown"

Elvis运算符接受两个运算数,如果第一个运算数不为null,运算结果就是第一个运算数,如果第一个运算数为null,运算结果就是第二个运算数。 fun strLen(s: String?) = if(s != null) s.length else 0 这个例子也可以用Elvis运算符简写:fun strLen(s: String?) = s?.length ?: 0

安全转换:"as?"

之前我们学习了as 运算符用于Kotlin中的类型转换。和Java一样,如果被转换的值不是你试图转换的类型,就会抛出ClassCastException异常。当然你可以结合is 检查来确保这个值拥有合适的类型。但Kotlin作为一种安全简洁的语言,有优雅的解决方案。 as? 运算符尝试把值转换成指定的类型,如果值不合适的类型就返回null。 一种常见的模式是把安全转换和Elvis 运算符结合使用。例如equals方法的时候这样的用法非常方便:

class Person(val name: String, val company: Company?) {
    override fun equals(other: Any?): Boolean {
        val o = other as? Person ?: return false  //检查类型不匹配直接返回false
        return o.name == name && o.company == company //在安全转换后o被智能地转换为Person类型
    }

    override fun hashCode(): Int = name.hashCode() * 31 + (company?.hashCode() ?: 0)
}

非空断言:"!!"

非空断言是Kotlin提供的最简单直接的处理可空类型值得工具,它可以把任何值转换成非空类型。如果对null值做非空断言,则会抛出异常。 之前我们也演示过非空断言的用法了:s!!.length

你可能注意到双感叹号看起来有点粗暴,就像你冲着编译器咆哮。这是有意为之的,Kotlin的设计设视图说服你思考更好的解决方案,这些方案不会使用断言这种编译器无法验证的方式。

但是确实存在这样的情况,某些问题适合用非空断言来解决。当你在一个函数中检查一个值是否为null。而在另一个函数中使用这个值时,这种情况下编译器无法识别这种用是否安全。如果你确信这样的检查一定在其他某个函数中存在,你可能不想在使用这个值之前重复检查。这时你就可以使用非空断言。

"let" 函数

let函数让处理可空表达式变得更容易。和安全调用运算符一起,它允许你对表达式求值,检查求值结果是否为null,并把结果保存为一个变量。所有这些动作都砸系统一个简洁的表达式中。 可空参数最常见的一种用法应该就是被传递给一个接受非空参数的函数。比如说下面这个函数,它接收一个String类型的参数并向这个地址发送一封邮件,这个函数在Kotlin中是这样写的:

fun sendEmailTo(email: String) { ... }

不能把null传给这个函数,因此通常需要先判断一下然后调用函数: if(email != null) sendEmailTo(email) 。 但我们有另一种方式:使用let函数,并通过安全调用来调用它。let函数做的所有事情就是把一个调用它的对象变成lambda表达式的参数: email?.let{ email -> sendEmailTo(email) } let函数只有在email的值非空时才被调用,如果email值为null则{} 的代码不会执行。 使用自动生成的名字it 这种简明语法之后,可以写成:email?.let{ sendEmailTo(it) } 。(Lambda的语法在只有章节会详细讲)

延迟初始化的属性

很多框架会在对象实例创建之后用专门的方法来初始化对象。例如Android中,Activity的初始化就发生在onCreate方法中。而JUnit则要求你把初始化的逻辑放在用@Brefore注解的方法中。 但是你不能再狗仔方法中完全放弃非空属性的初始化器。仅仅在一个特殊的方法里初始化它。Kotlin通常要求你在构造方法中初始化所有属性,如果某个属性时非空类型,你就必须提供非空的初始化值。否则,你就必须使用可空类型。如果你这样做,该属性的每次访问都需要null检查或者!! 运算符。

class Activity {
    var view: View? = null

    fun onCreate() {
        view = View()
    }

    fun other() {
        //use view
        view!!.onLongClickListener = ...
    }
}

这样使用起来比较麻烦,为了解决这个麻烦,使用lateinit 修饰符来声明一个不需要初始化器的非空类型的属性:

class Activity {
    lateinit var view: View

    fun onCreate() {
        view = View()
    }

    fun other() {
        //use view
        view.onLongClickListener = ...
    }
}

注意,延迟初始化的属性都是var 因为需要在构造方法外修改它的值,而val 属性会被编译成必须在构造方法中初始化的final字段。尽管这个属性时非空类型,但是你不需要再构造方法中初始化它。如果在属性被初始化之前就访问了它,会得到异常"lateinit property xx has not been initialized" ,说明属性还没有被初始化。

注意lateinit属性常见的一种用法是依赖注入。在这种情况下,lateinit属性的值是被依赖注入框架从外部设置的。为了保证和各种Java框架的兼容性,Kotlin会自动生成一个和lateinit属性具有相同可见性的字段,如果属性的可见性是public,申城字段的可见性也是public。

public final class Activity {
   public View view;

   public final View getView() {
      View var10000 = this.view;
      if(this.view == null) {
         Intrinsics.throwUninitializedPropertyAccessException("view");
      }
      return var10000;
   }

   public final void setView(@NotNull View var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.view = var1;
   }

   public final void onCreate() {
      this.view = new View();
   }

   public final void other() {
   }
}

可空性的扩展

为可空类型定义扩展函数是一种更强大的处理null值的方式。可以允许接收者为null的(扩展函数)调用,并在该函数中处理null,而不是在确保变量为null之后再调用它的方法。 Kotlin标准库中定义的String的两个扩展函数isEmptyisBlank 就是这样的例子。第一个函数判断字符串是否是一个空的字符串"" 。第二个函数判断它是否是空的或则只包含空白字符。通常用这些函数来检查字符串是有价值的,以确保对它的操作是有意义的。你可能意识到,像处理无意义的空字符串和空白字符串这样处理null也很有用。事实上,你的确可以这样做:函数isEmptyOrNullisNullOrBlank 就可以由String? 类型的接收者调用。

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) { //此方法是String?的方法,不需要安全调用
        println("Please fill in the required fields")
    }
}

无论input是null还是字符串都不会导致任何异常。我们来看下isNullOrBlank 函数的定义:

public inline fun CharSequence?.isNullOrBlank(): Boolean = this == null || this.isBlank()

可以看到扩展函数是定义给CharSequence? (String的父类),因此不像调用String的方法那样需要安全调用。 当你为一个可空类型定义扩展函数时,这以为这你可以对可空的值调用这个函数;并且函数体中this可能为null,所以你必须显示地检查。在Java中,this永远是非空的,因为他引用的时当前你所在这个类的实例。而在Kotlin中,这并不永远成立:在可空类型的扩展函数中,this可以为null。 之前讨论的let 函数也能被可空的接收者调用,但它并不检查值是否为null。如果你在一个可空类型直接调用let 函数,而没有使用安全调用运算符,lambda的实参将会是可空的:

val person: Person? = ...
person.let { sendEmailTo(it) }  //没有安全调用,所以it是可空类型

ERROR: Type mismatch:inferred type is Person? but Person was expected

因此,如果想要使用let来检查非空的实参,你就必须使用安全调用运算符?. 就像之前看到的代码一样:person?.let{ sentEmailTo(it) }

当你定义自己的扩展函数时,需要考虑该扩展是否需要可空类型定义。默认情况下,应该把它定义成非空类型的扩展函数。如果发现大部分情况下需要在可空类型上使用这个函数,你可以稍后再安全地修改他(不会破坏其他代码)。

类型参数的可空性

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

fun <T> printHashCode(t: T) {
    println(t?.hashCode())
}

在该函数中,类型参数T推导出的类型是可空类型Any? 因此,尽管没有用问号结尾。实参t依然允许持有null。 要使用类型参数非空,必须要为它指定一个非空的上界,那样泛型会拒绝可空值作为实参:

fun <T: Any> printHashCode(t: T) {
    println(t.hashCode())
}

后续章节会讲更多的泛型细节,这里你只需要记得这一点就可以了。

可空性和Java

我们在Kotlin中通过可空性可以完美地处理null了,但是如果是与Java交叉的项目中呢?Java的类型系统是不支持可空性的,那么该如果处理呢? Java中可空性信息通常是通过注解来表达的,当代码中出现这种信息时,Kotlin就会识别它,转换成对应的Kotlin类型。例如:@Nullable String -> String?@NotNull String -> String。 Kotlin可以识别多种不同风格的可空性注解,包括JSR-305标准的注解(javax.annotation包下)、Android的注解(android.support.annitation) 和JetBrans工具支持的注解(org.jetbrains.annotations)。那么还剩下一个问题,如果没有注解怎么办呢?

平台类型

没有注解的Java类型会变成Kotlin中的平台类型 。平台类型本质上就是Kotlin不知道可空性信息的类型。即可以把它当做可空类型处理,也可以当做非空类型处理。这意味着,你要像在Java中一样,对你在这个类型上做的操作负有全部责任。编译器将会允许所有操作,它不会把对这些值得空安全操作高亮成多余的,但它平时却是这样对待非空类型值上的空安全操作的。 比如我们在Java中定义一个Person类:

public class Person {
    private  String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

我们在Kotlin中使用这个类:

fun yellAt(person: Person) {
    println(person.name.toUpperCase()) //不考虑null情况,但是如果为null则抛出异常
    println((person.name ?: "Anyone").toUpperCase()) //考虑null的可能
}

我们即可以当成非空类型处理,也可以当成可空类型处理。

Kotlin平台类型在表现为:Type!

val i: Int = person.name

ERROR: Type mistach: inferred type is String! but Int was expected

但是你不能声明一个平台类型的变量,这些类型只能来自Java代码。你可以用你喜欢的方式来解释平台类型:

val person = Person()
val name: String = person.name
val name2: String? = person.name

当然如果平台类型是null,赋值给非空类型时还是会抛出异常。

为什么需要平台类型? 对Kotlin来说,把来自Java的所有值都当成可空的是不是更安全?这种设计也许可行,但是这需要对永远不为空的值做大量冗余的null检查,因为Kotlin编译器无法了解到这些信息。 涉及泛型的话这样情况就更糟糕了。例如,在Kotlin中,每个来自Java的ArrayList 都被当作ArrayList<String?>?,每次访问或者转换类型都需要检查这些值是否为null,这将抵消掉安全性带来的好处。编写这样的检查非常令人厌烦,所以Kotlin的设计者作出了更实用的选择,让开发者负责正确处理来自Java的值。

继承

当在Kotlin中重写Java的方法时,可以选择把参数和返回类型定义成可空的,也可以选择把它们定义成非空的。例如,我们来看一个例子:

/* Java */
interface StringProcessor {
    void process(String value);
}

Kotlin中下面两种实现编译器都可以接收:

class StringPrinter : StringProcessor {
    override fun process(value: String) {
        println(value)
    }
}

class NullableStringPrinter : StringProcessor {
    override fun process(value: String?) {
        if (value != null) {
            println(value)
        }
    }
}

注意,在实现Java类或者接口的方法时一定要搞清楚它的可空性。因为方法的实现可以在非Kotlin的代码中被调用,Kotlin编译器会为你声明的每一个非空的参数生成非空断言。如果Java代码传给这个方法一个null值,断言将会触发,你会得到一个异常,即便你从没有在你的实现中访问过这个参数的值。

因此,建议你只有在确保调用该方法时绝对不会出现空值时,才用非空类型取接收平台类型。