查看原文
其他

Koltin中的变与不变

沈剑心 郭霖
2024-07-19



/   今日科技快讯   /


近日,杭州市印发《杭州市推动消费品以旧换新行动方案》,开展汽车、电动自行车、家电、家装厨卫以旧换新行动。力争2024年,新能源汽车销售达到全省目标水平;家电销售额增长6%,淘汰更新电动自行车5.1万辆,新增再生资源集中分拣中心、回收点5个。到2027年,新能源汽车年销售量达30万辆,渗透率达到50%以上;报废汽车回收量3万辆以上,二手车交易量30万辆以上;家电年销售额较2023年增长20%,累计建成45个再生资源集中分拣处理中心。

/   作者简介   /


本篇文章转自沈剑心的博客,文章主要分享了Kotlin 开发中mutable和immutable的相关知识,相信会对大家有所帮助!


原文链接:

https://juejin.cn/post/7369483819864948770


/   前言   /


钻石恒久远,一颗永流传。


相信大家都听过这段话,因为其「永恒与不变」的象征意义,钻石作为珠宝价格被抬上了天。人们总是追求那些经得起时间考验、不受时代变迁影响的事物,道一句「初心不负」,彰显永恒不变的可贵,包括爱情、忠诚、新年和文化。在编程领域同样如此。


/   「mutable 可变」带来的副作用   /


什么是「副作用」


这个概念往往和函数式编程一起被人们提起。在非函数式编程(即命令式编程)中,副作用是一种常见现象,指的是除了返回函数值之外,函数还对外部环境或状态产生了影响,包括但不仅限于修改全局变量、修改输入参数、进行 I/O 操作等。


副作用往往使得程序的状态变得难以追踪,增加了理解和调试程序的复杂性,就像一个你不知道为何生气的女朋友,想想就头疼。



如果你还不能理解副作用是什么,看看下面的例子:


var counter = 0 // 全局变量

fun incrementCounter()Int {
    counter += 1 // 修改了外部状态
    return counter
}

fun main() {
    println(incrementCounter()) // 输出: 1
    println(incrementCounter()) // 输出: 2
    // 这里可以看到,尽管我们调用的是相同的函数,但由于函数内部修改了外部的变量,导致每次调用的结果不同,这就是副作用。
}


在上面的例子中,incrementCounter()函数修改了全局变量counter的值,然后将其返回。这种对外部状态的修改就是一个典型的副作用。由于counter是全局变量,它的状态可以被程序中的任何部分访问和修改,这使得程序的状态变得难以预测和控制。如果程序变得复杂,这种全局状态的修改可能导致难以发现的bug和难以理解的代码行为。


「副作用」的影响


「可变」引起副作用带来的影响最明显的场景就是多线程环境下修改数据造成的预期外的执行结果,下面是一段模拟实际业务,展示了一个必现java.lang.IndexOutOfBoundsException异常场景的代码:


// 全局变量list,包含 [1、2、3] 3个元素
val list = ArrayList<Int>()
list.add(1)
list.add(2)
list.add(3)

suspend fun main() {
    // delay 200ms,将最后一个元素移除
    launch(Dispatchers.IO) {
        delay(200)
        list.removeLast()
    }

    launch(Dispatchers.Main) {
        // get list的大小
        val size = list.size
        delay(300)
        // 延迟300s后通过size获取最后一个item
        // 由于list已经执行过removeLast, 所以此处数组会越界
        println(list[size - 1])
    }
}


千万不要以为实际开发中这样的情况不会发生,真实的需求开发过程中,开发者需要在后台请求数据、处理结果,然后在主线程中处理UI的渲染,这些操作本身就充满了不确定性,更要命的是,这种因为多线程修改数据可能导致的问题往往藏的很深,开发测试阶段很难发现,一旦上线带来的就是难以忘怀的crash。 


对于Android开发者,尤其是使用ComposeUI体系的开发者来说,可变带来的另一个影响就是会影响Compose的性能,导致Compose频繁触发重组,导致页面卡顿。


Compose 的一个关键特性是它的智能重组(Intelligent Recomposition)机制,这使得只有在需要时才会重组(更新)UI 的特定部分,而不是整个界面。这种机制显著提高了性能,并减少了不必要的工作,但是某些场景下,即使参数没有发生变化重组却仍然发生了,比如下面的例子:


@Composable
fun GoodItem(goodName: String) {
    Text(text = goodName)
}

@Composable
fun GoodList(goodList: List<String>) {
    for (good in goodList) {
        GoodItem(goodName = good)
    }
}

@Composable
fun GoodCart() {
    // 模拟购物车中的一个可变状态值
    var mutableState by remember {
        mutableStateOf(0)
    }
    // 模拟购物车中的交互,点击按钮会改变可变状态值
    Button(onClick = { mutableState++ }) {

    }

    GoodList(...)
}


上面模拟了一个购物车场景:购物车GoodCart除了商品列表GoodList组件外,还包含一个可变状态mutableState,购物车中的某些交互会更改该状态值(例子中模拟了按钮点击mutableState++)。


每次mutableState更改,都会触发GoodCart的重组,当执行到GoodList时,会校验参数是否发生变化,如果没有变化,GoodList就会跳过重组,否则就会重组。理想情况下,mutableState变化引发重组时,GoodList不应该发生重组,因为GoodList的参数并没有变化。但是遗憾的是,此处场景中的GoodList会发生重组。


why?


List 作为interface会被视为一个非稳定类型,因为编译器不知道其实现是不是可变的。


所以上面的例子中,mutableState状态的变更,就会引起GoodList的重组。这显然不是我们想要的。


/   函数式编程范式   /


既然可变带来的副作用后劲那么大?那有什么办法解决么?那就不得不提到函数式编程范式了。函数式编程范式引申自数学上的概念:


函数是不同数值之间的特殊关系:每一个输入值返回且只返回一个输出值。



在函数式编程一大特点就是「不可变性」,即在函数式编程中,状态不是通过修改变量,而是通过创建和返回新的状态来更新的。这意味着数据是不可变的(immutable),一旦创建,就不能更改。通过这种不可变性尽可能的避免副作用。


需要注意的是,在编程的范畴中,副作用是无法完全避免的,因为程序总是不可避免的与外界进行交互,如:读写文件、UI交互、读写数据库等...


Kotlin中的「mutable」和「immutable 」


熟悉Java的开发者应该知道,使用Java写实体类的时候,除了声明字段外,还需要写一堆一堆的getter和setter方法,这也意味着这样的实体类中声明的字段都是「mutable」可变的,使用者可以在程序的任意环节修改数据。


在Kotlin中,我们就可以使用data class来声明实体类,使用val修饰字段以来限制使用者修改数据。这有助于编写更安全、更易于维护的代码。


Kotlin 中的 val关键字与 Java 中的 final关键字都用于实现不可变性,但Kotlin中val在语法层面明显更加简洁、灵活,同时又强制让开发者在声明变量时确定其可变/不可变,反观Java,final属于可选项提供,导致很多原本「immutable」的对象失去了「immutable」的特性。


需要注意的是,Kotlin中,val修饰的对象也并非完全不可变,List就是一个例外。


val list = arrayOf(123).toList()
fun main() {
    // 直接add元素IDE会报错
    // list.add(4)
    // 强转成 MutableList后再添加元素4
    (list as MutableList).add(4)
    println(list.size)
}


归根结底的原因就是因为Kotlin对于List的实现问题。Kotlin标准库中有两类关于集合的接口:


  • Collection、List、Set、Map等只读集合接口

  • MutableCollection、MutableList、MutableSet、MutableMap等可变接口,允许修改集合中的元素


Kotlin 大多数情况下不提供自己的集合实现:在 Kotlin/JVM中,集合接口映射到标准 JDK 集合接口并由 JDK 集合实现实现,例如 ArrayList 、 HashSet 、 HashMap 以及 java.util 包中的其他类。所以对于Kotlin的标准库,我们无法获得一个真正意义上的不可变List的实现。 


Kotlin官方显然也意识到了这个问题,所以开发了 kotlinx.collections.immutable ,为Kotlin提供了不可变集合的实现。利用该拓展库,我们就能轻松获得一个不可变的集合:


// 将只读或可变集合转换为不可变集合
fun Iterable<T>.toImmutableList(): ImmutableList<T>
fun Iterable<T>.toImmutableSet(): ImmutableSet<T>
// 将只读或可变集合转换为持久集合
fun Iterable<T>.toPersistentList(): PersistentList<T>
fun Iterable<T>.toPersistentSet(): PersistentSet<T>


由于其「不可变」性,在多个线程之间共享该集合时,任何生产者和任何消费者都无法更改该集合,所以也不会有 ConcurrentModificationException 或任何其他预期外的状态。


Jetpack Compose 内部确实引入了kotlinx.collections.immutable库,kotlinx.collections.immutable 的使用主要是为了维护组件状态的不变性和线程安全。Compose 编译器 1.2版本后,会将Kotlin的immutable集合视为稳定类型,这对于Compose的智能重组很有帮助。但是Compose并未对外开放这个库,如果想使用它,我们需要自己引入它的依赖。


/   总结   /


如今的Android开发环境下,多线程、声明式UI都对数据的正确性提出了较高的要求,保证数据的稳定、不可变就显得尤为重要了,不可变意味着更简单的并发逻辑和更好的可维护性,所以,早日熟悉并使用immutable,早日脱离苦海。


推荐阅读:

我的新书,《第一行代码 第3版》已出版!

Android 14新特性,选择性照片和视频访问授权

Android无侵入式主题切换揭示动画,仿Telegram/酷安


欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存