不可变性的哲学: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对象都是独立的,不会被意外修改。多线程环境下,你可以放心地共享这些对象。
嗯,关于不可变性的哲学,今天就聊到这儿。记住一句话:不可变不是限制,而是自由。它让你从「这个变量什么时候被改了」的焦虑中解脱出来。