十、真、假和未定:可空性

在学校里,你学到了对与错的二分法,你可能听说过没有别的了。到目前为止,读完这本书,你已经知道在 Kotlin 中存在一个布尔类型Boolean,它有精确的可能值:truefalse。句号。真的吗?

如果你想想现实生活,经验会告诉你一些别的东西。问某人:明天会下雨吗?答案可能是肯定的,也可能是否定的。不过,老实说,没有人有百分之百的把握知道答案。因此我们有假、未定(或未知)。这种三分法被称为三值逻辑(也称为三值逻辑三值逻辑、三值逻辑)。为什么我们在这里谈论这个?这不是一本哲学书,是吗?对于类和对象,我们已经指出,计算机程序需要模拟真实世界的场景;因此,我们需要一些在计算机语言中既不是true也不是false的东西。

什么是空值

即使计算机语言开发人员不是真正的健全的哲学家,或者可能只是没有意识到这种三分法,未定从计算机语言历史的一开始就已经存在。只是没人这么叫它。比方说,你需要一个代表列表大小的变量。根据具体情况,大小为零的列表可能是有意义的,出于编码的原因,我们可能需要表明列表尚未定义。我们能做什么?嗯,大小的范围是 0,1,2,3,...,所以我们只取一个通常没有意义的数字,定义这个来代表一个还没有定义的。你能猜出这是什么数字吗?一个可能的答案是1

有了数组,情况就更加多样化了。通常数组由某个指针变量定义,该变量指向计算机内存中数组的第一个元素。如果我们需要说数组还没有定义,我们使用一个没有意义的指针值。这可能是1,但更实际的是值0。由于技术原因,在内存地址0实际启动一个数组是不可能的,所以未决定0是一个有效的选择。为了阐明真实内存地址和未确定内存地址之间的区别,表示后者的0只是获得了一个新名称:null。更有趣的是,在面向对象中我们还有指向类实例的指针,这些指针可以是null以及表示未决定的尚未定义的*。

除了紧接着truefalse的第三个伪布尔undecided,我们还有另一个用于数组和对象的undecided。它们之间有什么联系?请看下面的代码片段:

val b:Boolean = ... // some condition
if(b) {
    ... // do something
} else {
    ... // do something else
}

这里我们分支讨论是否满足某些条件。由于可以用null来表达它们的对象还没有被定义,在许多情况下你会有一个扩展版本:

val instance = ... // some object
val b:Boolean = ... // some condition
if(instance == null) {
    ... // do something
} else if(b) {
    ... // do something else
} else {
    ... // do something else
}

在这里,我们基于某事是真还是假,以及某事是否未定义来做出决定。现在,如果我们在一种虚构的计算机语言中有第三个布尔值undecided,它可以读作

val b:Boolean = ... // some three-valued condition
ifundecided(b) {
    ... // do something
} if(b) {
    ... // do something else
} else {
    ... // do something else
}

这两个构造,一个虚构的三值布尔和一个null对象引用,表达了相同的代码。这是两个犹豫不决的人相遇的地方。因为在 Kotlin 和我所知道的任何其他语言中,都不存在第三个布尔值,我们必须继续使用null来实现这个目的。

null有一个严重的问题:你还记得解引用操作符.是做什么的吗?它从.的左边取物体,用右边瞄准一个属性或函数。很自然地,对于未决定或null的对象,这种取消引用是没有意义的。不幸的是,许多计算机语言在这里都不太好,如果我们试图取消对null的引用,或者至少中断程序流并指示一个无效的程序流活动,就会崩溃。这种可空性给程序带来了不稳定性,困扰了几代开发人员。但是好处大于问题,所以仅仅避免可空性从来没有被认为是一个真正的选择。

Kotlin 内部如何处理可空性

Kotlin 引入了一些关于可空性的新概念,允许使用可空性,但避免了大多数相关的陷阱。首先,我们注意到默认情况下,Kotlin 不允许null值在你的应用中偷偷摸摸。类似这样的东西

var p:SomeType = ...
...
p = null

任何类型的属性都不允许使用。这同样适用于构造函数和函数调用:

class A(var p:SomeType) ...
A(null) // does not compile

fun f(p:SomeType) { ... }
f(null) // does not compile

有了这样的不可空属性,通过.的解引用将总是成功的。另一方面,如果我们希望一个属性、构造函数参数或函数参数可以为空,我们必须加上一个问号(?)到类型:

var p:SomeType? = ...
p = null // OK

class A(var p:SomeType?) ...
A(null) // OK

fun f(p:SomeType?) { ... }
f(null) // OK

注意

因为您必须添加一些东西来允许可空性,所以 Kotlin 稍微倾向于非可空性。事实上,在很多情况下你可以避免使用null值,如果是这样,你很有可能有一个好的应用设计。

对于这种可空类型,Kotlin 知道通过.property.function()的解引用可能会失败,并禁止使用它们:

var p:SomeType? = ...
...
p.property    // does not compile
p.function()  // does not compile

如果值碰巧不是null,那么这也是被禁止的。

那么我们如何使用这样的可空属性呢?答案是我们必须使用 Kotlin 提供的空安全操作符之一。因此,对于解引用.,有一个空安全变量?.,可以用于可空属性:

var p:SomeType? = ...
...
p?.property     // OK
p?.function()   // OK

不同的是,如果pnullp?.property本身求值为nullp?.function()中的函数不会被调用,调用也求值为null

var p:SomeType? = null

val res:TypeOfProperty? = p?.property       // -> null

val res2:TypeOfFunct? = p?.function()       // -> null
// ... and function() not invoked

另一个被设计成零安全的操作员是埃尔维斯操作员?:。我们已经知道这个了。如果那个不是null,它就评估到它的左边,否则就评估到它的右边。

var p:String? = "Hello"
var s1 = p?:"default" // -> "Hello"
p = null
var s2 = p?:"default" // -> "default"

Kotlin 不能总是知道一个属性是否可以为空。在这种情况下,使用!!操作符可能会有所帮助,它也被称为 not null 断言操作符。它取它的左边,不管它是否能评估为null,都假定它不能是null。如果你的应用需要别人写的程序,你可能偶尔会用到它。对于用其他语言编写并且没有应用 Kotlin 的空检查机制的库来说尤其如此。当然,如果你试图通过使用.取消引用它,而这个值意外地是null,你的应用将会崩溃。尽一切可能避免这种情况,或者知道该怎么做。

警告

使用!!你基本上绕过了 Kotlin 的空检查机制,所以尽量避免它。

var p:String? = ...
// for whatever reason we know that p cannot be null

val len = p!!.length
// valid, because the !! indicates it cannot be null
// If it accidentally _is_ null, we'll crash here.

顺便说一下,如果你应用!!,Kotlin 是相当聪明的。在同一个函数的后续语句中,它记得我们应用了这个断言,并继续假设值不能是null。你可以写作

var p:String? = ...

val len = p!!.length
val intVal = p.toInt()

在这里,最后一个语句只编译,因为在这一行之前的某个地方有!!。*