不可变性的哲学:val与var的区别

聊到Kotlin的数据类,绕不开一个核心概念——不可变性。说白了,就是你的变量到底能不能变。

我刚开始从Java转Kotlin时,觉得val和var不就是final和非final的区别吗?后来踩了坑才明白,事情没那么简单。

val ≠ 常量

很多人以为val就是常量。嗯,这个理解有点偏差。

val是只读引用,不是编译期常量。它可以在运行时赋值,但一旦赋值就不能再指向其他对象。

val name: String
name = "Kotlin"  // 第一次赋值,可以
// name = "Java"  // 编译错误!不能重新赋值

而var是可变引用,想改就改:

var count = 10
count = 20  // 没问题
count = 30  // 随便改

你想想看,这跟Java的final有啥区别?Java的final也是不能重新赋值,但Kotlin的val多了一个特性——它可以延迟初始化。这在依赖注入、懒加载场景下特别有用。

核心区别一句话:val保证引用不变,但不保证对象内部状态不变。

var的隐患:可变引用的代价

我在项目中遇到过一个问题:一个用var声明的列表,在多个协程里被同时读写,结果数据全乱套了。排查了半天,最后发现是var配合可变集合搞出来的鬼。

var本身没问题,问题在于它给了你一种「可以随便改」的心理暗示。一旦代码复杂起来,你很难追踪这个引用在什么时候被谁改过。

var list = mutableListOf("A", "B")
// 某处代码
list = mutableListOf("C", "D")  // 引用变了
// 另一处代码
list.add("E")  // 内容也变了

这种双重可变性,是bug的温床。我个人习惯是:能用val就用val,实在需要变,再考虑var。

不可变集合:listOf、mapOf、setOf

Kotlin把集合分成了可变和不可变两套API。这个设计我觉得特别聪明——从源头杜绝了数据被意外修改的可能。

不可变集合的创建

val immutableList = listOf("A", "B", "C")
val immutableMap = mapOf(1 to "One", 2 to "Two")
val immutableSet = setOf(1, 2, 3)

这些集合不能添加、删除、修改元素。你可能会问:那我要改数据怎么办?

答案是——创建新集合。Kotlin提供了丰富的操作符:

val original = listOf(1, 2, 3)
val newList = original + 4  // 创建新列表 [1, 2, 3, 4]
val filtered = original.filter { it > 1 }  // [2, 3]

这种模式叫「持久化数据结构」。每次修改都返回新对象,原对象纹丝不动。多线程环境下,这简直就是救星。

我的建议:函数参数和返回值,优先用不可变集合。只在局部作用域用可变集合做临时计算。

可变集合的适用场景

当然,不可变不是万能的。性能敏感的场景下,频繁创建新对象会有开销。这时候可以用mutableListOf、mutableMapOf:

fun buildLargeList(): List<Int> {
    val temp = mutableListOf<Int>()
    for (i in 1..10000) {
        temp.add(i)
    }
    return temp.toList()  // 返回时转成不可变
}

这种做法很常见:内部用可变集合高效构建,对外暴露不可变集合保证安全。

不可变引用的陷阱

这部分我要重点讲。很多人以为用了val和不可变集合就万事大吉了,其实不然。

陷阱一:val + 可变对象

还记得前面说的吗?val只保证引用不变,不保证对象内部不变。

val list = mutableListOf("A", "B")
list.add("C")  // 编译通过!内容变了
// list = mutableListOf("D")  // 编译错误!引用不能变

这个例子中,list是val,但因为它指向的是可变集合,内容照样能被修改。我曾经在代码review时看到有人把这种写法当成不可变,结果线上出了数据不一致的问题。

避坑指南:val + mutableListOf ≠ 不可变。想要真正的不可变,必须用listOf。

陷阱二:数据类中的可变字段

数据类默认提供了copy方法,看起来很美。但如果字段是可变类型,copy出来的对象可能共享内部状态。

data class User(val name: String, val tags: MutableList<String>)

val user1 = User("Alice", mutableListOf("dev", "kotlin"))
val user2 = user1.copy(name = "Bob")

user2.tags.add("java")  // 改了user2的tags
println(user1.tags)  // 输出 [dev, kotlin, java]  user1也被改了!

为什么会这样?因为copy是浅拷贝,tags字段指向的是同一个MutableList对象。

我遇到过一个线上bug,就是因为这种共享引用导致用户标签数据混乱。排查过程相当痛苦。

陷阱三:集合的「不可变」是运行时检查

Kotlin的不可变集合,其实是在运行时抛异常来阻止修改。什么意思?

val list: List<Int> = mutableListOf(1, 2, 3)
// 编译时类型是List,但实际是MutableList
// 如果通过其他引用修改...
val mutable = list as? MutableList
mutable?.add(4)  // 可能成功,也可能抛异常

这种向下转型是不安全的。Kotlin官方不保证所有List的实现都支持运行时修改检查。说白了,别这么干。

安全做法:用list.toList()显式创建不可变副本,彻底切断与可变源的关联。

如何构建真正的不可变

综合以上陷阱,我总结了几条实践原则:

  • 字段类型用不可变集合(listOf、mapOf、setOf)
  • 数据类所有字段用val
  • 嵌套对象也要保证不可变
  • 对外API返回不可变集合
  • 需要修改时,用copy + 操作符创建新对象
data class User(
    val name: String,
    val tags: List<String>  // 不可变集合
)

fun addTag(user: User, tag: String): User {
    return user.copy(tags = user.tags + tag)  // 创建新User
}

这种写法,每个User对象都是独立的,不会被意外修改。多线程环境下,你可以放心地共享这些对象。

嗯,关于不可变性的哲学,今天就聊到这儿。记住一句话:不可变不是限制,而是自由。它让你从「这个变量什么时候被改了」的焦虑中解脱出来。