Kotlin 集合:只读不等于不可变

0 阅读5分钟

222.png

我最近一直在用 Kotlin 的 explicit backing field 特性去写代码。

我发现,我的主要目的是缩小外部能力 —— 暴露给外部的接口一定比内部少,这样可以降低代码风险(也就是 Robust)。

例如我暴露给外部的是一个不可变集合,但实际上内部是一个可变集合。

这在 Java 中很难做到。Java 的 List 接口本身就带有 addremove 方法,如果你想写一个不可变集合,只能在这些方法里抛出异常,非常不友好。

于是,我仔细研究了一下 Kotlin 中的集合……

Kotlin 中有哪些集合类型

Kotlin 的集合类型分两种:只读集合和可变集合。

只读集合创建后不能改,可变集合可以增删改。搞清楚这两类,才能在不同场景下选对集合。

只读集合

只读集合只能访问和查询,创建后内容不能改。Kotlin 用这种方式保证数据不被意外修改。

主要的只读集合类型:

List:有序集合,用下标访问元素。

val readOnlyList = listOf("关注", "rockbyte", "公众号")
println(readOnlyList[0]) // 输出: 关注

Set:元素不重复的集合。

val readOnlySet = setOf("kotlin", "java", "kotlin")
println(readOnlySet) // 输出: [kotlin, java]

Map:键值对集合,键不能重复。

val readOnlyMap = mapOf("language" to "kotlin", "platform" to "android")
println(readOnlyMap["language"]) // 输出: kotlin

对了,这里建议各位看下这篇文章 —— Kotlin 准备引入 [1,2,3] 集合字面量,后续我们声明集合,不用这么麻烦了(我指的是写 xxxOf)。

可变集合

可变集合能增删改元素,是在只读集合接口基础上扩展出来的。

可变集合类型:

MutableList:可变的 List,能添加、删除、更新元素。

val mutableList = mutableListOf("rockbyte", "kotlin")
mutableList.add("developer")
println(mutableList) // 输出: [rockbyte, kotlin, developer]

MutableSet:可变的 Set,能动态增删元素。

val mutableSet = mutableSetOf("kotlin", "java")
mutableSet.add("android")
println(mutableSet) // 输出: [kotlin, java, android]

MutableMap:可变的 Map,能修改键值对。

val mutableMap = mutableMapOf("language" to "kotlin")
mutableMap["platform"] = "android"
println(mutableMap) // 输出: {language=kotlin, platform=android}

理解只读和可变集合的可变性

只读集合(listOfsetOfmapOf)创建后不能用它的方法修改,可变集合(mutableListOfmutableSetOfmutableMapOf)可以。

一般来说,数据不需要变就用只读集合,需要动态变化就用可变集合。

但要注意,只读集合不等于不可变。引用本身是只读的,但它和可变集合指向同一个底层对象,内容还是能改。看这个例子:

val mutableList: MutableList<String> = mutableListOf("A", "B", "C")
val readOnlyList: List<String> = mutableList

// 通过只读引用修改会编译报错:
// readOnlyList.add("D") // 不允许

// 但改原始的可变引用,只读引用也会受影响:
mutableList.add("D")
println(readOnlyList) // 输出: [A, B, C, D]

// 甚至可以强转成 MutableList 来改:
(readOnlyList as MutableList<String>).add("E")
println(readOnlyList) // 输出: [A, B, C, D, E]

这里 readOnlyList 虽然是只读的,但 mutableList 一改,它也跟着变了。

要真正不可变,用 kotlinx.collections.immutable 库,或者把内容复制到新集合里。

小结

Kotlin 支持只读和可变两类集合,包括 ListSetMap

只读集合保护数据不被意外修改,可变集合方便动态更新。选哪个看具体需求:要安全用只读,要灵活用可变。

进阶:listOf() 和 emptyList()

可能很多小伙伴在声明集合的时候,碰到过 listOf()emptyList(),但是这两有什么区别?

listOf()emptyList() 都能创建只读列表,但用法有点不同。

有意思的是,listOf() 不传参数时,底层其实调的就是 emptyList()

listOf()

listOf() 是创建只读列表的通用方法,可以传任意多个参数,返回包含这些元素的列表。不传参数就返回空列表,内部调的是 emptyList()

val nonEmptyList = listOf("关注", "rockbyte", "公众号")
println(nonEmptyList) // 输出: [关注, rockbyte, 公众号]

val emptyUsingListOf = listOf<String>()
println(emptyUsingListOf) // 输出: []

底层实现上,listOf() 先检查元素数量,大于零就直接返回,否则委托给 emptyList()

public fun <T> listOf(vararg elements: T): List<T> = 
    if (elements.size > 0) elements.asList() else emptyList()

所以 listOf() 不传参数,效果和 emptyList() 一样。

emptyList()

emptyList() 专门用来创建空的只读列表,不接受参数,返回的是单例空列表,针对空列表场景做了优化。

val emptyList = emptyList<String>()
println(emptyList) // 输出: []

明确要创建空列表时,用 emptyList() 语义更清晰,代码更好懂。

底层实现上,emptyList() 是个优化过的工厂方法,返回的是一个预先创建好的单例对象 EmptyList,不会每次调用都新建列表:

public fun <T> emptyList(): List<T> = EmptyList

看看 EmptyList 的实现:

internal object EmptyList : List<Nothing>, Serializable, RandomAccess {
    // ... 实现细节 ...

    override val size: Int get() = 0
    override fun isEmpty(): Boolean = true
    override fun contains(element: Nothing): Boolean = false

    override fun get(index: Int): Nothing = throw IndexOutOfBoundsException("Empty list doesn't contain element at index $index.")

    // ... 其他方法 ...
}

EmptyList 是内部单例对象,整个应用里所有 emptyList() 调用返回的都是同一个实例,非常省内存。

它实现了 List<Nothing> 接口。Nothing 是 Kotlin 的特殊类型,没有任何值,正好适合表示空列表。

因为 List<Nothing> 是任何 List<T> 的子类型,所以 EmptyList 可以安全地转换成任意列表类型(比如 List<String>List<User>)。

EmptyList 的实现就是硬编码成空列表:size 返回 0isEmpty() 返回 trueget(index) 直接抛 IndexOutOfBoundsException

一句话总结:

listOf() 是通用方法,能创建包含元素的列表,不传参数时委托给 emptyList()emptyList() 是专门创建空列表的方法,返回的是优化过的单例实例。要创建空列表,用 emptyList() 语义更清晰,虽然效果和 listOf() 不传参数一样。

一点想法

因为 Kotlin 的只读集合在类型层面就把写操作去掉了。对外暴露 List,对内持有 MutableList,不需要额外包装,也不会像 Java 那样在运行时才抛异常。编译器帮你守住了这道门,调用方想 add 都加不进去。

再配合 explicit backing field,能少写不少样板代码。

这语法糖,喂到嘴里了属于是。