跳转至

加餐三 什么是“不变性思维”?

你好,我是朱涛。

开篇词里,我提到过学习Kotlin的五种思维转变,包括前面加餐中讲过的函数思维、表达式思维,以及接下来要给你介绍的不变性思维、空安全思维以及协程思维。所以今天,我们就一起来聊聊Kotlin的不变性思维。

Kotlin将不变性的思想发挥到了极致,并将其融入到了语法当中。当开发者想要定义一个变量的时候,必须明确规定这个变量是可变的(var),还是不可变的(val)。在定义集合变量的时候,也同样需要明确规定这个集合是可变的(比如MutableList),还是不可变的(比如List)。

不过,不变性其实会被很多Kotlin初学者所忽略。尤其是有Java、C经验的开发者,很容易将老一套思想照搬到Kotlin当中来,为了方便,写出来的变量全部都是var的,写出来的集合都是MutableXXX。

事实上,不变性思维,对我们写出优雅且稳定的Kotlin代码很关键。要知道,我们代码中很多的Bug都是因为某个变量被多个调用方改来改去,最后导致状态错误才出问题的。毕竟,变动越多,就越容易出错!

那么,既然可变性这么“可恶”,我们为何不干脆直接在语法层面消灭var、MutableXXX这样的概念呢

这当然是不行的,因为我们的程序本身就是“为了产生变化而生的”。你想想,如果你的程序在运行过程中不会改变任何数据,那程序运行还有什么意义呢?而且你可以再想象一下,如果没有可变性,你打游戏的时候,画面根本就不会动啊!游戏数据不变,画面自然也不会动了。

所以说,所谓不变性思维,是一种反直觉的思路。这也是Kotlin从函数式编程领域借鉴过来的思想。在Kotlin当中,所谓的不变性思维,其实就是在满足程序功能可变性需求的同时,尽可能地消灭可变性。

这颇有一种“戴着镣铐跳舞”的意味。其实换句话理解一下,就是说我们应该:尽可能消灭那些非必要可变性。

下面,我们就一起来看几个代码案例吧,在解读案例的过程中,我们来具体理解下究竟什么是不变性思维。

表达式思维消灭var

那么现在,我们的目标就变成了尽可能消灭那些非必要可变性。沿着这个思路,我们很容易就可以想到一个方向:尽可能将var变成val。就比如下面这段代码:

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,就是我们需要消灭的非必要可变性。其实,在学了上节课“表达式思维”以后,这个问题我们不费吹灰之力就可以解决了,也就是把整体的赋值逻辑变成一个表达式,代码示例如下所示:

fun testExp(data: Any) {
    val i = when (data) {
        is Number -> {
            data.toInt()
        }
        is String -> {
            data.length
        }
        else -> {
            0
        }
    }
}

在这里你要注意,如果你把这个当做一道题目来做的话,它无疑是很容易的。但我想强调的是:在写Kotlin代码的时候,需要一步到位直接写出上面这样的代码。而想要做到这一点,你就一定要将不变性的思维铭记在心。因为要做到这一点其实不太容易,尤其是对Java、C开发者而言。

另外,如果你足够细心的话,相信你也发现了:我们上节课刚学的“表达式的思维”,对于我们的不变性思维,也是很有用的。

好,至此,我们就可以总结出不变性思维的第一条准则:尽可能使用条件表达式消灭var。

消灭数据类当中的可变性

第2讲里,我们曾经简单学习过数据类,它是专门用来存放数据的类。它与Java当中的“Java Bean”是类似的,它的优势在于简洁。

不过,我仍然见过有人在Kotlin当中写出类似这样的代码:

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代码,有时候,我还能看到这样的代码:

class Person {
    var name: String? = null
    var age: Int? = 0
}

这样的代码呢,看起来问题要小一些,但实际上呢,开发者脑子里想的可能是类似Java这样的代码模式:

public class Person {
    public String name = null;
    public int age = 0;
}

不过,总的来说,大部分Kotlin开发者都能写出类似这样的代码:

data class Person(
    var name: String?,
    var age: Int?
)

但这段代码其实还可以继续优化。我们可以将var都改为val,就像下面这样:

// var -> val
data class Person(
    val name: String?, 
    val age: Int?
)

而到这里,你可能就会产生一个疑问:Person的所有属性都改为val以后,万一想要修改它的值该怎么办呢?

比如说,直接修改它的值的话,这段代码就会报错:

class ImmutableExample {
    // 修改Person的name,然后返回Person对象
    fun changeUserName(person: Person, newName: String): Person {
        person.name = newName // 报错,val无法修改
        return person
    }
}

这一点也是我们要尤为注意的:我们从Java、C那边带来的习惯,会促使我们第一时间想到上面这样的解决方案。但实际上,Kotlin更加推崇我们用下面的方式来解决:

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讲委托当中就提到了:

class Model {
    val data: MutableList<String> = mutableListOf()

    private fun load() {
        // 网络请求
        data.add("Hello")
    }
}

fun main() {
    val model = Model()
    // 类的外部仍然可以修改data
    model.data.add("World")
}

当我们将类内部的集合对象暴露给外部以后,我们其实没办法阻止外部对集合的修改。在第9讲当中,我们是通过属性之间的直接委托来解决这个问题的:

class Model {
    val data: List<String> by ::_data
    private val _data: MutableList<String> = mutableListOf()

    fun load() {
        _data.add("Hello")
    }
}

但其实,要解决这个问题,我们也可以借助其他的方法,比如像下面这样:

class Model {
    val data: List<String>
        get() = _data // 自定义get
    private val _data: MutableList<String> = mutableListOf()

    fun load() {
        _data.add("Hello")
    }
}

在这段代码中,我们为data属性自定义了get()方法,然后返回了_data这个集合的值。这种方式也可以实现目的。

另外,我们其实还有其他的办法:

class Model {
    private val data: MutableList<String> = mutableListOf()

    fun load() {
        data.add("Hello")
    }

    // 变化在这里
    fun getData(): List<String> = data
}

上面这种解决方案也很简单,我们直接对外暴露了一个方法,把这个方法的返回值类型改成了List类型,这样一来,外部访问这个集合以后就无法直接修改了。

以上这三种方式,本质上都是对外暴露了一个“不可变的集合”,完成了可变性的封装,它们基本上可以满足大部分的使用需求。不过啊,这三种方式其实还是会存在一定的风险,那就是外部可以进行类型转换,就像下面这样:

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类型即可。

比如像下面这样:

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()方法。

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可以抛出一个异常的话,那也算是一个可以勉强接受的结局了。

但实际的情况还会更差,如果我们将代码改成这样:

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()方法。

所以这时候,我们就要睁大眼睛看清楚它的本质了。比如我们可以来看看下面这段代码:

object TestVal {
    val a: Double
        get() = Random.nextDouble()

    fun testVal() {
        println(a)
        println(a)
    }
}

// 结果
0.0071073054825220305
0.6478886064282862

很明显,在上面的代码中,我们通过自定义get()方法,打破了val的不变性。我们两次访问属性a所得到的值都不一样。对于val定义的成员属性,我们得到这样的结果并不会觉得奇怪,上面代码的运行结果也十分符合直觉。

那么你也许会这样想:我们用val定义的局部变量,那就一定是不可变的了吧?

答案仍然是否定的!

我们可以看看下面这个例子,这里我们同样是借助了委托相关的知识点:

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方法,我们可以轻松实现不变性。
  • 第三,尽可能对外暴露只读集合。根据开放封闭原则,我们的程序应该尽量对修改封闭。借助Kotlin的只读集合,我们在这方面可以做得比Java更好。
  • 第四,只读集合底层不一定是不可变的,要警惕Java代码中的只读集合访问行为。Kotlin为了兼容Java,它的集合类型必须要与Java兼容,因此它不能创造出Java以外的集合类型,这也就决定了它只能是语法层面的不可变性。Kotlin官方也在考虑进一步的优化,期待后续的版本能有更加优雅的处理方式。
  • 第五,val并不意味着绝对的不可变。在Kotlin当中定义的val变量与属性,它们并非绝对不可变的。由于Kotlin的语法十分灵活,我们完全可以用val写出可变的局部变量和成员属性。这一点,我们一定要小心。

思考题

简单的五个准则,是不可能完全概括出Kotlin的不变性思维的。请问你还能想到其他的不变性准则吗?欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。

精选留言(13)
  • BUG君 👍(16) 💬(1)

    使用copy()方法, 每次都会创建一个新的对象, 如果不注意在for循环里面使用该方法, 很可能会造成内存抖动

    2022-05-23

  • 魏全运 👍(7) 💬(2)

    kotlin 的函数式不变性思维也有一些不好的地方,比如对集合使用各种内置操作符链式调用时,每个操作都会new一个新的拷贝,这时候可以把集合在开始时转成sequence 来操作,避免构造太多中间变量。当然,这也不是万能的,sequence 是一个迭代器,不能覆盖所有场景

    2022-02-08

  • Paul Shan 👍(5) 💬(1)

    尽量避免写操作,不得不写的时候,优先考虑拷贝返回,最后才是提供读写操作。读写操作最直观,但是太容易到处写,最后很可能导致全局变量一样的毛病,不知道哪里修改了状态,如果有多线程并发的情况下,更加难调试。

    2022-03-20

  • Geek_66aa0c 👍(4) 💬(1)

    SingletonList是java.util.Collections类的内部类,只保存一个元素,在初始化需要赋值,如: List<String> list = Collections.singletonList("ok"); 并且不支持add、remove方法

    2022-02-15

  • 爱学习的小羊 👍(3) 💬(1)

    老师好,如果一个APP,需要有修改用户信息的功能,比如修改了用户信息里的身高,这个用数据类怎么处理呀

    2022-03-21

  • 白乾涛 👍(2) 💬(1)

    关于Java中的不变性集合,为什么我自动导了 java.util.List 而不是 SingletonList? 而且我报的错是 ClassCastException: java.lang.String cannot be cast to java.lang.Void ? import java.util.List; class Test { public static void main(String[] args) { List<String> data = new Model().getData(); data.add("bqt"); // ClassCastException: java.lang.String cannot be cast to java.lang.Void } }

    2022-02-19

  • A Lonely Cat 👍(2) 💬(1)

    我对 val 不可变理解是:地址不变(在 kotlin 下无法二次赋值,类似Java中的 final 关键字修饰的属性),但是值可能会变(通过该对象的相关方法修改属性)

    2022-02-08

  • better 👍(2) 💬(1)

    加餐的内容,非常不错。

    2022-02-07

  • 墨方 👍(1) 💬(2)

    数据类用copy做更改,太麻烦了吧,难道没有更好的方式了吗?

    2022-02-07

  • 山河入梦 👍(0) 💬(2)

    想请教一下老师,对于数据类用val声明的字段如果后台返回了null怎么办,尤其是“陈年接口”

    2022-03-03

  • A Lonely Cat 👍(0) 💬(2)

    感觉朱老师把自己写懵了。一会儿消除不变性,一会儿消除可变性。 例: 1、消灭数据类中的不变性。 2、数据类来存储数据,消灭数据的可变性。 类似的问题还有好多处🤔🤔 大家可以看看老外的这篇文章:https://commonsware.com/AndroidArch/pages/chap-immutability-001 Immutability is one way of imposing a contract upon yourself, as a developer, to avoid side effects. Calling a setter is a very casual act in programming, even if calling that setter introduces a side effect. Immutability enforces the creation of new objects, ideal for use in pure functions, where the function can create objects to return but cannot change the parameters’ contents and cause side effects. 译: 不变性是作为开发人员将合同强加于自己以避免副作用的一种方式。调用 setter 在编程中是一种非常随意的行为,即使调用该 setter 会带来副作用。不变性强制创建新对象,非常适合在纯函数中使用,其中函数可以创建要返回的对象,但不能更改参数的内容并导致副作用。

    2022-02-08

  • neo 👍(0) 💬(0)

    使用copy()方法, 每次都会创建一个新的对象。这样的话如果体量小倒还可以,大面积的运用的话难免会造成性能的损耗

    2023-06-05

  • 郑峰 👍(0) 💬(2)

    为什么委托里面的by每次访问每次执行,by lazy就只执行一次呢?

    2022-07-28