Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)

函数引用对象返回值典型用途
letitlambda 结果非空判断后操作 x?.let { }
runthislambda 结果配置对象并计算结果
withthislambda 结果对一个对象多次操作 (非扩展)
applythis对象本身初始化配置 Paint().apply { }
alsoit对象本身副作用 (打日志) 不改链式

记忆法:返回结果用 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>普通泛型
*不关心具体类型安全读取为上界, 不能安全写入具体 TList<*>?

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.

练习与掌握检查

  1. 把 Profile.name 改为只允许非空且长度 1 到 20 的值. 预期: 非法输入在 setter 处失败, nameLength 始终由当前 name 计算.
  2. 为 “网络返回的 Java 平台类型” 写一个边界转换函数: 输入可空 String?, 输出非空展示文案. 说明 !! 在这里为何不是默认方案.
  3. 用 let, run, apply, also 各写一行, 并说明接收者名称和返回值; 能解释选择理由而非只背表格即通过.
  4. 解释为什么 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, 既读又写通常保持不型变.