Effective Kotlin 翻译系列 - 第一章 - 条目 2 - 最小化变量范围

995 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 2 天,点击查看活动详情

📢📢📢 最近我们团队在翻译《Effective Kotlin: Best practices》,关注我们,我们会定时更新,欢迎指正,欢迎收藏 😁

翻译: jiajianchen 校对: zhiyueli

条目 2:最小化变量范围

TL;DR:

在任何情况下,我们都建议你尽可能地缩小变量或成员属性的范围。

介绍

当我们定义一个状态时,我们更倾向于参考以下准则来缩小变量属性的范围:

  • 优先使用本地变量而不是成员属性;
  • 在尽可能小的范围中使用变量,比如说,如果一个变量仅在循环中使用,那么请在循环中定义变量;

我们这里提到的元素的范围,指的是程序中元素的可见范围。在 Kotlin 中,元素的可见范围一般情况下都是由外部大括号决定的,并且通常可以访问当前范围外部的成员,比如下面这个例子:

val a = 1
fun fizz() {
  val b = 2
  print(a + b) // 当前位置可以访问 a
}
val buzz = {
  val c = 3
  print(a + c)
}
// 当前位置可以访问 a,但是不能访问 b 和 c

在上面这个例子,在方法fizz和方法buzz中,可以访问到外部范围的变量。但是,在外面并不能访问方法内的成员。

而下面这个例子会介绍如何限制变量的范围:

// 坏的
var user : User
for (i in users.indices) {
   user = users[i]
   print("User at $i is $user")
}
​
// 好的
for (i in users.indices) {
  val user = users[i]
  print("User at $i is $user")
}
​
// 同样的变量可见范围,更佳的语法实现
for ((i, user) in users.withIndex()) {
  print("User at $i is $user")
}

在第一个例子中,变量user不仅能在for循环中访问,而且在外部也能被访问;而在第二、三个例子中,我们将变量范围限制在循环当中。

类似的,平时可能会遇到可见范围嵌套的情况,比如lambda表达式中嵌套了另一个lambda表达式,我们推荐最佳的做法依然是将变量定义在尽可能小的范围中。

为什么

至于为什么我们要这么做,有以下几个原因:

首先最重要的是:当我们收紧了变量的范围,能更容易地去跟踪和管理我们的程序。当我们分析代码的时候,我们需要去考虑目前都有哪些元素。如果需要处理的元素越多,那么进行下一步编程的难度就会越大;而如果程序越简单,那么它就越不容易被破坏。其次,这跟我们更喜欢用不可变的属性或对象的原因是类似的。结合可变的属性来考虑,如果它仅能在一个更小的范围中修改,那么我们能更容易跟踪它是如何被修改的。我们也能更容易地对其进行进一步的推理和修改。

另一个问题是,具备更宽泛范围的变量,可能会被另一个开发者滥用。举个例子:

  • 我们使用一个变量来记录列表的最后一个元素。做法是对列表进行遍历,不断通过列表对应值修改当前变量,在循环结束时当前变量即可获取到列表的最后一个元素。

但这可能会引发严重的问题,比如说在循环结束之后去修改这最后的元素。这就非常糟糕了,因为另一个开发者会努力去理解整套逻辑,分析出当前元素代表的数值究竟是什么。而带来的这些副作用很明显是不必要的。

译者注:作者举这个例子的意图其实是推荐使用一个不可变的属性来记录上述提到的“列表末尾值”。

除此之外,无论一个变量是只读的还是可读写的,我们会更推荐在变量定义时对其进行初始化。别让开发者被迫要去找变量定义的位置。实现上述观点,我们可以在表达式中使用一些控制结构(如ifwhentry-catch和 Kotlin 的多目运算符)。

// 坏的
val user : User
if (hasValue) {
  user = getValue()
} else {
  user = User()
}
​
// 好的
val user : User = if (hasValue) {
  getValue()
} else {
  User()
}

如果我们需要同时定义多个属性,可以使用 Kotlin 的解构声明语法:

// 坏的
fun updateWeather(degrees: Int) {
  val description: String
  val color: Int
  if (degrees < 5) {
    description = "cold"
    color = Color.BLUE
  } else if (degrees < 23) {
    description = "mild"
    color = Color.YELLOW
  } else {
    description = "hot"
    color = Color.RED
  }
}
​
// 好的
fun updateWeather(degrees: Int) {
  val (description, color) = when {
    degrees < 5 -> "cold" to Color.BLUE
    degrees < 23 -> "mild" to Color.YELLOW
    else -> "hot" to Color.RED
  }
}

隐患

最后,太宽泛的可见范围是很危险的。接下来讲一种一个常见的危险做法:变量捕获「Capturing」

当我在传授 Kotlin 协程知识的时候,我布置的其中一个练习题是:通过 Sequence Builder 来过滤出某个列表中的素数。解题思路如下:

  1. 创建一个从 2 开始的列表;
  2. 取列表中的第一个数,同时它也是一个素数;
  3. 接下来从列表中过滤掉所有能被这个素数整除的数字。

下面是这个算法的简单实现:

var numbers = (2..100).toList()
val primes = mutableListOf<Int>()
while (numbers.isNotEmpty()) {
  val prime = numbers.first()
  primes.add(prime)
  numbers = numbers.filter { it % prime != 0 }
}
print(primes) // [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97]

现在增加一点难度,我需要一个能返回无限个数的素数序列「sequence」。(如果你想要挑战这道题,你可以在这里停下来,并且尝试自己去实现它)

解决的答案如下:

val primes: Sequence<Int> = sequence {
  var numbers = generateSequence(2) { it + 1 }
  while (true) {
    val prime = numbers.first()
    yield(prime)
    numbers = numbers.drop(1).filter { it % prime != 0 }
  }
}
​
print(primes.take(10).toList()) // [2,3,5,7,11,13,17,19,23,29]

然后,几乎每个组里面都有一个人想要试图去“优化”它,认为不应该在每次循环中都创建变量来提取素数,于是改成了以下代码:

val primes: Sequence<Int> = sequence {
  var numbers = generateSequence(2) { it + 1 }
  var prime: Int
  while (true) {
    prime = numbers.first()
    yield(prime)
    numbers = numbers.drop(1).filter { it % prime != 0 }
  }
}

问题是,“优化”后的实现并不能正确工作,得出的答案是错误的。

print(primes.take(10).toList())
// [2,3,5,6,7,8,9,10,11,12]

(你可以在这停下来,思考为什么会是这个结果)

为什么会出现这样的结果,是因为我们访问的是变量prime。当我们使用Sequence时候,执行过滤操作是惰性的。在每一次循环中,我们添加越来越多的循环操作。在“优化后”的代码中,我们添加的过滤器引用的prime是可变的,因此,每次执行过滤逻辑时使用的必然是最后一次变量 prime 的值(而并非预期的值),因此导致我们得出的结果是错误的。

了解到上面的情况之后,我们应该多注意获取数值过程中可能引发的意想不到的问题。要规避这些问题,我们推荐的做法是给变量设置更小的可见范围。

总结

结合许多原因,我们更推荐将变量定义在尽可能小的范围当中。而且对于本地变量而言,相比于var,我们更推荐使用val。这些简单的规则可以为我们省去不少麻烦。