首页 / 资讯中心 / 文章详情

Kotlin变量声明深度解析:var、val、const val与不可变性

Kotlin变量声明深度解析:var、val、const val与不可变性 ★ FEATURED ARTICLE
1. 从 var 和 val 说起Kotlin 变量声明的底层逻辑1.1 为什么 Kotlin 要把变量声明拆成两个关键字刚从 Java 转到 Kotlin 的人第一个卡壳的地方往往不是协程也不是扩展函数而是var和val这两个关键字。Java 里一个int a 1;就完事了Kotlin 偏偏要你选到底是var a 1还是val a 1。这个设计不是故意为难人它背后是 Kotlin 对“可变状态”这件事的基本态度。var代表variable声明一个可重新赋值的变量val代表value声明一个初始化后不能再指向其他对象的引用。注意我这里的措辞是“不能再指向其他对象”而不是“对象本身不可变”。这是理解 Kotlin 变量体系最关键的一句话后面我会用专门的篇幅把这个问题讲透。Kotlin 官方在设计语言时有一个很明确的倾向优先鼓励不可变性。因为可变状态是并发问题、逻辑 bug、难以追踪的副作用的主要来源。把val放在更“顺手”的位置让开发者在写代码时被迫思考“这个引用到底需不需要变”长期下来代码的健壮性会明显提升。你在实际项目里会发现一个写得好 Kotlin 文件里val的数量通常是var的三到五倍这不是巧合而是刻意为之的结果。从编译层面看val和var最终都会编译成 JVM 字节码里的字段或局部变量val只是多了编译期的赋值检查。也就是说不可变性是编译期约束不是运行期保证。这一点非常重要它解释了为什么反射可以绕过val的限制也解释了为什么val声明的集合仍然可以被修改内容。1.2 var 与 val 的语法细节和常见写法先看最基本的声明形式。Kotlin 的类型推断做得相当激进大多数情况下你不需要显式写类型val name Kotlin // 推断为 String var count 10 // 推断为 Int val price 9.99 // 推断为 Double var flag true // 推断为 Boolean如果你想要显式类型也可以写出来冒号后面跟类型val name: String Kotlin var count: Int 10 val ratio: Double 0.75这里有个新手常踩的坑显式类型和初始值类型必须兼容。比如val count: Int 10L会直接编译报错因为10L是Long不能隐式转成Int。Kotlin 不像 Java 那样有大量的隐式数值转换这一点在跨类型运算时要特别注意。val的赋值规则需要单独强调。它允许延迟初始化但只能赋值一次val result: Int if (someCondition) { result 1 } else { result 2 } // 到这里 result 已经被赋值之后不能再改这种写法在需要根据条件计算初始值的场景里非常实用比 Java 里先声明再在分支里赋值要清晰得多。但如果你试图在result已经赋值后再赋一次编译器会直接报Val cannot be reassigned。var就没有这个限制随时可以改var counter 0 counter 1 counter 10 counter // 自增等价于 counter counter 11.3 顶层、类成员、局部变量的差异同样是val放在不同位置行为并不完全一样。这一点很多人写了两三年 Kotlin 都没仔细想过。局部变量里的val编译器检查最严格赋值一次后彻底锁死。类成员里的val必须在声明处或者init块或者构造函数里完成初始化否则编译不过。顶层的val会在文件对应的类比如FileKt里生成一个静态字段加 getter。// 顶层 val VERSION 1.0 class Config { val host localhost // 声明处初始化 val port: Int init { port 8080 // init 块初始化 } } fun main() { val local 42 // 局部 println(local) }这里有个细节值得说类成员val如果只在init块里赋值编译器会做数据流分析确认所有构造路径都赋值了才放行。如果某个分支漏了会报Property must be initialized。这个检查比 Java 的final字段要智能一些Java 里 final 字段的初始化检查相对宽松容易漏。提示顶层val在 Android 或服务端项目里要慎用。它会在类加载时初始化如果初始化逻辑有耗时操作或者依赖其他尚未就绪的组件可能引发难以排查的初始化顺序问题。我一般只在放常量的时候用顶层val。2. val 不等于不可变一个必须掰扯清楚的概念2.1 val 锁的是引用不是对象内容这是 Kotlin 学习路上最大的认知陷阱没有之一。很多人以为val声明的对象就完全不能改了结果写出这样的代码然后一脸懵val list mutableListOf(1, 2, 3) list.add(4) // 完全合法 list[0] 100 // 也合法 println(list) // [100, 2, 3, 4]为什么因为val约束的是list这个引用不能指向别的对象但list指向的那个MutableList对象本身是可变的你当然可以改它的内容。下面这行才会报错list mutableListOf(5, 6) // 编译错误Val cannot be reassigned用生活化的类比val就像给你的房子上了把锁你不能换房子但房子里的家具随便你怎么搬。想要连家具都不能动你得用不可变对象比如listOf(1, 2, 3)返回的List它根本没有add方法。val immutable listOf(1, 2, 3) immutable.add(4) // 编译错误List 没有 add 方法所以正确的理解是val提供的是引用不可变真正的不可变要靠类型本身。Kotlin 标准库里List、Set、Map这些接口都是只读的对应的MutableList、MutableSet、MutableMap才是可变的。写代码时优先用只读接口需要改的时候再转成可变版本这是 Kotlin 社区比较推崇的做法。2.2 只读集合和可变集合的选型建议实际项目里怎么选我的经验是分三层来看。第一层函数参数尽量用只读类型。这样调用方一看签名就知道你不会改它传进来的集合心理负担小也避免了意外的副作用。fun calculateTotal(prices: ListDouble): Double { return prices.sum() }第二层类成员如果对外暴露用只读类型内部需要修改的话用一个私有的可变字段对外只暴露只读视图。class Repository { private val _items mutableListOfString() val items: ListString get() _items fun add(item: String) { _items.add(item) } }这种写法在 Android 的 ViewModel 里特别常见配合StateFlow或者LiveData使用能有效防止外部代码绕过封装直接改内部状态。第三层局部变量看情况。如果只是临时收集数据用mutableListOf完全没问题没必要为了“纯粹”而绕来绕去。我见过有人为了坚持不可变写出一堆list element的代码每次都在创建新列表性能反而更差。不可变是好习惯但不是教条。2.3 数据类的 val 属性和 copy 机制data class是 Kotlin 里体现不可变思想最典型的场景。看这个例子data class User(val name: String, val age: Int) val user User(Alice, 25) val older user.copy(age 26) println(user) // User(nameAlice, age25) println(older) // User(nameAlice, age26)user本身没变copy返回了一个新对象。这种“改一个字段就生成新对象”的模式在函数式编程里叫持久化数据结构的思路好处是线程安全、易于追踪状态变化、方便做撤销重做。但要注意copy是浅拷贝。如果User里有个MutableList字段copy出来的新对象和原对象共享同一个列表引用data class Team(val name: String, val members: MutableListString) val t1 Team(A, mutableListOf(x)) val t2 t1.copy(name B) t2.members.add(y) println(t1.members) // [x, y] —— t1 也被改了要避免这个问题data class里的集合字段也应该用只读类型需要修改时通过copy传入新列表。这是我在实际项目里反复强调的一条规范能省掉很多诡异的 bug。3. 常量const val 和 val 的区别到底在哪3.1 const val 的编译期常量特性Kotlin 里的“常量”有两个层次很多人混着用其实差别很大。const val是编译期常量它要求满足几个条件必须是顶层声明或者放在object或者companion object里类型只能是String或基本类型必须在声明处直接赋值不能靠函数计算。const val MAX_RETRY 3 const val BASE_URL https://example.com object Config { const val TIMEOUT 5000 } class NetworkClient { companion object { const val DEFAULT_PORT 8080 } }const val最大的特点是值在编译期就确定并内联。什么意思如果你在 A 模块定义const val VERSION 1.0B 模块引用了它编译后 B 模块的字节码里直接就是字符串1.0而不是去读 A 模块的字段。这带来一个坑改了 A 模块的常量值B 模块如果不重新编译用的还是旧值。在大型多模块项目里这个坑踩一次能查半天。而普通的val是运行期常量它在类加载或者对象初始化时才赋值可以调用函数val currentTime System.currentTimeMillis() // 合法 val randomId UUID.randomUUID().toString() // 合法 // const val currentTime System.currentTimeMillis() // 编译错误3.2 什么时候用 const val什么时候用 val判断标准很简单值在编译期就能确定且是基本类型或字符串用const val否则用val。场景推荐写法原因固定配置项如超时时间、端口号const val编译期内联无运行期开销需要调用函数计算的值valconst val不允许对象类型的常量valconst val只支持基本类型和 String需要被 Java 代码以静态字段访问const val会生成public static final依赖其他模块的常量谨慎用const val内联可能导致版本不一致关于最后一条我再展开说一下。假设你有个基础库模块定义了const val API_VERSION 1业务模块引用它。某天基础库把值改成 2业务模块如果没重新编译运行时用的还是 1。这种问题在增量编译、热修复场景下特别容易出。我的建议是跨模块共享的常量如果可能频繁变动用val而不是const val牺牲一点性能换取一致性。3.3 常量的命名规范和存放位置Kotlin 的官方编码规范里const val和顶层的val都用全大写下划线分隔的命名const val MAX_CONNECTIONS 100 const val DEFAULT_TIMEOUT_MS 3000 val BUILD_TIMESTAMP System.currentTimeMillis()存放位置我一般按这个原则只在一个文件里用的常量放在文件顶部多个文件共享的放在专门的Constants.kt或者对应的object里。不要把所有常量都堆到一个巨大的Constants类里那样会变成垃圾场谁也不知道哪个常量还在用。按业务域拆分比如NetworkConstants、CacheConstants维护起来清爽得多。注意companion object里的const val在 Java 侧访问是ClassName.Companion.getXXX()还是ClassName.XXX答案是后者const val会生成真正的静态字段Java 可以直接ClassName.DEFAULT_PORT访问。而普通val在companion object里Java 侧得通过ClassName.Companion.getXXX()比较啰嗦。这也是为什么给 Java 用的常量优先选const val。4. 注释不只是给人看的还影响工具链4.1 三种注释形式及适用场景Kotlin 的注释语法和 Java 基本一致分三种// 单行注释 /* * 多行注释 * 可以跨行 */ /** * KDoc 文档注释 * param name 用户名 * return 问候语 */ fun greet(name: String): String Hello, $name单行注释最常用用来解释某一行或某一段代码的意图。多行注释现在用得越来越少因为大部分场景单行注释连续写几行就够了。KDoc 是重点它不只是注释还是文档生成工具的输入格式对了才能被 Dokka 正确解析。KDoc 的块标签有一套约定常用的有这些param描述参数return描述返回值throws描述可能抛出的异常see关联其他内容sample引用示例代码author、since元信息/** * 计算两个整数的和。 * * param a 第一个加数 * param b 第二个加数 * return 两数之和 * sample com.example.samples.addSample */ fun add(a: Int, b: Int): Int a b4.2 KDoc 和 JavaDoc 的关键差异从 Java 转过来的人容易在 KDoc 上踩坑因为两者看着像实际有区别。第一KDoc 用 Markdown 语法JavaDoc 用的是 HTML。你在 KDoc 里写p标签不会报错但也不推荐直接空行分段就行。链接用[文本](url)而不是a href。第二KDoc 的param和return位置更灵活但为了工具解析还是建议放在描述之后、标签集中排列。第三KDoc 支持sample引用真实可编译的示例代码这是 JavaDoc 没有的。你可以在测试目录里写一个示例函数然后用sample引用它Dokka 生成文档时会自动把代码贴进去。这个功能对库开发者特别有用能保证文档里的示例永远是可运行的不会过时。// 在 test 目录 fun addSample() { val result add(1, 2) println(result) // 3 } // 在主代码里引用 /** * sample com.example.addSample */ fun add(a: Int, b: Int): Int a b4.3 注释的实战规范和避坑写了这么多年代码我对注释的态度是注释解释“为什么”代码本身解释“是什么”。如果一段代码需要大段注释才能看懂它在做什么那多半是代码本身写得不够清晰应该先重构而不是加注释。几条我坚持的规范不要写废话注释。// 给 count 加 1这种注释纯属噪音代码count已经说得很清楚了。公共 API 必须写 KDoc。这是给调用方看的契约参数含义、返回值、异常、边界条件都要写清楚。复杂逻辑写“为什么”。比如某个看似多余的判断注释说明它是为了兼容某个历史行为避免后人“优化”掉。TODO 和 FIXME 要带上下文。// TODO: 优化等于没写应该写// TODO: 这里 O(n^2)数据量超过 1 万会卡需要改成哈希表。提示Kotlin 的注释可以嵌套/* /* */ */是合法的这点和 Java 不同。但实际几乎用不到知道有这么回事就行。还有一个容易被忽略的点注释也会影响编译产物。KDoc 默认不会打进 class 文件但如果你配置了-Xemit-jvm-type-annotations或者用了 Dokka注释内容会进入文档产物。所以注释里不要写敏感信息、内部地址、临时方案之类的东西这些一旦发布出去就收不回来了。5. 变量作用域、空安全和初始化时机5.1 作用域规则和变量遮蔽Kotlin 的变量作用域和 Java 类似但有几个细节值得单独拎出来。局部变量的作用域从声明处开始到所在代码块结束。Kotlin 允许在嵌套块里声明同名变量形成变量遮蔽val x 1 run { val x 2 // 遮蔽外层的 x println(x) // 2 } println(x) // 1这种写法虽然合法但强烈不建议。遮蔽会让代码可读性急剧下降读代码的人得时刻记住当前在哪一层。我见过的真实 bug 里就有因为遮蔽导致改错了变量的案例。如果确实需要改个名字比如innerX、outerX。类成员和局部变量同名时局部变量优先。要访问成员得用this.class Foo { val name member fun print() { val name local println(name) // local println(this.name) // member } }5.2 空安全类型和变量的关系Kotlin 的空安全是它的一大卖点而变量声明是空安全的第一道关口。类型后面加?表示可空var nullable: String? null val nonNull: String abc nullable now not null // nonNull null // 编译错误可空变量在使用时必须处理空的情况要么用?.安全调用要么用!!强制解包不推荐要么用?:提供默认值val len nullable?.length ?: 0这里有个和var相关的坑智能转换对var不生效。看这段代码var value: String? abc if (value ! null) { println(value.length) // 编译错误 }为什么因为value是var编译器无法保证在if判断之后、使用之前没有其他线程或者代码把它改成null。如果换成val智能转换就能正常工作val value: String? abc if (value ! null) { println(value.length) // 合法 }这个差异在实际开发里影响很大。能用val就用val不只是风格问题还直接关系到空安全智能转换能不能生效。我见过有人抱怨 Kotlin 空安全不好用一查代码全是var智能转换全失效自然觉得处处受限。5.3 延迟初始化和 lateinit有些场景下变量确实没法在声明处初始化比如 Android 里的 View 绑定、依赖注入的字段。Kotlin 提供了lateinit来处理class MainActivity { lateinit var adapter: RecyclerView.Adapter* fun setup() { adapter MyAdapter() } }lateinit有几个限制只能用于var不能用于基本类型Int、Boolean等不能用于可空类型。访问未初始化的lateinit变量会抛UninitializedPropertyAccessException。判断是否初始化过用::variable.isInitializedif (::adapter.isInitialized) { adapter.notifyDataSetChanged() }对于基本类型或者需要可空的场景可以用by lazy或者委托属性。by lazy适合只读的延迟初始化val heavyObject: HeavyObject by lazy { HeavyObject() // 第一次访问时才创建 }by lazy默认是线程安全的LazyThreadSafetyMode.SYNCHRONIZED如果确定单线程访问可以指定NONE模式省掉同步开销。这个细节在性能敏感的场景下值得注意。6. 常见问题排查与实战避坑清单6.1 变量相关的典型报错速查报错信息原因解决方法Val cannot be reassigned对val二次赋值改用var或检查是否真的需要修改Property must be initialized类成员val未在所有路径初始化在声明处、init块或构造函数中赋值UninitializedPropertyAccessException访问未初始化的lateinit先赋值或用isInitialized判断Type mismatch显式类型和初始值不兼容检查数值类型Kotlin 不做隐式转换Smart cast impossible对var做智能转换改用val或先赋给局部valConst val initializer should be a constant valueconst val用了函数调用改用普通val6.2 我踩过的几个真实坑坑一const val跨模块内联导致的值不一致。前面提过这里再强调一次。多模块项目里基础库的const val改了值业务模块没重新编译运行时用的还是旧值。排查这种问题非常痛苦因为代码看着没问题就是行为不对。后来我们的规范改成跨模块共享的配置值一律用val只有真正永不改变的常量比如数学常数、协议魔数才用const val。坑二data class的copy浅拷贝。前面也讲过集合字段共享引用导致改一个影响另一个。解决办法是集合字段用只读类型修改时通过copy传新集合。这个规范我们写进了代码检查规则里用静态分析工具强制约束。坑三lateinit在配置变更后失效。Android 里 Activity 重建时lateinit字段会重新变成未初始化状态如果某个回调在初始化之前触发就会崩。我的做法是lateinit字段在使用前一律加isInitialized判断或者干脆用可空类型加安全调用虽然啰嗦但稳。坑四var导致的智能转换失效。这个坑最隐蔽因为代码逻辑看着完全正确就是编译不过。养成习惯局部变量默认写val只有确实需要重新赋值时才改成var。IDE 会提示你把可以改成val的var改掉别忽略这些提示。6.3 变量声明的最佳实践清单最后整理一份我日常遵守的清单可以直接抄作业默认用val需要重新赋值时才用var。IDE 的 “Convert to val” 提示尽量都处理掉。集合优先用只读接口List、Set、Map需要修改时在内部用可变实现对外暴露只读视图。跨模块常量用val只有真正不变的用const val。公共 API 写 KDoc参数、返回值、异常、边界条件都写清楚。避免变量遮蔽嵌套作用域里换个名字。lateinit使用前判断isInitialized或者用可空类型替代。data class的集合字段用只读类型避免copy浅拷贝的坑。注释解释“为什么”不解释“是什么”。这些规范看着琐碎但每一条背后都是真实踩过的坑。Kotlin 的变量体系设计得比 Java 精细用好了能写出更安全、更清晰的代码用不好反而会觉得处处受限。关键还是理解每个设计背后的意图而不是死记语法规则。
阅读完成 · 觉得有帮助?
咨询建站