一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 1 天,点击查看活动详情
📢📢📢 最近我们团队在翻译《Effective Kotlin: Best practices》,关注我们,我们会定时更新,欢迎指正,欢迎收藏 😁
条目1:减少可变性
TL;DR:
- 优先使用 val 而非 var
- 优先使用不可变集合
- 使用 data class 减少模板代码
- 需要修改对象时,考虑使用不可变的 data class ,并使用 copy 函数完成修改
- 尽可能减少修改点
- 尽量不要暴露可变对象
Kotlin 有类、对象、函数、类型别名、顶级属性等类型,其中一部分可以持有状态。可读可写
属性可用 var
或 MutableXxx
对象声明:
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
持有帐号余额这个状态。持有此状态是个双刃剑,在方便修改的同时,也让这个状态的维护变得更加困难,因为:
- 可变状态有多处修改点且互相依赖时,会让理解和维护变得更加困难,尤其是有些状态会触发错误时。
- 可变状态有时需处理多线程同步问题。
- 可变状态更难测试,因为它存在更多组合条件。
- 当状态是可变时,其它类通常需要监听其变化。如:一个排好序的 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 集合类图继承关系图。其中左侧为只读而右侧为可读可写类型。
只读集合背后通常还是可读写集合,只是其修改接口被隐藏了,参考以下为 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 的List
。List
有 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 中set
和map
背后都是使用哈希表实现,当我们修改可变对象值时,就再也无法在集合中找到它了。详见 条目 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>()
以上方式,会让多线程场景下需要关注两个同步问题:属性本身的引用变化和属性内部的值变化。同时,也会让 +=
操作符变得不可用。
普遍的规则是:不要提供不必要的状态修改接口。所有的状态修改都会带来理解和维护成本。
不要暴露修改点
暴露保存状态的可修改对象是很危险的。如下例:
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 函数完成修改
- 明智地设计修改点,尽可能减少修改点
- 不要暴露可变对象
以上规则都有例外情况。有些时候我们会选择可变对象,因为这样会更高效,这类优化只应存在于对性能有较高要求的部分(将在本书第三部分讨论),且当考虑使用可变对象时,必须记得它的修改是否有多线程问题。