Kotlin 语言核心
Kotlin 是现代 Android 的第一语言。你会用,但要把“会用“升级到“讲得清原理“。本篇覆盖中级面试高频语法点的底层机制。
一、空安全(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,线程安全延迟初始化。
二、扩展函数 / 扩展属性
fun String.lastChar(): Char = this[length - 1]
原理(高频追问):扩展函数编译成静态方法,接收者作为第一个参数传入。所以:
- 扩展函数是静态分发,不是多态——调用哪个由声明类型决定,不是运行时类型。
- 不能真正修改类、不能访问 private 成员。
- 扩展属性没有 backing field,只能定义 get/set。
三、高阶函数与 inline
inline fun <T> measure(block: () -> T): T { ... }
- Lambda 默认会被编译成
Function对象,有对象创建开销。 inline把函数体和 lambda 直接内联到调用处,消除对象分配,还能让 lambda 内的return直接返回外层函数(非局部返回)。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才持有外部类引用。
六、委托(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 或初始化逻辑集中管理。
七、泛型型变
泛型型变解决的是:泛型类型之间能不能保持原来的父子关系。
先看一个反直觉点:
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 参数更灵活。
高频面试题
Q1:== 和 === 的区别?
== 比较值(调用 equals),=== 比较引用。Java 的 == 对应 Kotlin 的 ===。
Q2:lateinit 和 by lazy的区别?
lateinit 用于 var、非空、可多次赋值、不能用于基本类型、由开发者负责初始化时机;lazy 用于 val、首次访问自动初始化、线程安全可配置。
Q3:扩展函数能被重写吗?为什么? 不能。扩展函数是静态分发,编译成静态方法,调用哪个由声明类型决定而非运行时类型,所以没有多态。
Q4:inline 一定能提升性能吗? 不一定。inline 消除 lambda 对象分配,适合高阶函数;但内联会增大字节码,对大函数滥用反而增加体积、降低性能。Kotlin 编译器会对大 inline 函数告警。
Q5:Kotlin 的 Unit、Nothing、Any 区别?
Any 是所有非空类型的根(类比 Object);Unit 表示无返回值(类比 void,但是真实对象);Nothing 表示永不返回(抛异常或死循环),是所有类型的子类型。
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,既读又写通常保持不型变。