409 lines
17 KiB
Markdown
409 lines
17 KiB
Markdown
# 加餐三 | 什么是“不变性思维”?
|
||
|
||
你好,我是朱涛。
|
||
|
||
在[开篇词](https://time.geekbang.org/column/article/472129)里,我提到过学习Kotlin的五种思维转变,包括前面加餐中讲过的函数思维、表达式思维,以及接下来要给你介绍的不变性思维、空安全思维以及协程思维。所以今天,我们就一起来聊聊Kotlin的不变性思维。
|
||
|
||
Kotlin将不变性的思想发挥到了极致,并将其融入到了语法当中。当开发者想要定义一个变量的时候,必须明确规定这个变量是**可变的**(var),还是**不可变的**(val)。在定义集合变量的时候,也同样需要明确规定这个集合是可变的(比如MutableList),还是不可变的(比如List)。
|
||
|
||
不过,不变性其实会被很多Kotlin初学者所忽略。尤其是有Java、C经验的开发者,很容易将老一套思想照搬到Kotlin当中来,为了方便,写出来的变量全部都是var的,写出来的集合都是MutableXXX。
|
||
|
||
事实上,不变性思维,对我们写出优雅且稳定的Kotlin代码很关键。要知道,我们代码中很多的Bug都是因为某个变量被多个调用方改来改去,最后导致状态错误才出问题的。毕竟,变动越多,就越容易出错!
|
||
|
||
**那么,既然可变性这么“可恶”,我们为何不干脆直接在语法层面消灭var、MutableXXX这样的概念呢**?
|
||
|
||
这当然是不行的,因为我们的程序本身就是“为了产生变化而生的”。你想想,如果你的程序在运行过程中不会改变任何数据,那程序运行还有什么意义呢?而且你可以再想象一下,如果没有可变性,你打游戏的时候,画面根本就不会动啊!游戏数据不变,画面自然也不会动了。
|
||
|
||
所以说,**所谓不变性思维,是一种反直觉的思路**。这也是Kotlin从函数式编程领域借鉴过来的思想。在Kotlin当中,所谓的不变性思维,其实就是在满足程序功能可变性需求的同时,尽可能地消灭可变性。
|
||
|
||
这颇有一种“戴着镣铐跳舞”的意味。其实换句话理解一下,就是说我们应该:尽可能消灭那些**非必要**可变性。
|
||
|
||
下面,我们就一起来看几个代码案例吧,在解读案例的过程中,我们来具体理解下究竟什么是不变性思维。
|
||
|
||
## 表达式思维消灭var
|
||
|
||
那么现在,我们的目标就变成了**尽可能消灭那些非必要可变性**。沿着这个思路,我们很容易就可以想到一个方向:尽可能将var变成val。就比如下面这段代码:
|
||
|
||
```plain
|
||
fun testExp(data: Any) {
|
||
var i = 0
|
||
|
||
if (data is Number) {
|
||
i = data.toInt()
|
||
} else if (data is String) {
|
||
i = data.length
|
||
} else {
|
||
i = 0
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
在这段代码中,我们定义了一个可变的变量i,它的初始值是0,如果data是数字类型,我们就将其转换成整型,赋值给i;如果data是String类型,我们就将字符串的长度赋值给i,否则就用0赋值。
|
||
|
||
这段代码虽然没什么实际用途,但它代表了Java、C当中普遍存在的代码模式。这里的i,就是我们需要消灭的**非必要**可变性。其实,在学了上节课“表达式思维”以后,这个问题我们不费吹灰之力就可以解决了,也就是把整体的赋值逻辑变成一个表达式,代码示例如下所示:
|
||
|
||
```plain
|
||
fun testExp(data: Any) {
|
||
val i = when (data) {
|
||
is Number -> {
|
||
data.toInt()
|
||
}
|
||
is String -> {
|
||
data.length
|
||
}
|
||
else -> {
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
在这里你要注意,如果你把这个当做一道题目来做的话,它无疑是很容易的。但我想强调的是:在写Kotlin代码的时候,需要**一步到位**直接写出上面这样的代码。而想要做到这一点,你就一定要将不变性的思维铭记在心。因为要做到这一点其实不太容易,尤其是对Java、C开发者而言。
|
||
|
||
另外,如果你足够细心的话,相信你也发现了:我们上节课刚学的“表达式的思维”,对于我们的不变性思维,也是很有用的。
|
||
|
||
好,至此,我们就可以总结出不变性思维的第一条准则:**尽可能使用条件表达式消灭var。**
|
||
|
||
## 消灭数据类当中的可变性
|
||
|
||
在[第2讲](https://time.geekbang.org/column/article/473349)里,我们曾经简单学习过数据类,它是专门用来存放数据的类。它与Java当中的“Java Bean”是类似的,它的优势在于简洁。
|
||
|
||
不过,我仍然见过有人在Kotlin当中写出类似这样的代码:
|
||
|
||
```plain
|
||
class Person {
|
||
private var name: String? = null
|
||
private var age: Int? = 0
|
||
|
||
fun getName(): String? {
|
||
return name
|
||
}
|
||
|
||
fun setName(n: String?) {
|
||
name = n
|
||
}
|
||
|
||
fun getAge(): Int? {
|
||
return age
|
||
}
|
||
|
||
fun setAge(a: Int?) {
|
||
age = a
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
上面的代码就是明显在用Java思维写Kotlin代码,有时候,我还能看到这样的代码:
|
||
|
||
```plain
|
||
class Person {
|
||
var name: String? = null
|
||
var age: Int? = 0
|
||
}
|
||
|
||
```
|
||
|
||
这样的代码呢,看起来问题要小一些,但实际上呢,开发者脑子里想的可能是类似Java这样的代码模式:
|
||
|
||
```java
|
||
public class Person {
|
||
public String name = null;
|
||
public int age = 0;
|
||
}
|
||
|
||
```
|
||
|
||
不过,总的来说,大部分Kotlin开发者都能写出类似这样的代码:
|
||
|
||
```plain
|
||
data class Person(
|
||
var name: String?,
|
||
var age: Int?
|
||
)
|
||
|
||
```
|
||
|
||
但这段代码其实还可以继续优化。我们可以将var都改为val,就像下面这样:
|
||
|
||
```plain
|
||
// var -> val
|
||
data class Person(
|
||
val name: String?,
|
||
val age: Int?
|
||
)
|
||
|
||
```
|
||
|
||
而到这里,你可能就会产生一个疑问:**Person的所有属性都改为val以后,万一想要修改它的值该怎么办呢?**
|
||
|
||
比如说,直接修改它的值的话,这段代码就会报错:
|
||
|
||
```plain
|
||
class ImmutableExample {
|
||
// 修改Person的name,然后返回Person对象
|
||
fun changeUserName(person: Person, newName: String): Person {
|
||
person.name = newName // 报错,val无法修改
|
||
return person
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
这一点也是我们要尤为注意的:我们从Java、C那边带来的习惯,会促使我们第一时间想到上面这样的解决方案。但实际上,Kotlin更加推崇我们用下面的方式来解决:
|
||
|
||
```plain
|
||
class ImmutableExample {
|
||
fun changeUserName(person: Person, newName: String): Person =
|
||
person.copy(name = newName)
|
||
}
|
||
|
||
```
|
||
|
||
在这段代码中,我们并没有直接修改参数person的值,而是返回了一个新的person对象。我们借助数据类的copy方法,快速创建了一份拷贝的同时,还完成了对name属性的修改。类似这样的代码模式,就可以极大地减少程序出Bug的可能。
|
||
|
||
那么到这里,我们也就能总结出Kotlin不变性思维的第二条准则了:**使用数据类来存储数据,消灭数据类的可变性**。
|
||
|
||
## 集合的不变性
|
||
|
||
在Kotlin当中,提到不变性,我们就不得不谈它的**集合库**。我们在学习数据结构的时候,都会学到:数组、列表、HashMap、HashSet、栈、队列。这些概念在大部分的编程语言里都会存在,不过Kotlin在这方面的设计,却与Java之类的语言不太一样。
|
||
|
||
以最常见的列表(List)为例:
|
||
|
||
* 在Java当中,List是一个接口,它代表了一个可变的列表,会包含add()、remove()方法;
|
||
* 在Kotlin当中,List也是一个接口,但是它代表了一个不可变的列表,或者说是“只读的列表”。在它的接口当中,是没有add()、remove()方法的,当我们想要使用可变列表的时候,必须使用MutableList。
|
||
|
||
关于集合的不变性,我们其实在第9讲[委托](https://time.geekbang.org/column/article/479112)当中就提到了:
|
||
|
||
```plain
|
||
class Model {
|
||
val data: MutableList<String> = mutableListOf()
|
||
|
||
private fun load() {
|
||
// 网络请求
|
||
data.add("Hello")
|
||
}
|
||
}
|
||
|
||
fun main() {
|
||
val model = Model()
|
||
// 类的外部仍然可以修改data
|
||
model.data.add("World")
|
||
}
|
||
|
||
```
|
||
|
||
当我们将类内部的集合对象暴露给外部以后,我们其实没办法阻止外部对集合的修改。在第9讲当中,我们是通过**属性之间的直接委托**来解决这个问题的:
|
||
|
||
```plain
|
||
class Model {
|
||
val data: List<String> by ::_data
|
||
private val _data: MutableList<String> = mutableListOf()
|
||
|
||
fun load() {
|
||
_data.add("Hello")
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
但其实,要解决这个问题,我们也可以借助其他的方法,比如像下面这样:
|
||
|
||
```plain
|
||
class Model {
|
||
val data: List<String>
|
||
get() = _data // 自定义get
|
||
private val _data: MutableList<String> = mutableListOf()
|
||
|
||
fun load() {
|
||
_data.add("Hello")
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
在这段代码中,我们为data属性自定义了get()方法,然后返回了\_data这个集合的值。这种方式也可以实现目的。
|
||
|
||
另外,我们其实还有其他的办法:
|
||
|
||
```plain
|
||
class Model {
|
||
private val data: MutableList<String> = mutableListOf()
|
||
|
||
fun load() {
|
||
data.add("Hello")
|
||
}
|
||
|
||
// 变化在这里
|
||
fun getData(): List<String> = data
|
||
}
|
||
|
||
```
|
||
|
||
上面这种解决方案也很简单,我们直接对外暴露了一个方法,把这个方法的返回值类型改成了List类型,这样一来,外部访问这个集合以后就无法直接修改了。
|
||
|
||
以上这三种方式,本质上都是对外暴露了一个“不可变的集合”,完成了可变性的封装,它们基本上可以满足大部分的使用需求。不过啊,这三种方式其实还是会**存在一定的风险**,那就是外部可以进行类型转换,就像下面这样:
|
||
|
||
```plain
|
||
fun main() {
|
||
val model = Model()
|
||
println("Before:${model.getData()}")
|
||
val data = model.getData()
|
||
(data as? MutableList)?.add("Some data")
|
||
println("After:${model.getData()}")
|
||
}
|
||
|
||
结果:
|
||
Before:[]
|
||
After:[Some data]
|
||
|
||
```
|
||
|
||
针对这种特殊情况,我们可以根据实际情况来决定是否要规避这个问题。其实要解决这个问题的话也很容易,我们只需要借助Kotlin提供的toList函数,让data变成真正的List类型即可。
|
||
|
||
比如像下面这样:
|
||
|
||
```plain
|
||
class Model {
|
||
private val data: MutableList<String> = mutableListOf()
|
||
|
||
fun load() {
|
||
data.add("Hello")
|
||
}
|
||
|
||
// 变化在这里
|
||
// ↓
|
||
fun getData(): List<String> = data.toList()
|
||
}
|
||
|
||
// 改完以后的输出结果
|
||
Before:[]
|
||
After:[]
|
||
|
||
```
|
||
|
||
这段代码基本上可以帮助我们完成集合可变性的封装,不过在这里,有一点我们需要格外**注意**:当data集合数据量很大的时候,toList()操作可能会比较耗时。
|
||
|
||
好了,至此,相信你很快就能总结出第三条准则了:**尽可能对外暴露只读集合**。
|
||
|
||
## 只读集合与Java
|
||
|
||
另外,还有一点需要注意,当只读集合在Java代码中被访问的时候,它的不变性将会被破坏,因为Java当中不存在“不可变的集合”的概念。比如说,你在Java当中,仍然可以调用这个集合的set()方法。
|
||
|
||
```plain
|
||
public List<String> test() {
|
||
Model model = new Model();
|
||
List<String> data = model.getData();
|
||
data.set(0, "Some Data"); // 抛出异常 UnsupportedOperationException
|
||
return data;
|
||
}
|
||
|
||
```
|
||
|
||
因此,当我们在与Java混合编程的时候,Java里使用Kotlin集合的时候一定要足够小心,最好要有详细的文档。就比如说在上面的例子当中,虽然我们可以在Java当中调用set()方法,但是这行代码最终会抛出异常,引起崩溃。而引起崩溃的原因,是Kotlin的List最终会变成Java当中的“**SingletonList**”,它是Java当中的不可变List,在它的add()、remove()方法被调用的时候,会抛出一个异常。
|
||
|
||
不得不说,Java这样的实现方式真的很丑陋。我相信,可能很多Java开发者甚至都不知道Java居然还有SingletonList这个私有的类。
|
||
|
||
并且,只读集合在和Java混编的时候,不仅仅只有这一个问题。毕竟,当我们尝试修改只读集合的值的时候,Java可以抛出一个异常的话,那也算是一个可以勉强接受的结局了。
|
||
|
||
但实际的情况还会更差,如果我们将代码改成这样:
|
||
|
||
```plain
|
||
class Model1 {
|
||
val list: List<String> = listOf("hello", "world")
|
||
}
|
||
|
||
public class ImmutableJava {
|
||
public List<String> test1() {
|
||
Model1 model = new Model1();
|
||
List<String> data = model.getList();
|
||
System.out.println(data.get(0));
|
||
data.set(0, "some data"); // 注意这里
|
||
System.out.println(data.get(0));
|
||
return data;
|
||
}
|
||
}
|
||
|
||
// 结果
|
||
hello
|
||
some data
|
||
|
||
```
|
||
|
||
我们在Java代码当中调用data.set()方法,并没有引起异常,程序也正常执行完毕,并且结果也符合预期。在这种情况下,Kotlin的List被编译器转换成了 `java.util.Arrays$ArrayList` 类型。因此,我们Kotlin当中的只读集合,在Java当中就变成了一个普通的可变集合了。
|
||
|
||
事实上,对于Kotlin的List类型来说,在它转换成Java字节码以后,可能会变成多种类型,比如前面我们看到的SingletonList、`java.util.Arrays$ArrayList`,甚至还可能会变成java.util.ArrayList。在这里,我们完全不必去深究编译器背后的翻译规则,我们只需要时刻记住,Kotlin当中的只读集合,在Java看来和普通的可变集合是一样的。
|
||
|
||
至此,我们就能总结出第四条准则了:**只读集合底层不一定是不可变的,要警惕Java代码中的只读集合访问行为**。
|
||
|
||
## 反思:val 一定不可变吗?
|
||
|
||
最后,我们再来反思一下Kotlin的不变性。通常来说,我们用val定义的临时变量,都会将其看做是不可变的,也就是只读变量。但是别忘了,**val还可以定义成员属性**。而在这种情况下,它意味着:属性+ get方法。而且,我们还可以自定义get()方法。
|
||
|
||
所以这时候,我们就要睁大眼睛看清楚它的本质了。比如我们可以来看看下面这段代码:
|
||
|
||
```plain
|
||
object TestVal {
|
||
val a: Double
|
||
get() = Random.nextDouble()
|
||
|
||
fun testVal() {
|
||
println(a)
|
||
println(a)
|
||
}
|
||
}
|
||
|
||
// 结果
|
||
0.0071073054825220305
|
||
0.6478886064282862
|
||
|
||
```
|
||
|
||
很明显,在上面的代码中,我们通过自定义get()方法,打破了val的不变性。我们两次访问属性a所得到的值都不一样。对于val定义的成员属性,我们得到这样的结果并不会觉得奇怪,上面代码的运行结果也十分符合直觉。
|
||
|
||
那么你也许会这样想:我们用val定义的**局部变量**,那就一定是不可变的了吧?
|
||
|
||
答案仍然是**否定**的!
|
||
|
||
我们可以看看下面这个例子,这里我们同样是借助了委托相关的知识点:
|
||
|
||
```plain
|
||
class RandomDelegate() : ReadOnlyProperty<Any?, Double> {
|
||
override operator fun getValue(thisRef: Any?, property: KProperty<*>): Double {
|
||
return Random.nextDouble()
|
||
}
|
||
}
|
||
|
||
fun testLocalVal() {
|
||
val i: Double by RandomDelegate()
|
||
|
||
println(i)
|
||
println(i)
|
||
}
|
||
|
||
// 输出结果
|
||
0.1959507495773669
|
||
0.5166803777645403
|
||
|
||
```
|
||
|
||
在这段代码中,我们定义了一个委托RandomDelegate,然后把局部变量委托给了这个RandomDelegate,之后每次访问i这个变量,它的值都是不一样的。
|
||
|
||
那么,到这里,相信你马上就可以总结出第五条准则了:**val并不意味着绝对的不可变**。
|
||
|
||
## 小结
|
||
|
||
好了,我们来做一个简单的总结吧。所谓Kotlin的不变性思维,就是尽可能消灭代码中**非必要**的可变性。具体来说,一共有5大准则。
|
||
|
||
* 第一,**尽可能使用条件表达式消灭var**。由于Kotlin当中大部分语句都是表达式,我们可以借助这种思路减少var变量的定义。
|
||
* 第二,**使用数据类来存储数据,消灭数据类的可变性**。我们应该充分发挥Kotlin数据类的优势,借助它提供的copy方法,我们可以轻松实现不变性。
|
||
* 第三,**尽可能对外暴露只读集合**。根据[开放封闭原则](https://en.wikipedia.org/wiki/Open%25E2%2580%2593closed_principle),我们的程序应该尽量对修改封闭。借助Kotlin的只读集合,我们在这方面可以做得比Java更好。
|
||
* 第四,**只读集合底层不一定是不可变的,要警惕Java代码中的只读集合访问行为**。Kotlin为了兼容Java,它的集合类型必须要与Java兼容,因此它不能创造出Java以外的集合类型,这也就决定了它只能是语法层面的不可变性。Kotlin官方也在考虑进一步的优化,期待后续的版本能有更加优雅的处理方式。
|
||
* 第五,**val并不意味着绝对的不可变**。在Kotlin当中定义的val变量与属性,它们并非绝对不可变的。由于Kotlin的语法十分灵活,我们完全可以用val写出可变的局部变量和成员属性。这一点,我们一定要小心。
|
||
|
||
## 思考题
|
||
|
||
简单的五个准则,是不可能完全概括出Kotlin的不变性思维的。请问你还能想到其他的不变性准则吗?欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||
|