Effective Kotlin 翻译系列 - 第一章 - 条目 1 - 减少可变性

430 阅读8分钟

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

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

翻译: iptton 校对: zhiyueli

条目1:减少可变性

TL;DR:

  1. 优先使用 val 而非 var
  2. 优先使用不可变集合
  3. 使用 data class 减少模板代码
  4. 需要修改对象时,考虑使用不可变的 data class ,并使用 copy 函数完成修改
  5. 尽可能减少修改点
  6. 尽量不要暴露可变对象

Kotlin 有类、对象、函数、类型别名、顶级属性等类型,其中一部分可以持有状态。可读可写 属性可用 varMutableXxx 对象声明:

var a = 10
val list: MutableList<Int> = mutableListOf()

可读可写的属性,其行为受对它的操作历史影响。如下例:

class BankAccount {
  var balance = 0.0
    private set
  
  fun deposit(depositAmount: Double) {
    balance += depositAmount
  }
  
  @Throws(InsufficientFunds::class)
  fun withdraw(withdrawAmount: Double) {
    if (balance < withdrawAmount) {
      throw InsufficientFunds()
    }
    balance -= withdrawAmount
  }
}

class InsufficientFunds : Exception()

val account = BankAccount()
println(account.balance) // 0.0
account.deposit(100.0)
println(account.balance) // 100.0
account.withdraw(50.0)
println(account.balance) // 50.0

以上代码,BankAccount 通过 balance 持有帐号余额这个状态。持有此状态是个双刃剑,在方便修改的同时,也让这个状态的维护变得更加困难,因为:

  1. 可变状态有多处修改点且互相依赖时,会让理解和维护变得更加困难,尤其是有些状态会触发错误时。
  2. 可变状态有时需处理多线程同步问题。
  3. 可变状态更难测试,因为它存在更多组合条件。
  4. 当状态是可变时,其它类通常需要监听其变化。如:一个排好序的 list ,当其内部值变化时,需要重新排序。译注:这在 java 中是一个经典的问题:对 SortedList 的元素直接修改并不会主动触发排序。

在大项目中,开发者们通常要面对状态的一致性问题,和项目随着修改点变多而日益复杂的问题。下例展示在多线程场景下共享状态的问题。

var num = 0
for (i in 1..1000) {
  thread {
    Thread.sleep(10)
    num += 1
  }
}
Thread.sleep(5000)
println(num) // 一般会小于 1000,每次运行都有可能是不同的结果。

以上问题,如果使用协程,它的冲突会更少点...

译注:Kotlin 协程之所以冲突少点,原因是 Kotlin 协程的实现背后有线程池,而上例没用线程池,但用不用线程池都解决不了同步问题,这是多线程编程的基础知识。这里不再赘述。

如何在 Kotlin 中减少可变性

Kotlin 可通过以下方式达到限制可变性:

  • 只读属性 val
  • 区分可变及只读集合类
  • data class 的复制

只读属性 val

val a = 10
a = 20 // 编译错误

请注意,只读属性不一定是不可变的,也不一定是 final 的,只读属性也可以持有可变对象(可变对象的引用不可被修改,但是其内部值可以被修改):

val list = mutableListOf(1,2,3)
list.add(4) // 不会出错
print(list) // [1, 2, 3, 4]

只读属性也可自定义 getter ,依赖其它属性来获取自身的值:

var name: String = "Marcin"
var surname: String = "Moskala"
val fullName
    get() = "$name $surname"

Kotlin 的属性默认是被封装的,且其 getter setter 可被自定义。这个特性在 Kotlin 中非常重要,灵活使用它可以方便我们定义 API,这一使用具体会在 条目 16:属性应该代表状态而非行为 中讨论。其核心是:val不提供修改点,原理上则是 val 只提供了 getter 而var则同时提供了 getter 和 setter。因此,我们可以用 var 覆盖 val 属性:

interface Element {
  val active: Boolean
}

class ActualElement: Element {
  override var active: Boolean = false
}

译者注:以上 ActualElement 实际上是在继承原有接口 Element 上(只有 getter),再多加一个 setter 方法。

区分可变及只读集合类

Kotlin 提供了只读和可读写属性,同时也提供了只读和可读写的集合 (collections) 。下图为 Kotlin 集合类图继承关系图。其中左侧为只读而右侧为可读可写类型。

image-20220317193329015

只读集合背后通常还是可读写集合,只是其修改接口被隐藏了,参考以下为 Kotlin 标准库 map 函数的实现,实际使用的是 ArrayList ,但通过声明类型为 List 达到只读目的。

inline fun <T, R> Iterable<T>.map(
  transformation: (T) -> R
): List<R> {
  val list = ArrayList<R>()
  for (elem in this) {
    list.add(transformation(elem))
  }
  return list
}

知道以上原理,也许我们可以对只读属性进行类型转换?

val list = listOf(1,2,3)

// 不要这样做
if (list is MutableList) {
  list.add(4) // 这一行的行为不同平台不同表现
}

在 JVM 中, listOf 会返回一个 Arrays.ArrayList ,它继承于 Java 的ListList有 add/set 接口,但 Arrays.ArrayList 并未实现,因此以上代码在 JVM 平台上会出现错误:

Exception in thread “main” java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)

当然以上行为随时可能发生变化,它是不可预期的。因此,把不可变类型转为可变类型不应存在于 Kotlin 代码中。如果你实在需要把不可变转为可变类型,应使用 List.toMutableList 之类的方法,它会复制一份数据到可变集合中。

data class 的复制

不可变类型(immutable)有以下优点

  • 更易理解,因为一经创建即不再改变。
  • 更易进行跨线程共享状态。
  • 不可变对象的引用可被缓存。
  • (在提供给外部时)不需要做防御性拷贝,当需要拷贝时,也不需要进行深度拷贝。
  • 使用不可变对象构建其它对象(不管是可变还是不可变)是完美的,因为它只有一个修改点,即构造时。
  • 可添加到 set 集合中或使用它们当 map 的 key,而可变对象则不应如此使用。因为在 Kotlin/JVM 中 setmap 背后都是使用哈希表实现,当我们修改可变对象值时,就再也无法在集合中找到它了。详见 条目 41:遵从 hashCode 契约
val names: SortedSet<FullName> = TreeSet()
val person = FullName("AAA", "AAA")
names.add(person)
names.add(FullName("Jordan", "Hansen"))
names.add(FullName("David", "Blanc"))

print(s) //[AAA AAA, David Blanc, Jordan Hansen]
print(person in names) // true

person.name = "ZZZ"
print(names) // [ZZZ AAA, David Blanc, Jordan Hansen]
print(person in names) // false

如你所见,可变对象更危险且更不可预测。而当需要改变不可变对象时,应该由一个方法来创建修改后的新的对象。例如:Int 是不可变的,但它有如 plus / minus之类的方法在不修改原对象的前提下,创建修改后的对象。迭代器 (Iterable) 都是只读的,所以集合处理方法不会修改原对象而是返回一个新的集合:

class User(
  val name: String,
  val surname: String
) {
  fun withSurname(surname: String) = User(name, surname)
}

var user = User("Maja", "Markiewicz")
user = user.withSurname("Moskała")
print(user) // User(name=Maja, surname=Moskała)

以上代码是可行的,但如果要为每个属性都添加一个类似功能,这类模板代码又太乏味了。因此 Kotlin 提供了 data 修饰符解救我们,它提供了很多函数,其中一个是 copy 函数:创建一个新对象,除了新提供的属性外,此新对象的属性和原对象完全一样。data class生成的其它函数详见 条目 37: 使用 data 修饰符声明数据集。如下例:

data class User(
  val name: String,
  val surname: String
)

var user = User("Maja","Markiewicz")
user = user.copy(surname="Moskała")
print(user) //User(name=Maja,surname=Moskała)

不同类型的修改点

有两种方法声明一个可变 List,使用可变集合类型或使用可读写属性var:

val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()

两个属性都可以被修改,但是方式不同,比如同样都是添加元素 '1' 到集合中 :

list1.add(1)
list2 = list2 + 1

以上代码可以使用加号操作符来代替:

list1 += 1 // 相当于 list1.plusAssign(1)
list2 += 1 // 相当于 list2 = list2.plus(1)

以上代码看起来一样,实际上效果不同,list1 += 1 的结果是同一个集合对象,但是集合多了一个元素;而 list2 += 1 的结果是一个新的集合对象,且集合中多了一个元素。

两种方式都是正确的,但两种方式都不是线程安全的译注:作者很喜欢说线程安全,但实际上涉及修改,线程安全就不是一个简单话题,此段关于线程安全的描述与代码略

使用可变属性而非可变 List 有一个好处:可以通过设置自定义 setter 或 delegate 来监听属性变化:

var names by Delegates.observable(listOf<String>()) {
  _/* property: KProperty<*> */, old, new ->
  println("Names changed from $old to $new")
}
names += "Fabio" // Names changed form [] to [Fabio]

简言之,使用可变集合类型会有微略的性能优势,而使用可变属性则可对对象的修改进行控制。需注意的是,不要同时使用两种方式:

// 不要这样写
var list3 = mutableListOf<Int>()

以上方式,会让多线程场景下需要关注两个同步问题:属性本身的引用变化和属性内部的值变化。同时,也会让 += 操作符变得不可用。

image-20220317193405545

普遍的规则是:不要提供不必要的状态修改接口。所有的状态修改都会带来理解和维护成本。

不要暴露修改点

暴露保存状态的可修改对象是很危险的。如下例:

data class User(val name: String)
class UserRepository {
  private val storeUsers: MutableMap<Int, String> = mutableMapOf()
  
  fun loadAll(): MutableMap<Int, String> = storeUsers
}

使用者可通过 loadAll接口获得 storeUsers,并直接进行修改,这将让状态的修改变得不可控。建议的写法:

// 如果实在需要返回可变对象,使用 copy
fun loadAll(): MutableUser = user.copy()

// 如果没有返回可变对象的需要,则可返回不可变状态
fun loadAll(): Map<Int, String> = sotreUers

总结

本节我们了解了为什么限制可变性及优先选择不可变对象那么重要。Kotlin 为我们提供了很多方法,简单的规则如下:

  • 优先选择 val 而不是 var
  • 优先选择不可变属性而不是可变属性
  • 在需要修改对象时,考虑使用不可变的 data class ,并使用 copy 函数完成修改
  • 明智地设计修改点,尽可能减少修改点
  • 不要暴露可变对象

以上规则都有例外情况。有些时候我们会选择可变对象,因为这样会更高效,这类优化只应存在于对性能有较高要求的部分(将在本书第三部分讨论),且当考虑使用可变对象时,必须记得它的修改是否有多线程问题。