Kotlin 语言核心
Kotlin 是现代 Android 的第一语言. 你会用, 但要把 “会用” 升级到 “讲得清原理”.本篇覆盖中级面试高频语法点的底层机制.
章节边界与示例说明: 本篇讲日常 Kotlin/JVM 基础语法, 类型系统和对象模型; inline 字节码, value class 装箱, context parameters 与 Java ABI 等编译器视角问题见进阶专题. 除标注 “可运行示例” 的小程序外, 本文既有 Kotlin 代码均为需要放入 Android/JVM 工程的上下文片段; 带 “伪代码” 标记的块只解释机制.
Kotlin 是什么: Kotlin 是静态类型语言, 以 JVM/Android 为重要运行环境, 也可通过 Kotlin Multiplatform 面向其他平台, 并非 “Android 专属”. 它以空安全与 Java 互操作降低边界风险, 以扩展函数, 高阶函数和简洁语法提升表达力; 协程由语言提供 suspend 等语法与编译支持, 调度, CoroutineScope 等能力属于协程库. 这些特性可组合使用, 但仍需根据运行平台和 API 边界判断实际行为.
学习目标: 读者应能解释 null 为何仍会进入 Kotlin, 何时选择作用域函数和类类型, 并能从编译器报错判断型变与可见性边界. 完成标准见文末练习.
一, 空安全 (Null Safety)
Kotlin 把 null 检查提前到编译期.
val a: String不可空;val b: String?可空.- 安全调用
b?.length: b 为 null 时整体返回 null. - Elvis
b?.length ?: 0: 为 null 时给默认值. - 非空断言
b!!: 为 null 时抛 NPE (慎用). - 平台类型
String!: 来自 Java 的类型, Kotlin 不知其可空性, 调用方负责.
易错: lateinit var 用于非空且延迟初始化 (只能用于 var, 非基本类型), 访问前未初始化抛 UninitializedPropertyAccessException, 可用 ::x.isInitialized 判断. by lazy 用于 val, 线程安全延迟初始化.
null 从哪里进入 Kotlin
非空类型是编译器在 Kotlin 边界内提供的约束, 不是运行时世界永远不产生 null 的保证. 常见入口如下:
- Java 注解缺失或平台类型: 无可靠
@Nullable/@NonNull注解的 Java 返回值在 Kotlin 看作平台类型; 把它当String使用, 实际返回 null 时仍可能 NPE. - 反射或序列化: 反射, JSON/Parcel 等框架可绕过正常构造/类型检查, 缺字段或不可信数据可能得到 null 或未初始化状态.
- Java 集合污染: Java 可把 null 写入 Kotlin 声明为
MutableList<String>的底层集合, Kotlin 遍历时才暴露问题. - 并发时序: 检查非空后, 另一线程可能修改共享可变引用; 用不可变快照, 锁或其他安全发布方式, 而不是依赖一次 null 检查.
边界层应将平台类型和外部数据先归一化为明确的可空/非空领域类型, 再交给核心逻辑. 症状是 “类型明明非空却 NPE”; 证据是 Java 调用链, 反序列化输入, 集合内容或跨线程写入记录; 定位输入首次跨越 Kotlin 边界的位置; 修复为注解, 显式校验/默认值, 不可变快照或同步; 验证用 null, 缺字段, 污染集合和并发交错的测试输入覆盖该边界.
Any, Any? 与 Java Object
Any 是 Kotlin 所有非空类型的根, Any? 则是包括 null 在内的可空类型根. Java Object 本身可为 null; 未标注空性时, Kotlin 将 Java Object 视为需在调用边界处理的 platform type, 不能把它直接等同于 Any 或 Any?. Kotlin 的基本类型在 JVM 上通常映射为原始类型, 在泛型, 可空类型或其他需要对象表示的位置会装箱; 与 Java 互操作时应依据签名的空性和装箱形式处理, 而不是只按源码名称判断.
Int 在可空 / 泛型位置会装箱为 Integer. JVM 的 Integer.valueOf 缓存 -128~127 (规范下限, 实现可扩大, 如 HotSpot 可用 -XX:AutoBoxCacheMax 调大), 区间内装箱复用同一实例, 区间外每次新建对象:
val a: Int? = 100
val b: Int? = 100
check(a === b) // true, 缓存区间内复用同一 Integer
val c: Int? = 1000
val d: Int? = 1000
check(c === d) // false, 缓存区间外各自装箱
Kotlin == 走 equals 按值比较, 所以 a == b 与 c == d 均为 true; 装箱陷阱只出现在 Java 互操作的 == 或 Kotlin === 引用比较. 热路径上高频装箱会产生对象分配, 增加 GC 压力, 应优先用原始 Int/Long.
if, when, try 都是表达式
Kotlin 的控制结构可产生值, 因此能减少 “先声明可变变量, 再在分支赋值” 的样板. Nothing 表示不会正常返回的计算, 是所有类型的子类型, 常用于让失败分支保持穷尽.
// 可运行示例(Kotlin/JVM,无额外依赖;保存为 ExpressionDemo.kt 后执行
// kotlinc ExpressionDemo.kt -include-runtime -d demo.jar && java -jar demo.jar).
sealed interface LoadState {
data object Loading : LoadState
data class Content(val text: String) : LoadState
data class Failed(val cause: Throwable) : LoadState
}
fun fail(message: String): Nothing = error(message)
fun label(state: LoadState, cached: String?): String = when (state) {
LoadState.Loading -> if (cached != null) "cached: $cached" else "loading"
is LoadState.Content -> state.text
is LoadState.Failed -> try {
throw state.cause
} catch (_: IllegalArgumentException) {
"bad input"
} catch (error: Throwable) {
fail("unrecoverable: ${error.message}")
}
}
fun main() {
check(label(LoadState.Loading, null) == "loading")
check(label(LoadState.Content("ready"), null) == "ready")
}
二, 日常语法与 Java 互操作
默认参数与 @JvmOverloads
Kotlin 调用方可直接省略默认参数; JVM 通过编译器生成的 synthetic 默认方法处理省略值: 普通函数通常生成带 bitmask 的 方法名$default, 构造函数则生成带 bitmask 与 DefaultConstructorMarker 的 synthetic constructor; Java 不会自动识别这些桥接. 对确实需要让 Java 省略末尾参数的构造函数或方法使用 @JvmOverloads, 编译器会从右向左生成重载, 每个重载省略一个或多个连续的末尾默认参数.
class Request @JvmOverloads constructor(
val url: String,
val timeoutMillis: Long = 3_000,
val retryCount: Int = 1
)
// Java 可调用: new Request(url), new Request(url, timeoutMillis), new Request(url, timeoutMillis, retryCount)
它不会为跳过中间参数生成任意组合; 默认参数很多时会扩大 Java API 表面. 仅为稳定, 常用的 Java 调用路径添加它, 其他情况提供命名工厂方法或让 Java 显式传参.
Unit, Java void 与 java.lang.Void
Unit 是 Kotlin 表示 “正常完成但无业务返回值” 的类型; 普通返回 Unit 的 JVM 方法通常以 Java void 暴露. Unit 仍有单例值 Unit, 因而在函数类型或泛型位置会盒装为 kotlin.Unit, 例如 Result<Unit>. java.lang.Void 是可空的 Java 引用类型, 没有可供正常返回的实例; Java 泛型 API 通常以 null 表示无结果, 不应把它当作 Kotlin Unit 的等价替代.
fun save(): Unit = println("saved")
fun <T> completed(value: T): Result<T> = Result.success(value)
val result: Result<Unit> = completed(Unit)
infix, 解构和初始化顺序
infix 只能标记成员函数或扩展函数, 且函数必须恰好有一个参数. 该参数不能是 vararg, 也不能声明默认值; 调用时可写成 a merge b, 但没有点号和括号的写法不适合复杂表达式或 API 含义不直观的场景.
infix fun String.merge(other: String): String = this + other
val message = "Hello, " merge "Kotlin"
data class Point(val x: Int, val y: Int)
val (x, y) = Point(3, 5) // 依次调用 component1() 与 component2()
data class 自动生成 componentN; 普通类也可自行定义 operator fun componentN(). 解构只适合少量, 语义明确的字段, 不应用它隐藏昂贵计算或可空副作用.
主构造参数先成为属性参数或传给父类; 接着按源码顺序执行属性初始化器和 init 块; 最后才执行次构造函数体. 次构造函数必须先委托给主构造函数或另一个次构造函数, 因而不能绕过前面的初始化.
class InitOrder(val name: String) {
private val normalized = name.trim().also { println("property: $it") }
init {
println("init: $normalized")
}
constructor(name: String, suffix: String) : this(name) {
println("secondary: $suffix")
}
}
// InitOrder(" Ada ", "!") 依次输出 property, init, secondary.
数值转换, 遍历与可见性映射
Kotlin 不做隐式数值扩大或缩小转换, 例如 Int 不能直接赋给 Long; 这避免 Java 风格隐式转换在重载, 精度和空值边界中掩盖错误. 根据意图显式调用 toLong(), toInt() 等, 并在窄化转换前处理溢出语义.
遍历时, for (item in items) 直接使用迭代器协议, 适合需要 break, continue 或清晰索引控制的主流程; forEach 是函数调用, 不能用普通 break/continue; map/filter 用于产生新集合或后续流水线, 不要只为副作用创建被丢弃的中间集合.
for (item in items) {
if (item.isInvalid()) continue
consume(item)
}
val visibleNames = users.filter { it.visible }.map { it.name }
Kotlin 与 Java 的可见性不是完全一一对应的源码规则. public 在两侧都对外可见; Kotlin internal 面向同一 Kotlin module, 但 JVM 字节码通常仍是 public 并带模块名改写, 不是 Java 安全边界; Kotlin protected 允许类和子类访问, Java 调用方还受 Java 的包 / 子类规则约束; Kotlin 没有 Java package-private 关键字. 需要给 Java 限制包内访问时, 应将 Java API 放在合适包中并通过公开入口封装, 不要假设 Kotlin internal 等于 package-private.
三, 扩展函数 / 扩展属性
fun String.lastChar(): Char = this[length - 1]
原理 (高频追问): 扩展函数编译成静态方法, 接收者作为第一个参数传入. 所以:
- 扩展函数是静态分发, 不是多态: 调用哪个由声明类型决定, 不是运行时类型.
- 不能真正修改类, 不能访问 private 成员.
- 扩展属性没有 backing field, 只能定义 get/set.
四, 高阶函数与 inline
// 伪代码:省略计时实现,只说明 inline 的函数签名.
inline fun <T> measure(block: () -> T): T
- 捕获外部变量的 lambda 常需要保存捕获状态; 非捕获 lambda 在具体 Kotlin 后端上可能复用单例. 两者的实际对象分配还受 JVM/IR 后端, 目标版本, 调用位置和优化器影响, 不能只凭源码断言.
inline会把函数体和可内联 lambda 展开到调用处, 常可避免该调用点的 lambda 对象 / 虚调用, 并支持 lambda 内的非局部返回; 它不保证所有后端, 所有调用形态都 “零分配”.noinline: 某个 lambda 参数不内联.crossinline: 禁止该 lambda 非局部返回 (用于会在别处调用的场景).reified: 配合 inline, 让泛型类型在运行时可见 (T::class), 解决泛型擦除.- 边界:
reified只让内联函数调用点能拿到T的运行时类型, 不能恢复集合元素的完整泛型实参; 例如仍无法把List<String>和List<Int>的元素类型当作普通运行时类型安全区分.
五, 作用域函数 (let/run/with/apply/also)
| 函数 | 引用对象 | 返回值 | 典型用途 |
|---|---|---|---|
let | it | lambda 结果 | 非空判断后操作 x?.let { } |
run | this | lambda 结果 | 配置对象并计算结果 |
with | this | lambda 结果 | 对一个对象多次操作 (非扩展) |
apply | this | 对象本身 | 初始化配置 Paint().apply { } |
also | it | 对象本身 | 副作用 (打日志) 不改链式 |
记忆法:返回结果用 let/run/with, 返回自身用 apply/also; 用 it 是 let/also, 用 this 是 run/with/apply.
六, class 家族
- data class: 自动生成 equals/hashCode/toString/copy/componentN. 注意 copy 是浅拷贝; 只有主构造参数参与生成.
- sealed class / sealed interface: 密封类型, 子类受限在同一模块. 配合
when可穷尽分支 (无需 else), 适合表达状态 (Loading/Success/Error). - object: 单例;
companion object伴生对象 (类级别成员, 可实现接口, 可命名). - enum: 枚举, 可带属性和方法.
- 嵌套 vs 内部类: Kotlin 嵌套类默认是静态的; 加
inner才持有外部类引用.
反例: copy () 是浅拷贝, 可变字段共享同一引用:
data class Profile(val name: String, var tags: MutableList<String>)
val a = Profile("Ada", mutableListOf("a"))
val b = a.copy() // tags 与 a 指向同一个 MutableList
b.tags.add("b") // 就地修改共享列表, a.tags 同步变为 ["a","b"]
check(a == b) // 仍相等: 两边看到同一个列表
val c = Profile("Ada", mutableListOf("a"))
val d = c.copy()
d.tags = mutableListOf("a", "b") // 替换为新列表, 内容不同
check(c == d) // false: MutableList.equals 按内容比较
data class 的 equals 逐字段调用各属性 equals, 因此可变成员内容或引用一旦出现分歧, 相等性就随内容漂移. 用可变字段做集合 key 或状态比较前, 应保证字段不可变或复制出独立快照.
可见性与 backing field
可见性决定 “谁能在源码层引用声明”, 不是线程安全或不可变性的同义词. public 对所有模块可见; internal 对同一 Kotlin 模块可见; protected 对类及子类可见; private 仅在声明范围可见. 公共 Android SDK 的 internal 在 JVM 字节码中并非安全边界, 敏感能力应通过 API 设计来保护, 而非依赖它.
可运行示例 (Kotlin/JVM, 无额外依赖; 保存为 VisibilityDemo.kt, 执行 kotlinc VisibilityDemo.kt -include-runtime -d demo.jar && java -jar demo.jar):
class Profile {
var name: String = "anonymous"
set(value) {
field = value.trim()
}
val nameLength: Int
get() = name.length
}
fun main() {
val profile = Profile()
profile.name = " Ada "
check(profile.name == "Ada")
check(profile.nameLength == 3)
println(profile.name)
}
field 是编译器在 accessor 中提供的 backing field (后备字段) 引用. 带默认 getter/setter 的属性通常有后备字段; 只有自定义 getter, 且不引用 field 的计算属性通常没有. 例如 nameLength 每次读取计算结果, 不存储独立值. 症状是以为计算属性会缓存而得到过期性能判断; 证据是 getter 每次都会执行; 定位看是否引用 field; 修复是需要缓存时明确保存状态; 验证是在 getter 中断点或写测试确认读取次数.
域案例 (设备指纹 / 风控 SDK): 用 value class + sealed 把 “设备 ID” 与 “设备状态” 建模成类型, 让业务函数不可能收到裸字符串或乱传枚举值:
@JvmInline value class DeviceId(val raw: String) // 包装设备指纹 hash
sealed class DeviceState {
data object Normal : DeviceState()
data object Emulator : DeviceState()
data object Unknown : DeviceState()
data class RiskFlagged(val reason: String) : DeviceState()
}
fun report(id: DeviceId, state: DeviceState) {
// 编译期约束: id 只能是 DeviceId, 不会误传原始字符串
}
sealed 保证 when (state) 穷尽所有状态, 新增状态分支时编译器强制检查; value class 让指纹字符串与普通 String 在类型上区分开.
七, 委托 (Delegation)
委托的心智模型是:我不亲自做, 把某件事交给另一个对象做. 它解决的是 “复用行为” 问题, 不是 “继承层级” 问题.
Kotlin 里有两类常见委托:
| 类型 | 解决什么问题 | 典型写法 | 面试关键词 |
|---|---|---|---|
| 类委托 | 把接口实现转交给另一个对象 | class B(a: A) : A by a | 组合优于继承, 编译器生成转发 |
| 属性委托 | 把 getter/setter 的通用逻辑抽出去 | val x by lazy { } | getValue / setValue 约定 |
1. 类委托: 组合优于继承
interface DataSource {
fun query(): String
}
class RemoteDataSource : DataSource {
override fun query(): String = "remote"
}
class LoggingDataSource(
private val real: DataSource
) : DataSource by real {
override fun query(): String {
println("before query")
return real.query()
}
}
: DataSource by real 的意思是: 如果 LoggingDataSource 没有自己实现某个 DataSource 方法, 编译器就自动生成转发代码, 把调用交给 real.
这比继承更灵活: 你可以包装不同实现, 增强行为, 替换数据源, 而不用把类层级设计得很深. Android 里常见于 Repository 包装, 缓存层包装, 埋点 / 日志包装, 测试 fake 实现替换.
易错点: 外层类 override 的成员, 不会改变委托对象内部自己的调用逻辑. 委托对象在执行自己的方法时, 访问的是它自己的成员, 不是外层包装类 override 后的成员.
2. 属性委托: 把属性访问逻辑抽出去
val config by lazy {
loadConfigFromDisk()
}
var name: String by Delegates.observable("unknown") { property, old, new ->
println("${property.name}: $old -> $new")
}
属性委托的本质是编译器把属性访问改写为对委托对象的调用:
class StringDelegate {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "value of ${property.name}"
}
}
val title: String by StringDelegate()
读取 title 时, 实际调用的是 StringDelegate.getValue(...). 如果是 var, 还需要 operator fun setValue(...).
常见标准库委托:
by lazy { }: 首次访问才初始化. 默认SYNCHRONIZED, 多线程安全但有锁开销; 单线程场景可考虑LazyThreadSafetyMode.NONE.Delegates.observable: 属性变化时回调, 适合状态监听.Delegates.notNull(): 非空但延迟赋值, 访问前未赋值会抛异常.by map: 属性名作为 key, 从 Map 中读值, 常见于 JSON / 配置映射示例.
Android 落地可以这样理解: ViewBinding 委托, SharedPreferences/DataStore 属性包装, 页面参数校验, 配置中心读取, 本质都是把重复的 getter/setter 或初始化逻辑集中管理.
八, 泛型型变
泛型型变解决的是:泛型类型之间能不能保持原来的父子关系.
List 只读不等于深度不可变
List<T> 是只读接口 / 视图, 它不提供写操作, 但不保证底层集合或元素深度不可变; 同一底层集合仍可经其他 MutableList 引用被修改. MutableList<T> 才提供 add, remove 等写操作. 需要跨模块或并发边界隔离可变状态时, 使用 defensive copy 创建独立快照; 需要持续派生而不共享可变状态时, 选择持久化不可变集合, 并约束元素自身的可变性.
先看一个反直觉点:
val strings: MutableList<String> = mutableListOf("a")
// val anys: MutableList<Any> = strings // 编译不允许
虽然 String 是 Any 的子类, 但 MutableList<String> 不是 MutableList<Any> 的子类. 否则下面这种写法就会破坏类型安全:
val strings: MutableList<String> = mutableListOf("a")
val anys: MutableList<Any> = strings // 假设允许
anys.add(123) // 往 String 列表塞 Int
val s: String = strings[1] // 运行时炸掉
所以 Kotlin 默认让泛型不型变 (invariant). 只有当编译器能确认安全时, 才允许你声明 out 或 in.
| 关键字 | 心智模型 | 只能做什么 | 典型例子 | Java 类比 |
|---|---|---|---|---|
out T | 生产者 Producer | 主要把 T 读出来 | List<out T> / Source<out T> | ? extends T |
in T | 消费者 Consumer | 主要把 T 写进去 / 传进去 | Comparator<in T> / Sink<in T> | ? super T |
| 不加 | 既读又写 | 精确类型 | MutableList<T> | 普通泛型 |
* | 不关心具体类型 | 安全读取为上界, 不能安全写入具体 T | List<*> | ? |
1. out: 只生产, 所以可以协变
interface Source<out T> {
fun next(): T
}
val stringSource: Source<String> = object : Source<String> {
override fun next(): String = "hello"
}
val anySource: Source<Any> = stringSource // 安全:String 一定也是 Any
Source<out T> 只把 T 作为返回值 “吐出来”, 外部不会把错误类型塞进去, 所以 Source<String> 可以当成 Source<Any> 使用.
2. in: 只消费, 所以可以逆变
interface Sink<in T> {
fun accept(value: T)
}
val anySink: Sink<Any> = object : Sink<Any> {
override fun accept(value: Any) = println(value)
}
val stringSink: Sink<String> = anySink // 安全:能消费 Any,当然能消费 String
Sink<in T> 只接收 T, 不把具体 T 返回给你. 一个能处理 Any 的消费者, 当然也能处理 String, 所以赋值方向和 out 反过来.
3. 什么时候用什么
记住 PECS: Producer Extends, Consumer Super. Kotlin 写法就是: 生产者用 out, 消费者用 in.
- 只从容器里读
T: 考虑out T. - 只往对象里传入
T: 考虑in T. - 既要读又要写具体
T: 通常不要加型变, 保持T不型变. - 不知道具体类型, 只想安全遍历或打印: 用
*星投影.
面试表达可以这样说: 型变不是为了 “更高级”, 而是为了在不牺牲类型安全的前提下, 让 API 参数更灵活.
版本基线与示例边界
Kotlin/JVM 结论应记录 Kotlin compiler, JDK, JVM target 与 Android desugaring 基线. inline, sealed 类型, 集合实现和标准库 API 可能受编译目标与版本影响; 示例在项目当前 toolchain 中验证后再声称可运行. 官方语义优先参考 Kotlin language/stdlib 文档, 不用反编译某一版本产物外推所有版本. 最后核验: 2026-08-07.
练习与掌握检查
- 把
Profile.name改为只允许非空且长度 1 到 20 的值. 预期: 非法输入在 setter 处失败,nameLength始终由当前 name 计算. - 为 “网络返回的 Java 平台类型” 写一个边界转换函数: 输入可空
String?, 输出非空展示文案. 说明!!在这里为何不是默认方案. - 用
let,run,apply,also各写一行, 并说明接收者名称和返回值; 能解释选择理由而非只背表格即通过. - 解释为什么
MutableList<String>不能赋值给MutableList<Any>, 再把只读参数改成合适的List类型.
高频面试题
Q1: == 和 === 的区别?
== 比较值 (调用 equals), === 比较引用. Java 的 == 对应 Kotlin 的 ===.
Q2: lateinit 和 by lazy 的区别?
lateinit 用于 var, 非空, 可多次赋值, 不能用于基本类型, 由开发者负责初始化时机; lazy 用于 val, 首次访问自动初始化, 线程安全可配置.
Q3: 扩展函数能被重写吗? 为什么? 不能. 扩展函数是静态分发, 编译成静态方法, 调用哪个由声明类型决定而非运行时类型, 所以没有多态.
Q4: inline 一定能提升性能吗?
不一定. inline 在常见调用形态可消除函数对象与调用开销, 适合小型高阶函数; 但 noinline, 捕获, 函数引用, 跨模块调用及不同后端 / 优化器都会影响结果, 不能承诺所有形态零分配. 内联还会增大字节码, 对大函数或大量调用点滥用可能增加体积, 降低性能; 应以目标工具链的字节码和基准测量判断.
Q5: Kotlin 的 Unit, Nothing, Any 区别?
Any 是所有非空 Kotlin 类型的根, Any? 才包含 null; Java Object 未标注空性时进入 Kotlin 会形成平台类型, 不能简单等同于二者. Kotlin 基本类型在 JVM 上通常映射为原始类型, 在泛型或可空位置按需装箱. Unit 表示正常完成但无业务结果, 普通 JVM 方法通常映射为 void, 泛型位置则使用真实的 kotlin.Unit 对象; Nothing 表示永不正常返回 (抛异常或死循环), 是所有 Kotlin 类型的子类型.
Q6: data class 用作 HashMap 的 key 安全吗? 安全 (自动生成了 hashCode/equals), 但若字段可变, 作为 key 后修改字段会导致查找失败: 应保证 key 不可变.
Q7: Kotlin 委托的本质是什么?
类委托本质是编译器为接口方法生成转发代码, 把调用交给被委托对象; 属性委托本质是把 getter/setter 改写为 getValue / setValue 调用. 它的价值是用组合复用行为, 减少继承和重复样板代码.
Q8: 为什么 MutableList<String> 不能赋给 MutableList<Any>?
因为 MutableList 既能读也能写. 如果允许赋值, 就可以通过 MutableList<Any> 往原本的 MutableList<String> 里写入 Int, 破坏类型安全. 只读的生产者可以用 out, 只写 / 消费的对象可以用 in, 既读又写通常保持不型变.