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: List<Double>): Double { return prices.sum() }第二层,类成员如果对外暴露,用只读类型;内部需要修改的话,用一个私有的可变字段,对外只暴露只读视图。
class Repository { private val _items = mutableListOf<String>() val items: List<String> 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(name=Alice, age=25) println(older) // User(name=Alice, age=26)user本身没变,copy返回了一个新对象。这种“改一个字段就生成新对象”的模式,在函数式编程里叫持久化数据结构的思路,好处是线程安全、易于追踪状态变化、方便做撤销重做。
但要注意,copy是浅拷贝。如果User里有个MutableList字段,copy出来的新对象和原对象共享同一个列表引用:
data class Team(val name: String, val members: MutableList<String>) 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.0",B 模块引用了它,编译后 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 | 编译期内联,无运行期开销 |
| 需要调用函数计算的值 | val | const val不允许 |
| 对象类型的常量 | val | const 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.isInitialized:
if (::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,或先赋给局部val |
Const val initializer should be a constant value | const val用了函数调用 | 改用普通val |
6.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 精细,用好了能写出更安全、更清晰的代码,用不好反而会觉得处处受限。关键还是理解每个设计背后的意图,而不是死记语法规则。