【译】说服Kotlin编译器代码安全

7,538 阅读5分钟

Kotlin 这门语言最出色的特点之一就是它内部自带的空值安全系统。如果你在要求非空的情况下使用空值,那么编译器会就会发出警告。

不过确保空值安全偶尔也会造成一些棘手的情况。你所熟知的毋庸置疑的代码也会布满空值的隐患...至少从编译器的角度来说是这样。

操纵 Map

来看一个例子。假设我们想将一个 List<String> 转化为 Map<String, Int>, 其中每一个 Int 代表对应 String 在数列中所出现的次数。我们可以这么写:

fun countInstances(list: List<String>): Map<String, Int> {
  val map = mutableMapOf<String, Int>()
  for (key in list) {
    if (key !in map) {
      map[key] = 0
    }
    map[key] = map[key] + 1
  }
  return map
}

代码逻辑正确但却无法编译。 Kotlin 认为这行代码有问题:

map[key] = map[key] + 1

map[key] 等同于 map.get(key) 。严格上来讲 get() 会返回 T? 类型,因为你可以给它提供一个本身不存在的关键词。即使知道 map[key] 不是空值,编译器意识不到你在每次使用 map[key] 前都会将其初始化。

我发现我在使用 Map.get() 时经常出现这个问题。我自己总是通过思考代码的逻辑来保障非空值的使用是否安全,但编译器无法对此进行核实。

我可以依赖使用运算符!!,但它看上去就像是一种警告 - 你不能无视编译器所产生的的错误。以下是其他几种可以解决这种问题的方法。

空值检查

不直接在 Map 上进行操作,而是通过先提取数值并存储于本地变量中再进行空值检查。

val oldValue = map[key]
if (oldValue != null) {
  map[key] = oldValue + 1
}
else {
  map[key] = 1
}

虽然 oldValue 是可为空类型( Int?),但它是一个本地变量,所以其他线程无法接触到它。这意味着编译器能确保在条件判断后这个变量的值不会再发生改变。结果就是 Kotlin 将其视为非空变量。

空值检查可以用,但是这个方法较为繁琐。

Elvis运算符

我们可以通过结合 Elvis 运算符将空值检查的解法压缩为单行代码:

map[key] = (map[key] ?: 0) + 1

Elvis 运算符会选择 map[key]0中第一个为非空值的那一个。这样能保证结果为整数类型,以便后期对其进行增值。

绝地心术

如果我们直接声明“这些都不是非空值”会发生什么呢?

其实 Kotlint 专门为此提供了 Map.getValue()。这个函数会返回 T 类而不是 T? 类。因此, map.getValue(key) 具有 map[key] 所不具备的功能:

map[key] = map.getValue(key) + 1

如果本来就没有值会发生什么?这种情况下,它会生成一个异常! getValue() 本身长这样:

val value = map[key] ?: throw new NoSuchElementException()

结合前文可得知, getValue()!!其实差不多。如果有空值存在它们都会生成异常,然而...

默认值

你可以通过使用 Map.withDefault() 来给你的 Map 提供默认值。使用这个方法的话, Map.getValue() 在找不到关键词的情况下会返回默认值:

fun countInstances(list: List<String>): Map<String, Int> {
  val map = mutableMapOf<String, Int>().withDefault { 0 }
  for (key in list) {
    map[key] = map.getValue(key) + 1
  }
  return map
}

在这种情况下, Map.getValue() 肯定!!好,因为它不可能产生异常。

如果你不想为整个 Map 设置默认值,你也可以分情况使用默认值,比如用 Map.getOrDefault():

map[key] = map.getOrDefault(key, 0) + 1

除了使用数值作为默认值,你还可以使用 Map.getOrElse() 将函数作为默认值:

map[key] = map.getOrElse(key, { 0 }) + 1

在这个例子里这么写很不明智,但如果默认值的计算很费时,这个方法会节省很多时间。(同时,由于 getOrDefault() 最近才添加到 Android 中,除非你所使用的最低开发版本为24,你还得用 Kotlin 的 getOrElse() 函数。)在这个例子中,设默认值和使用 Elvis 运算符都行。

集合的变形

除了遍历集合中的每一个元素,我们也可以将整个集合进行一次变形。变形能避免空值检查,因为我们所遍历的元素一定存在于集合中。

Kotlin 的标准库中内置很多不错的函数正好能解决我们的问题:

fun countInstances(list: List<String>) = list.groupingBy { it }.eachCount()

这里我们首先将 List 转化为 Grouping,然后我们用 Grouping.eachCount() 将其变形为 Map<String, Int>

集合层面的操作能力十分强大,经常会比遍历整个集合有用很多(主要是因为标准库会在背后进行优化)。

哪个最好?

我已经示范了几种能保证代码通过编译器的策略:

  1. 空值检查
  2. Elvis 运算符
  3. 转化为非空值 (可能出现异常)
  4. 默认值
  5. 集合变形

(我这么写并不意味着这就是所有的策略;不同情况可能有其它选择)

一般要根据代码的上下文来判断哪种方法最适合。在我们的例子中, groupingBy().eachCount() 肯定最好。它简洁,有效,不难理解,而且完全避免了空值检查。


感谢 Jake Wharton 对这篇文章的帮助

掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划