1. 上一章:第 5 章
  2. 下一章:第 7 章
Kotlin 图解指南 • 第 6 章

空值与空安全

章节封面图片
“Now serving number 12648430!”

到目前为止,在本书中,每次我们创建一个变量,无论是 StringInt 还是 Boolean,我们都会为其赋值。然而,有时候我们需要创建一个可能不实际持有值的变量!

这把我们带到了空值这个令人兴奋的话题!

Kotlin 中空值简介

James 在市中心开了一家咖啡摊,他准备好开始分享他的美味咖啡了!每次递出一杯咖啡后,他都会请客人评价咖啡,以便他可以与可能有兴趣的人分享评分。

一张供客人输入姓名、评论和星级评分的评价卡。

由于他想在一个 Kotlin 程序中跟踪评分,他编写了这个简单的类:

class CoffeeReview(
    val name: String,
    val comment: String,
    val stars: Int
)

name 是评价他咖啡的人的姓名,comment 属性用于他们想要分享的任何关于咖啡的评论,而 stars 属性持有星级评分——他们给咖啡的星星数量,范围是 0 到 5。

当天前三位客人已经填写了评价卡——让我们看看他们是如何评价他的咖啡的!

三张评价卡——Sarah,'咖啡很棒!',5星;Toby,'相当不错!',4星;Lucy,'会再购买!',未指定星级。

James 实例化了一些 CoffeeReview 对象来记录他收到的三条评价。他从前两个开始……

val saraReview = CoffeeReview("Sara", "Loved the coffee!", 5)
val tobyReview = CoffeeReview("Toby", "Pretty good!", 4)

然而,当他查看 Lucy 的评价时,他注意到她忘记留下星级评分了。”没关系,”他自言自语道,”既然她没有标记任何星星,我就用数字零代替吧。”

val lucyReview = CoffeeReview("Lucy", "Will buy this again!", 0)

他准备在屏幕上展示这些评价,所以他写了一个简单的函数,并用他收到的每条评价来调用它:

fun printReview(review: CoffeeReview) =
    println("${review.name} gave it ${review.stars} stars!")

println("Latest coffee reviews")
println("---------------------")
printReview(saraReview)
printReview(tobyReview)
printReview(lucyReview)

屏幕上显示的内容如下:

Latest coffee reviews
---------------------
Sara gave it 5 stars!
Toby gave it 4 stars!
Lucy gave it 0 stars!

他以为这个解决方案会很有效,但当客人们看到评价时,他们想:”哇,如果 Lucy 不喜欢这咖啡,可能确实不好喝。我还是去别家吧。”

哎呀!

James 意识到,零星评分没有评分是两件不同的事

两张评价卡——一张是客人圈了零作为星级评分,另一张是客人没有圈任何数字作为星级评分。

当某人不留下星级评分时,James 不想在屏幕上显示零星评分。相反,他需要一种方法告诉 Kotlin 他们根本没有留下评分。他该怎么做呢?

存在值与缺失值

正如你在第 1 章中所记得的,变量就像一个持有值的桶。

到目前为止,我们编写的所有代码中,我们创建的变量都是包含值的。换句话说,那个桶里总是有东西。例如,一个 stars 桶包含一个 Int,比如数字 5

一个标有'stars'的桶,里面有数字5。

对于 CoffeeReview 类,我们需要一个可能有值也可能没有值的 stars 桶。当客人留下评分时,桶里需要包含那个值;但当他们忘记评分时,那个桶需要是空的。

所以我们需要一个可以放值进去,也可以留空的桶。这就引出了两个新术语:

  • 当一个变量里面有值时,我们说这个值是存在的(present)
  • 当一个变量里面没有值时,我们说这个值是缺失的(absent)
'stars'桶……一次里面有数字5,另一次没有任何数字。

Kotlin 使用一个叫做 null关键字来表示值的缺失1 一个被赋值为 null 的变量就像一个桶。2

对于没有星级的评价,我们想把 stars 设为 null。我们可以在构造 Lucy 的 CoffeeReview 时尝试给它一个 null,但这样做时会出现错误:

val lucyReview = CoffeeReview("Lucy", "Will buy this again!", null)
Error

实际上,即使抛开 CoffeeReview 类,如果我们简单创建一个名为 starsInt 变量,也不能给它赋 null

val stars: Int = null
Error

为什么会这样?

在某些编程语言中,你可以给任何变量赋 null。这听起来可能是个好主意,但这会导致代码运行时出现很多意外,因为我们永远无法保证一个变量的值是存在的。

为了帮助防止这类问题,Kotlin 不会让你给任意变量赋 null。相反,你必须明确表示何时一个变量可以为空。

我们该怎么做呢?

可空类型与非空类型

在 Kotlin 中,我们使用不同的类型来表示一个变量是否可以设置为 null

到目前为止,我们在本书中使用过的所有类型——如 StringIntBoolean——都要求你赋一个实际值。你不能像上面尝试的那样给它们赋 null。由于它们不允许赋 null,我们称其为非空类型(non-nullable types)。相反,允许赋 null 的类型被称为可空类型(nullable types)

换句话说:

  • 当你想保证一个变量的值是存在的——也就是说,当这个值是必需的时——就给这个变量一个非空类型
  • 当你想允许一个变量的值是缺失的——也就是说,当这个值是可选的时——就给这个变量一个可空类型

在 Kotlin 中,可空类型以问号结尾。例如,Int非空类型,而 Int?(以问号结尾)是可空类型

每一个非空类型都有一个对应的可空类型。

非空类型:String、Int、Boolean、Circle、SchnauzerBreed;可空类型:String?、Int?、Boolean?、Circle?、SchnauzerBreed?

让我们再看一遍代码清单 6.6中的代码:

val stars: Int = null
Error

要允许这个变量接受 null,我们只需把变量的类型从非空类型 Int 改为可空类型 Int?,如下所示:

val stars: Int? = null

现在,stars 可以被设为 null。当然,它也可以仍然被设为一个正常的整数值。例如:

val saraStars: Int? = 5
val tobyStars: Int? = 4
val lucyStars: Int? = null

编译时和运行时

变量的类型告诉我们该变量能否持有 null,但无法告诉我们它实际上是否持有 null。

这引出了一个需要考虑的重要区别。有些事情是我们在 Kotlin 读取代码时就可以知道的,有些是我们在运行代码时才能知道的。

  • Kotlin 读取我们代码的时间点被称为编译时(compile time)。如果我们使用的是 IntelliJ 或 Android Studio 这样的 IDE,这发生在我们编写代码的过程中。
  • 我们的电脑运行 Kotlin 代码的时间点被称为运行时(runtime)

你可以把编译时想象成水管工正在组装水管的时刻,而运行时则类似于有人打开水龙头、水开始流经水管之后的时刻。

水管工正在组装水管——编译时。有人在水管组装好后从同一根水管接水倒入玻璃杯——运行时。

Kotlin 在编译时就知道变量的类型,这就是为什么它在编写代码时就能知道变量能否持有 null。另一方面,Kotlin 直到运行时才知道一个变量实际上是否持有 null。

在某些简单的情况下,比如代码清单 6.9,我们直接在代码中用字面量设置变量值时,对我们来说值是存在还是缺失似乎是显而易见的。事实上,当你在一个声明为可空的变量上使用非空值时,你的 IDE(如 IntelliJ 或 Android Studio)甚至可能会警告你——比如上面的 saraStarstobyStars

但值也可能来自外部来源,比如数据库、硬盘上的文件,或者用户键盘输入时。而且一旦你开始调用带有参数的函数时,实参的值可能在从一处调用时是缺失的,而从另一处调用时是存在的。

要想在运行时知道一个变量实际上是否有值,我们必须把它从可空类型转换为非空类型。下面我们将看到一些很酷的技巧!但首先,理解可空类型和非空类型之间的关系很重要。

尽管 IntInt? 是相关的,但它们仍然是两种不同的类型,你不能在使用 Int 的地方随意使用 Int?。例如,一个期望 Int 的函数如果你尝试传入 Int? 就不会工作:

fun printReview(name: String, stars: Int) =
    println("$name gave it $stars stars!")

val saraStars: Int? = 5

printReview("Sara", saraStars)
Error

为什么会这样?

为了理解这一点,让我们把注意力从评价屏幕转向柜台,那里 James 正在接收咖啡订单!

期望可空类型

目前,James 经营着一家非营利性咖啡慈善机构,向任何人提供热饮,即使他们无法支付。如果客人愿意捐赠一些付款,他们可以,但这不是必需的。

这是一个对这个安排的建模函数。慈善机构的付款是可选的,所以我们将把 payment 参数设为可空的

fun orderCoffee(payment: Payment?): Coffee {
    return Coffee()
}

当然,如果有人点咖啡并提供付款,James 会很高兴地给他们咖啡!

客人捐赠了付款并收到咖啡。

orderCoffee() 函数传递一个 Payment 实参就像这个场景——客人明确地提供了付款。

val payment: Payment = Payment()
val coffee = orderCoffee(payment)

现在,想象有人走进来,说:”这个盒子可能有付款……也可能是空的。里面的东西你可以拿走。”James 说:”即使是空的也没关系。毕竟我们是慈善机构。这是你的咖啡!”

客人捐赠了一个神秘盒子并收到咖啡。

当我们向 orderCoffee() 函数传递一个 Payment? 实参时,这就像是客人递给 James 一个神秘盒子,里面可能装着付款,也可能什么都没有。

val payment: Payment? = Payment() // or you could set this to null
val coffee = orderCoffee(payment)

总而言之,一个拥有像 Payment? 这样的可空参数的函数就像一个慈善机构——它可以接受非空类型(如 Payment)的实参,也可以接受可空类型(如 Payment?)的实参。两种都可以。

总结——orderCoffee 可以接收 'Payment' 或 'Payment?' 类型。

期望非空类型

过了一段时间,James 意识到他无法获得足够的捐款来维持慈善机构的运营,所以他现在把咖啡摊当作企业来经营。所有那些咖啡豆都需要钱,而且由于企业不是靠捐款运行的,他必须收到付款才能给客户提供咖啡。

这是 orderCoffee() 的一个新版本,它像企业而不是慈善机构那样运作。注意这个参数有一个非空类型,因为 payment 现在是必需的

fun orderCoffee(payment: Payment): Coffee {
    return Coffee()
}

和之前一样,当有人点咖啡并提供付款时,James 会很高兴地给他们咖啡。

客人提供付款并收到咖啡。

向这个函数传递一个 Payment 变量就像这个场景——客人明确地提供了付款,所以一切都很顺利。

val payment: Payment = Payment()
val coffee = orderCoffee(payment)

现在想象有人点了咖啡,但不是给他付款,而是递出一个盒子,说:”这个盒子可能有付款……也可能是空的。我用这个盒子里的东西换你的咖啡。”

”不行!”他说。”你必须真正为你的咖啡付款!我不能拿咖啡去换一个收到付款的机会。我必须实际收到付款!”

客人递出一个神秘盒子,但没有收到咖啡。

向这个函数传递一个 Payment? 变量就像这个场景——客人要么递给 James 付款,要么什么都没有。就像 James 一样,Kotlin 说:”不行!”(……嗯,实际上它说的是”类型不匹配”。)

val payment: Payment? = Payment() // or you could set this to null
val coffee = orderCoffee(payment)
Error

当 James 要求付款时,他不能接受一个可能没有的付款。它必须在那里。所以一个拥有 Payment 类型参数的函数就像一个企业——它不能接受 Payment? 类型的实参。

总结——这个版本的 orderCoffee 可以接收 'Payment' 但不能接收 'Payment?'。

总而言之,你可以在期望可空类型(如 Payment?)的地方使用非空类型(如 Payment),但反过来不行。

现在,客户的盒子里实际上很可能有付款!如果他们能把付款从盒子里拿出来,他们就能用那个付款换咖啡了!

同样地,Kotlin 给了我们几种不同的方法来安全地将可空类型转换为非空类型。让我们来看看!

使用条件语句检查 null

这是咖啡店企业的函数:

fun orderCoffee(payment: Payment): Coffee {
    return Coffee()
}

当顾客尝试用一个可能是空的盒子付款时,情况是这样的:

val payment: Payment? = Payment()
val coffee = orderCoffee(payment)
Error

在这种情况下仍然点咖啡的一个非常简单的方法是检查 payment 在代码运行时实际上是否有值。换句话说,我们可以看看盒子里,如果付款不是 null,那么我们就可以点咖啡。

val payment: Payment? = Payment()

if (payment != null) {
    val coffee = orderCoffee(payment)
} else {
    println("I can't order coffee today")
}

写这段代码时没有错误,而且运行时会调用 orderCoffee()

这是怎么工作的?为什么我们可以在代码清单 6.19中调用 orderCoffee(payment),但在代码清单 6.18中却不行?

尽管我们声明 paymentPayment? 类型(可空的),但在 if 代码块内部,它的类型会改变Payment(非空的)!Kotlin 知道 payment 在那个代码块内必须有一个值,因为我们检查过它!这被称为智能转换(smart cast)

与代码清单 6.19 相同的代码,但标注了每行 'payment' 的类型。除了 'if' 代码块内(第4行),它的类型是 'Payment?',其他地方都是 'Payment',其类型已被智能转换为 'Payment'。

智能转换也可以与 when 条件语句一起使用,如下所示:

when (payment) {
    null -> println("I can't order coffee today")
    else -> orderCoffee(payment)
}

顺便说一句,这不是 Kotlin 能执行的唯一一种智能转换。在以后讲解高级对象和类概念的章节中,我们会再次遇到它。

所以,用这种方式使用条件语句就像打开盒子,如果里面有东西,我们就点咖啡。

客人从神秘盒子中取出付款,递给 James,James 作为回报给他咖啡。

否则,如果盒子里什么都没有,我们就不点咖啡。

客人打开了盒子,但它是空的,所以他说:'今天我不喝咖啡了……'

使用条件语句进行智能转换只是将可空类型转换为非空类型的一种方式!接下来,让我们来看看 Elvis 运算符。

使用 Elvis 运算符提供默认值

在上面的代码中,我们仅在payment变量中有值时才点咖啡。如果在没有付款的情况下我们也能点咖啡就好了。例如,如果我们的 payment 变量为 null,也许我们的朋友可以替我们付款!

val payment: Payment? = null

if (payment != null) {
    val coffee = orderCoffee(payment)
} else {
    val coffee = orderCoffee(getPaymentFromFriend())
}

这允许我们在两种情况下都能点咖啡。如果 payment 实际上有值,我们就可以使用它。否则,我们就调用 getPaymentFromFriend() 函数,它返回一个我们可以使用的 Payment 值。

正如我们在第 3 章中学到的,我们可以使用if 表达式来替代if 语句,if 表达式可以将 coffee 变量从 ifelse 代码块中提取出来。让我们对代码做这个小改动,使其更加简洁。

val payment: Payment? = null

val coffee = if (payment != null) {
    orderCoffee(payment)
} else {
    orderCoffee(getPaymentFromFriend())
}

我们还在两个分支中都调用了 orderCoffee(),所以让我们也把它从 ifelse 代码块中提取出来:

val payment: Payment? = null

val coffee = 
    orderCoffee(if (payment != null) payment else getPaymentFromFriend())

代码清单 6.23中高亮的代码在处理可空类型时非常常见:检查值是否存在……如果存在就使用该值,否则使用某个默认值。为了让这种常见的表达式更易于编写,Kotlin 为我们提供了 Elvis 运算符 ?:,它之所以叫这个名字,是因为如果你把头向左倾斜并眯起眼睛,它看起来有点像 Elvis Presley 发际线上方的一双眼睛的表情符号。(你可能需要发挥一点想象力!)

不管怎样,让我们来看看如何使用 Elvis 运算符使代码更加简洁:

val payment: Payment? = null

val coffee = 
    orderCoffee(payment ?: getPaymentFromFriend())

这段代码的作用与代码清单 6.21、6.22和6.23中的代码相同——只是更简短、更易读。

使用 Elvis 运算符就像打开盒子,如果里面有东西,我们就使用它。

客人从神秘盒子中取出付款,递给 James,换取咖啡。

否则,如果盒子是空的,我们就从别的地方获取一个值来使用。

客人的盒子是空的,所以他向朋友伸手,朋友给他付款。他把它递给 James,换取一杯咖啡。

使用非空断言运算符来坚持要求存在一个值

我犹豫要不要提这个。它很危险,但在一些罕见的情况下,它可能是一个有用的选择。

如果你确定在代码运行时一个可空变量必定会有一个值,那么你就可以使用非空断言运算符 !! 来将它转换为非空类型。在点咖啡时它看起来是这样的:

val payment: Payment? = Payment()
val coffee = orderCoffee(payment!!)

payment 变量的类型是 Payment?(可空的),但表达式 payment!! 的类型是 Payment(非空的)。

通过在变量名 payment 后面加上 !!,就像是在对 Kotlin 说:”相信我……代码运行时,payment 不会是 null!”如果你错了——如果变量确实是 null,你会在代码运行时得到一个错误:

val payment: Payment? = null
val coffee = orderCoffee(payment!!) // Error: KotlinNullPointerException

非空断言运算符就像把手伸进盒子里,如果里面有东西,我们就使用它。

客人从神秘盒子中取出付款,递给 James,换取咖啡。

否则,如果盒子是空的……

爆炸——轰!

这就是非空断言运算符危险的原因!在上述其他情况下——使用条件语句检查 null,以及使用 Elvis 运算符——我们不可能得到错误,因为 Kotlin 关于可空类型的规则不允许这样做。但在这里,我们放弃了那种空安全,承担了变量在代码运行时实际上可能是 null 的风险。

编译时错误与运行时错误

我们可以在编译时运行时获得错误。

  • 代码清单 6.18 展示了一个编译时错误的例子——IDE 在我们编写代码时会高亮显示问题。
  • 上面的代码清单 6.26 展示了一段会导致运行时错误的代码,但与编译时错误不同,没有高亮提示我们错误会发生。

一般来说,编译时错误比运行时错误更有帮助,因为我们能更早地知道它。事实上,Kotlin 在我们修复错误之前甚至不会允许我们运行代码!另一方面,运行时错误是邪恶的,它们通常更难追踪。

当我们使用非空断言运算符 !! 时,我们避免了编译时错误,但冒着一个可能最终出现运行时错误的风险。如果你确定变量在运行时不会是 null,那么考虑使用非空类型代替。如果由于某种原因你不能这样做,那么非空断言运算符可能就是你所需要的。但只能作为最后的手段!

说明何时使用非空断言运算符的流程图。上一段描述了这个流程。

上面流程图中提到的作用域函数,其工作方式与智能转换类似。我们将在以后的章节中学习它们。

Kotlin 还给了我们另一个空安全工具。让我们来看看!

使用安全调用运算符调用函数和属性

回到第 4 章,我们看到了对象如何拥有函数属性,我们可以用点字符来调用它们。例如,假设我们的 Payment 类有一个属性,告诉我们客户使用什么类型的付款方式,是现金、支票还是卡片:

enum class PaymentType {
    CASH, CHECK, CARD;
}

class Payment(
    val type: PaymentType = PaymentType.CASH
)

在这种情况下,当我们收到一个 Payment 时,我们可能想用它做些什么,比如打印出 type。让我们更新 orderCoffee() 函数来做到这一点。

fun orderCoffee(payment: Payment): Coffee {
    val paymentType = payment.type.name.lowercase()
    println("Thank you for supporting us with your $paymentType")
    return Coffee()
}

payment 参数是非空的 Payment 类型时,这运行得很好。但当它的类型是可空的 Payment? 类型时——就像 James 经营慈善机构时那样——我们就会得到一个编译时错误:

fun orderCoffee(payment: Payment?): Coffee {
    val paymentType = payment.type.name.lowercase()
    println("Thank you for supporting us with your $paymentType")
    return Coffee()
}
Error

为什么会这样?

记住——一个具有可空类型的变量——比如 Payment?——就像一个可能是空的、也可能有值的桶……而我们要到运行时才会知道它是否是空的!如果代码运行时空桶确实是空的,那么就没有实际的 payment 来获取 type 了。

换句话说,如果 payment 不在那里,那么 payment type 也不在那里!除非我们知道 payment 的值存在,否则获取 type 是不安全的。值得庆幸的是,Kotlin 给了我们一个编译时错误,迫使我们处理这个事实。

在本章中,我们学到了一些可以帮助我们的技巧。例如,我们可以使用 if 来进行智能转换,像这样:

fun orderCoffee(payment: Payment?): Coffee {
    val supportType = if (payment == null) {
        "encouragement"
    } else {
        payment.type.name.lowercase()
    }

    println("Thank you for supporting us with your $supportType")
    return Coffee()
}

当这段代码运行时,如果 payment 为 null,我们会打印”感谢您以鼓励的方式支持我们”。否则,如果 payment 的值存在,那么消息将基于付款的 type,比如”感谢您以现金的方式支持我们”

这当然可以工作,但需要写很多代码。Kotlin 为我们提供了一个安全调用运算符 ?.,可以做同样的事情,只是更简洁。我们可以将它与 Elvis 运算符结合使用,达到与代码清单 6.30相同的效果,像这样:

fun orderCoffee(payment: Payment?): Coffee {
    val supportType = 
        payment?.type?.name?.lowercase() ?: "encouragement"
    println("Thank you for supporting us with your $supportType")
    return Coffee()
}

那么,安全调用运算符是如何工作的呢?

当我们看 payment.type.name.lowercase() 时,它看起来有点像一列火车。3

A train with cars that have labels for payment, type, name, and toLowerCase(), each separated by a dot.lowercase()nametypepayment...

代码清单 6.31中的主要变化是我们替换了火车车厢的连接器——之前我们使用点 .,现在我们使用安全调用运算符 ?.

The same train, but with each separated by a safe-call operator, '?.'paymentlowercase()nametype

当 Kotlin 评估这样一个”火车”表达式时,你可以想象它从车厢到车厢地跳跃,从左到右。当下一个连接器是安全调用运算符 ?. 时,它会问:”这个车厢的表达式求值为 null 吗?”如果是,那么它就带着 null 跳下火车。

The Kotlin logo bouncing off of the first car with a null.nulllowercase()nametypepayment

否则,它跳到下一个车厢,重复这个过程,直到找到一个 null 或带着最终值跳下车尾。

The Kotlin logo bouncing off of each car in succession, then hopping off of the caboose with the payment type string 'cash'.cashlowercase()nametypepayment

一般来说,在编写火车表达式时,如果其中一个车厢具有可空类型,那么它之后的火车连接器需要是安全调用运算符,而不仅仅是点运算符。(我说的是一般来说——当我们在讲解扩展函数和属性的章节时,会看到一个例外!)

总结

恭喜!你在这章中学到了很多东西,包括:

正确处理空值是每个优秀的 Kotlin 程序员必备的技能。在下一章中,我们将学习另一个重要概念——lambda 表达式。届时见!

感谢 Louis CAD 和 James Lorenzen 审阅本章。


  1. 在某些基于拉丁语的语言中,”null”这个词与数字零的关系更密切,但在英语中,它更常指没有价值或效果的东西。当你在 Kotlin 中看到它时,不要把它当作数字零;而要把它当作”没有值”。 ↩︎

  2. 从技术上讲,即使 null 本身也可以被认为是一个值。毕竟,在现实生活中,即使是一个空桶从技术上讲也充满了空气。所以,你可能会听到有人说:”那个变量的值是 null”,这没问题。然而,由于用”存在”和”缺失”这些容易理解的概念来学习更容易,在本文中,我们将把 null 视为值的缺失,而不是把它本身当作一个值。 ↩︎

  3. 事实上,术语”train wreck(火车残骸)”曾被用来描述这样的表达式——许多函数或属性调用链接在一起。在本书中,我不会对这类表达式的优点或缺点进行评论。所以,我不把它叫作火车残骸,而只叫它火车! ↩︎