Kotlin 全面速查

速查总览:这一页怎么查

本章是全页的索引:章节地图告诉你哪个话题在哪一章,定位与选型回答「什么时候该选 Kotlin」,版本年表解决查资料时最费时的一件事——你搜到的这个写法要 Kotlin 几、要不要 opt-in、在你项目的版本上能不能编过。

全页十九章,按「跑起来 → 写得对 → 并发与互操作 → 立得住」排列。不必顺读——按关键词定位,翻该章第一张速查表。

章节地图

章查什么关键词
01三条运行路径、kotlinc 与 Gradle 命令、程序骨架、按报错反查main · gradle run
02val / var、基本类型与装箱、字符串模板与原始字符串、const 与类型别名、类型推断的边界val · $ 模板 · toInt()
03if / when 是表达式、范围与循环、return@label 与 Nothing、控制台与文件 I/Owhen · until · run{}
04默认参数与命名实参、vararg、顶层与局部函数、中缀与函数引用fun · = 默认值 · ::
05可空类型全套符号、智能转换、lateinit、平台类型、null 处理惯用法、异常?. · ?: · !! · T!
06类与构造器、默认 final 与 open、data class、sealed、接口委托与伴生对象、属性委托data · sealed · by
07只读 ≠ 不可变、集合操作符按用途反查、Sequence 什么时候值得用、并发与拷贝map · filter · asSequence
08Lambda 与高阶函数、五个作用域函数怎么选、扩展函数与扩展属性let · apply · 扩展
09类型擦除、型变 out / in、inline / reified、运算符重载与解构where · reified
10挂起到底是什么、launch / async / runBlocking、结构化并发、调度器、取消与异常suspend · CoroutineScope
11Flow 冷流、操作符与上下文保持、背压与缓冲、StateFlow / SharedFlow / Channelflow{} · collect · StateFlow
12平台类型与集合映射、Java 侧看到的 API 形态、@Jvm* 注解全表、受检异常、混编边界@JvmStatic · 互操作
13value class、前置条件 require / check、错误处理四种谱系、可见性与 API 稳定性、命名惯例require · Result · sealed
14带接收者的 lambda、一个完整 HTML DSL、@DslMarker、什么时候不该写 DSLDSL · 接收者
15kotlin.test 与 JUnit 5、协程虚拟时间测试、Gradle 配置全表、序列化与网络、静态检查runTest · build.gradle.kts
16反射、KMP 跨平台、Kotlin 2.x 到 2.4 的语言变化、被语言吃掉的设计模式、编译产物K2 · expect/actual
17Compose 多平台、声明式与重组的心智模型、不用 Compose 的选择@Composable · remember
18动手项目、资料索引、自测标准下一步

三条常见查法

你手上的线索去哪查
编译器报了一段红字01 章的报错反查表——Kotlin 的报错文字很直白,但空安全与协程那几类需要知道背景
「这个集合操作叫什么来着」07 章的操作符反查表:按「我想做什么」找,不按名字找
「这么写地道吗」13 章是 Kotlin 惯用法的集中地;作用域函数怎么选在 08 章
// 一段代码能同时用到本页大半章节
import kotlinx.coroutines.*

sealed interface Result<out T> {                       // 06 章 sealed + 09 章型变
    data class Ok<T>(val data: T) : Result<T>
    data class Err(val code: Int)   : Result<Nothing>
}

suspend fun load(ids: List<Int>): List<Repo> = coroutineScope {   // 10 章结构化并发
    ids.map { async { api.fetch(it) } }               // 并发发起
       .awaitAll()                                     // 一起等
       .filterIsInstance<Result.Ok<Repo>>()               // 07 章集合操作
       .map { it.data }
       .sortedByDescending { it.stars }
}

fun describe(r: Result<*>): String = when (r) {         // 03 章 when 是表达式
    is Result.Ok  -> "拿到 ${r.data}"                     // 02 章字符串模板
    is Result.Err -> "失败 ${r.code}"
}                                                     // sealed:不用写 else
查 Kotlin 资料要留意两个断层:协程 API 在 1.3 之前完全不同(老文章里的 experimental 包名、CommonPool 都已作废),以及 Kotlin 2.0 换了 K2 编译器——一些以前能编过的模糊写法现在会报错。搜到的代码先看它用的是哪个版本;本页基准是 Kotlin 2.4。
每章第一张卡都是「速查表:…」,只有表格没有长篇解释,用来当场查完就走;想知道「为什么」再往下翻同章后面的卡。深水区的卡带 deep 徽章,第一次查可以跳过。

Kotlin 的立身之本是「更好的 Java」:100% 兼容 JVM 生态,同时把空安全写进类型系统、用协程驯服异步。2019 年起它是 Android 官方首选语言。

今天它用在哪

  • Android 的事实标准:Jetpack Compose 只面向 Kotlin 设计,头部应用的新代码基本都是 Kotlin;
  • 服务端:Spring Boot 官方支持,JetBrains 自家的 Ktor 走轻量协程路线;
  • 招牌是四样:空安全(05 章)、data class 与 sealed(06 章)、扩展函数(08 章)、协程(10 章)。

和邻居怎么选

  • vs Java:互操作是王牌,同一项目可逐文件迁移——新代码几乎没有理由不用 Kotlin;
  • vs Dart/Flutter:Flutter 是「一套 UI 跨全端」,KMP 是「共享逻辑 + 各端原生 UI」,重原生手感选后者;
  • 什么时候别选:不在 JVM/Android 上、又不想背 JVM 的启动与内存开销。
// Kotlin 的气质:空安全 + 一切皆表达式
data class User(val name: String, val email: String?)

fun domain(user: User?): String {
    val email = user?.email ?: return "无邮箱"  // 安全调用 + Elvis
    return email.substringAfter("@")
}

println(domain(User("Alice", "alice@corp.com")))  // corp.com
别把「100% 互操作」听成「两边一样」。从 Java 拿到的值是平台类型,编译器不替你检查空——空安全的保护在边界上有个缺口(05 章、12 章各有一卡专讲)。反过来你写的 Kotlin API 在 Java 眼里也常常不是你以为的样子。
本页以 Kotlin 2.4 为基准,K2 编译器自 2.0 起已是默认。学法是把它当一场「Java 思维改造」:每学一个特性就问它替换了 Java 的哪种样板——data class 替 POJO、扩展函数替 Utils 类。

Kotlin 的语言版本由 Gradle 里的 Kotlin 插件版本决定(不是你装的 IDE)。搜到的写法编译不过,先查它要几版、要不要 opt-in。

语言特性年表(本页基准 2.4)

版本带来了什么长什么样
1.0(2016)空安全、扩展函数、data class、when——这门语言的底子从第一版就定了val s: String?
1.3协程稳定(此前是 experimental,包名都不同)suspend fun · coroutineScope { }
1.4SAM 转换改进、尾随逗号—
1.5sealed interface、@JvmInline value class、JVM records 互操作@JvmInline value class Id(val v: Long)
1.6 ~ 1.8when 对 sealed 的穷尽检查收紧、构建与增量编译改进—
1.9data object、枚举的 entries(取代 values())data object Loading · Color.entries
2.0(2024)K2 编译器成为默认前端:类型推断更准、诊断更清楚、编译更快;代价是一批有歧义的老写法开始报错升级后「没改代码却编不过」多半来自这里
2.xwhen 的 guard conditions 落地(2.4 上无需 opt-in);context receivers 的设计被 context parameters 取代,仍在演进is String if x.isEmpty() -> …
2.4本页基准,页内断言都在 2.4.x 上确认过—

版本在哪定、怎么查

要什么在哪说明
Kotlin 版本build.gradle.kts 的 plugins { kotlin("jvm") version "2.4.x" }这一行决定语言特性;多模块项目在根工程统一
语言/API 级别kotlin { compilerOptions { languageVersion.set(KotlinVersion.KOTLIN_2_1) } }需要兼容老代码时才调低,平时不写
JVM 目标jvmToolchain(21)与 Kotlin 版本是两回事;Java 版本不一致是「本地能跑 CI 不能」的常见原因
命令行确认kotlinc -version · ./gradlew -q kotlinVersion(若有该任务)—
标准库版本随 Kotlin 插件自动对齐一般不用手写依赖
协程库org.jetbrains.kotlinx:kotlinx-coroutines-core单独的版本号,不随 Kotlin 版本走(10 章)

实验特性:三条判断

问题怎么确认
它在哪个版本稳定kotlinlang.org 的 What's new 页与 KEEP 提案——博客文章的版本号经常过期
要不要 opt-in报错会直接告诉你需要 @OptIn(...) 或编译器参数 -Xsomething
能不能编过自己写五行编译一次最快,比读十篇文章可靠
该不该用实验特性别进生产代码的核心路径:语法与开关都可能在小版本间变,迁移成本由你承担
// build.gradle.kts —— 决定语言版本的就是 plugins 那一行
plugins {
    kotlin("jvm") version "2.4.10"        // ← 语言特性由它决定
    application
}

kotlin {
    jvmToolchain(21)                      // JVM 目标,和 Kotlin 版本是两回事
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")  // 独立版本号
    testImplementation(kotlin("test"))
}

// 2.x 落地的两个写法,2.4 上直接可用
sealed interface Route {
    data object Home : Route          // 1.9:toString 打印 Home 而不是 Route$Home@1b6d
    data class User(val id: Long) : Route
}

fun classify(x: Any): String = when (x) {
    is String if x.isEmpty() -> "空串"      // 2.x:guard condition
    is String                -> "串:${x.length}"
    is Int if x > 0        -> "正数"
    else                     -> "其他"
}
协程库的版本独立于 Kotlin 版本:升了 Kotlin 却没升 kotlinx-coroutines,会遇到莫名其妙的二进制兼容报错。同理 kotlinx-serialization 的插件版本必须与 Kotlin 版本匹配。
Kotlin 2.0 的 K2 会让一部分老代码编不过——这通常不是回归,而是原来的写法本就有歧义。别急着把语言版本调回去,按报错改才是正路。
只想知道「这句语法要几版」,写五行编译一次比查表快——编译器会明确告诉你需要哪个版本或哪个 opt-in。
升级 Kotlin 大版本时先看 What's new 的 Compatibility guide,K2 收紧的那批写法都列在里面。

起步 · 命令 · 报错速查

本章覆盖三张随手查的表:三条运行路径与 kotlinc / Gradle 命令全表、项目骨架与最小可跑代码、按报错反查(编译错误、运行时异常、Gradle 构建失败各一组)。Kotlin 的入口比多数语言绕:它要跑在 JVM 上,工程侧的事绕不开 Gradle。

Kotlin 没有「装个解释器就能跑脚本」这条捷径:它编译到 JVM 字节码,要有 JDK。三条路按「多快能看到输出」排。

三条运行路径

路径怎么做什么时候用
在线 Playgroundplay.kotlinlang.org,打开就能跑验证一段语法最快,不用装任何东西
命令行 kotlinckotlinc hello.kt -include-runtime -d hello.jar → java -jar hello.jar单文件小实验;第一次编译很慢(JVM 启动 + 编译器预热)
脚本kotlinc -script foo.kts一次性脚本;生态不如 Python,别指望太多
Gradle 项目gradle init --type kotlin-application → ./gradlew run真实开发的唯一形态:要引依赖、写测试、出产物就得是它
IDE 临时文件IntelliJ 的 Scratch File(.kts)在项目上下文里试一段代码,能用项目的依赖
前提JDK 17 或 21(java -version 确认)Kotlin 自己不带运行时

命令全表

做什么命令要点
建项目gradle init --type kotlin-application --dsl kotlinDSL 选 Kotlin(.kts),别选 Groovy
跑./gradlew run传参 --args="a b";Windows 上是 gradlew.bat
只编译./gradlew build · compileKotlinbuild 含测试与打包
测试./gradlew test--tests "com.x.FooTest" 筛选;报告在 build/reports/tests/
清理./gradlew clean玄学构建错误先跑它
看依赖树./gradlew dependencies--configuration runtimeClasspath 更清爽
看有哪些任务./gradlew tasks—
诊断构建失败./gradlew build --stacktrace · --info · --scan报错看不懂先加 --stacktrace
安静输出./gradlew -q run --console=plain脚本里用
离线./gradlew build --offline网络差时
出可执行包./gradlew installDist · distZipapplication 插件提供
打胖 jarShadow 插件的 shadowJar默认 jar 不含依赖,直接 java -jar 会 NoClassDefFoundError
升级 wrapper./gradlew wrapper --gradle-version 8.x—
单文件编译kotlinc a.kt -include-runtime -d a.jar不加 -include-runtime 的 jar 缺标准库
查版本kotlinc -version · java -version—

项目结构

路径是什么
src/main/kotlin/源码;包名与目录可以不一致,但保持一致更好找
src/test/kotlin/测试(15 章)
src/main/resources/随包走的资源文件
build.gradle.kts模块构建脚本:插件、依赖、JVM 目标
settings.gradle.kts项目名与子模块列表
gradle/libs.versions.toml版本目录:多模块时把依赖版本集中在这里
gradlew / gradlew.batwrapper,一定要用它而不是本机 gradle——版本才一致
build/ · .gradle/生成物,不进版本库

最小骨架:照抄能跑

要什么怎么写
整个程序fun main() { println("hi") }——顶层函数,不需要 class
拿命令行参数fun main(args: Array<String>) { … }
读一行val line = readln()(读到 EOF 会抛)· readlnOrNull()(返回 String?)
退出码kotlin.system.exitProcess(2)
包声明package com.example,写在文件第一行;可以省略
入口在哪Gradle 里 application { mainClass.set("com.example.MainKt") }——顶层函数生成的类名是「文件名 + Kt」
字符串输出println("x = $x, 和 = ${a + b}")
# 从零到跑通:四条命令
$ mkdir demo && cd demo
$ gradle init --type kotlin-application --dsl kotlin
$ ./gradlew run                       # 编译 + 运行
$ ./gradlew test                      # 跑测试

// src/main/kotlin/Main.kt —— 最小但完整的程序
import kotlin.system.exitProcess

fun main(args: Array<String>) {
    val name = args.firstOrNull() ?: run {      // 05 章:?: 兜底
        System.err.println("用法: demo <名字>")
        exitProcess(2)                            // 返回类型是 Nothing,能当值用
    }
    println("你好 $name,参数共 ${args.size} 个")   // 02 章:字符串模板
}

// build.gradle.kts —— 一个能跑的最小配置
plugins {
    kotlin("jvm") version "2.4.10"
    application
}
repositories { mavenCentral() }
dependencies { testImplementation(kotlin("test")) }
kotlin { jvmToolchain(21) }
application { mainClass.set("MainKt") }     // Main.kt 的顶层函数 → MainKt
kotlinc 编出来的 jar 默认不含标准库,直接 java -jar 会 NoClassDefFoundError: kotlin/jvm/internal/Intrinsics——要加 -include-runtime。Gradle 项目里同理,默认 jar 任务不打包依赖,要分发得用 Shadow 插件或 installDist。
顶层 main 对应的类名是「文件名 + Kt」:Main.kt → MainKt。mainClass 写错时报的是 ClassNotFoundException,看起来像依赖问题,其实只是名字不对。
只是想验证一段语法,别建项目——用 Playground 或 IDE 的 Scratch File,省下的是几十秒的 Gradle 冷启动。
项目里一律用 ./gradlew 而不是 gradle:wrapper 会自动下载并锁定构建工具的版本,「我这能跑你那不能跑」一大半来自这里。

Kotlin 的报错文字比多数语言直白,难的是知道它背后在坚持什么规则——尤其空安全与智能转换那几条。下面按来源分三组。

一、编译错误

报错意思改法
Null can not be a value of a non-null type把 null 赋给了不可空类型类型改成 T?,或换个非空的值(05 章)
Only safe (?.) or non-null asserted (!!.) calls are allowed…对可空变量直接点成员x?.f() · x?.f() ?: d · 先判空让智能转换生效;!! 是最后手段
Smart cast to 'X' is impossible, because 'y' is a mutable property…判空后编译器仍不敢当它非空:var 属性可能被别的线程改先赋给局部 val:val v = y ?: return
Type mismatch: inferred type is A but B was expected类型对不上看清推断出来的到底是什么;泛型场景看 09 章型变
Unresolved reference: x名字找不到拼写、漏 import、依赖没加、或该扩展函数所在包没导入
'when' expression must be exhaustivewhen 用作表达式却没覆盖所有情况补 else;sealed / enum 建议补齐分支而不是加 else(06 章)
This type is final, so it cannot be inherited fromKotlin 的类默认 final给父类加 open;或者别继承,用组合与接口
'x' hides member of supertype and needs 'override' modifier覆盖必须显式加 override
Val cannot be reassigned给 val 重新赋值改成 var——但先想想是不是逻辑写歪了
Property must be initialized or be abstract属性没有初值给初值、放进构造器、用 lateinit(仅非空引用类型)或 by lazy
Suspend function should be called only from a coroutine…在普通函数里调 suspend 函数调用方也标 suspend,或用 runBlocking / scope.launch(10 章)
Overload resolution ambiguity多个重载都匹配显式写类型或具名实参
None of the following functions can be called with the arguments supplied没有匹配的重载展开看每个候选缺什么——常常是可空性不匹配
Type parameter is declared as 'out' but occurs in 'in' position型变位置冲突09 章;临时解法是 @UnsafeVariance,但先想清楚
Platform declaration clash两个声明在 JVM 上签名撞了加 @JvmName("other")(12 章)
Data class must have at least one primary constructor parameterdata class 主构造器为空要么加参数,要么用 data object

二、运行时异常

异常从哪来改法
NullPointerException九成来自 !!,其余来自 Java 侧返回的平台类型去掉 !! 写判空;边界处把平台类型显式标成 T?(05 / 12 章)
UninitializedPropertyAccessExceptionlateinit 属性还没赋值就读了确认赋值路径先执行;::x.isInitialized 可判断
ClassCastException强转失败用 as? + 判空,或 is 智能转换
IllegalArgumentExceptionrequire(...) 没过——参数不合法13 章:这是设计好的失败
IllegalStateExceptioncheck(...) / error(...)——状态不对同上
ConcurrentModificationException遍历时改集合先 toList() 复制,或用 removeAll { }
CancellationException / JobCancellationException协程被取消——这是正常控制流,不是错误不要用 catch (e: Exception) 吞掉它,否则取消会失效(10 章)
kotlinx.serialization.MissingFieldExceptionJSON 缺必填字段字段给默认值或改成可空(15 章)
NoSuchMethodError / NoClassDefFoundError版本不匹配或依赖没打进包查 ./gradlew dependencies;胖 jar 见本章前面
StackOverflowError无限递归属性 getter 里写了 get() = this.x 自己调自己是经典写法错误

三、Gradle 与工具链

症状原因先试什么
Could not find com.x:y:1.0依赖坐标错、仓库没配确认 repositories { mavenCentral() };坐标去 Maven Central 核对
Unsupported class file major versionJDK 版本与 Gradle / 插件不匹配java -version;用 jvmToolchain(21) 固定版本
Execution failed for task ':compileKotlin'上面还有真正的编译错误往上翻找第一条 e: 开头的行
Daemon will be stopped / OutOfMemoryGradle 守护进程内存不够gradle.properties 里 org.gradle.jvmargs=-Xmx4g
构建极慢没开缓存与并行org.gradle.caching=true · org.gradle.parallel=true
本地能跑 CI 不行JDK 或 Gradle 版本不同用 wrapper + toolchain 把两者钉死
改了代码没生效增量编译状态脏./gradlew clean build

查不动的时候看哪里

手段怎么做
只看第一条Kotlin 的错误会级联,先修最上面那条再重编
看完整堆栈./gradlew build --stacktrace;运行期用 e.printStackTrace() 或结构化日志
看编译器到底生成了什么IntelliJ:Tools → Kotlin → Show Kotlin Bytecode → Decompile。互操作、inline、data class、协程的疑问一看就清楚(12 / 16 章)
确认推断出的类型IDE 悬停;或临时写 val x: Unit = expr 让编译器在报错里告诉你真实类型
协程卡住 / 泄漏加 -Dkotlinx.coroutines.debug;日志里会带协程名(10 章)
玄学./gradlew clean、删 .gradle/、IDE 里 Invalidate Caches
// ① 最常见的三条编译错误与它们的正解
var name: String? = load()

// println(name.length)          → Only safe (?.) or non-null asserted calls…
println(name?.length ?: 0)          // 安全调用 + 兜底

if (name != null) {
    // println(name.length)      → Smart cast impossible:name 是 var 属性
}
val n = name                       // ← 先固定成局部 val
if (n != null) println(n.length)   // 智能转换这才生效

// ② when 用作表达式时必须穷尽
sealed interface S { data object A : S; data object B : S }
fun f(s: S) = when (s) {           // sealed:编译器知道只有两种
    S.A -> "a"
    S.B -> "b"
}                                   // 不写 else;将来加一种会在这里报错(这正是目的)

// ③ 一条典型的 Gradle 报错:真正的原因在上面
e: file:///…/Main.kt:12:15 Unresolved reference: launch   // ← 看这一行
> Task :compileKotlin FAILED                              //   不是这一行
// 修:import kotlinx.coroutines.launch(或依赖没加)
「Smart cast is impossible」不是编译器犯傻:只要那个东西是 var 属性、或者来自别的模块的 open 属性,两次读取之间它就可能变。解法永远是先赋给局部 val,而不是加 !!。
Gradle 的最后一行几乎没有信息量(Execution failed for task ':compileKotlin')。真正的原因是往上几十行里以 e: 开头的第一条——养成从上往下读的习惯。
Kotlin 的报错值得逐字读完——它经常把解法直接写在句子里(「should be called only from a coroutine」「needs 'override' modifier」)。
拿不准编译器把一段代码变成了什么,反编译成 Java 看一眼:data class 生成了哪些方法、inline 展开成什么样、suspend 多了哪个参数,全都一目了然。

四行代码里有三处「Java 必须写、Kotlin 不用写」的地方。它们不是语法糖,背后都有一个明确的机制。

fun main() 为什么可以没有类

  • JVM 只认「某个类的静态 main 方法」,所以类还是有的,只是编译器替你造了:顶层函数被收进一个按文件名生成的 HelloKt 类;
  • javap -p -cp out HelloKt 能看到里面有两个 main——你写的那个,加一个编译器合成的转发器 main(String[]) 供 java 命令找入口。你自己写成 fun main(args: Array<String>) 时它就不必合成了。

不写分号、不写 import、文件名随便取

  • 分号可选,Kotlin 用换行断句;println 不用 import,kotlin.io 与 kotlin.collections.* 等几个包默认导入;
  • 文件名与类名无关(Java 里 public class 必须同名),代价见下面的坑。
// hello.kt
fun main() {                    // ① 顶层函数,不属于任何类
    val name = "Kotlin"          // ② 行尾没有分号
    println("Hello, $name!")   // ③ println 来自 kotlin.io,默认导入
}

// ---- 用 javap 看编译器到底造了什么 ----
//   kotlinc hello.kt -d out
//   javap -p -cp out HelloKt
// 输出:
//   public final class HelloKt {
//     public static final void main();              ← 你写的那个
//     public static void main(java.lang.String[]);  ← 编译器合成的转发器
//   }
// 类是编译器按文件名造的,那个转发器是给 java 命令找入口用的;
// 自己写成 fun main(args: Array<String>) 时,就只有一个 main 了。
文件名推导类名有个反直觉处:文件名里的非法字符会被替换,my-app.kt 生成的类叫 My_appKt(连字符变下划线),你按 MyAppKt 去 java -cp 会找不到。要固定这个名字,在文件顶部加 @file:JvmName("Hello")。
javap 是 JDK 自带的,整本 Kotlin 都值得随手用它——Kotlin 的很多「魔法」在字节码层面都平平无奇:data class 生成的方法、扩展函数其实是静态方法,一看就清楚。

变量与类型系统

本章覆盖:val / var / const / lateinit 怎么选、基本类型与装箱的时机、没有隐式数字转换这条规矩、字符串模板与原始字符串、类型推断的边界。val 管的是「指向谁」,不是「内容能不能改」——这条贯穿全章。

Kotlin 的类型系统对 Java 来说是「默认值重置」:变量默认不可重新赋值、类型默认不可空、数字默认不隐式转换。三条默认值解释了这一章的大半内容。

声明

写法含义什么时候用
val x = 1绑定不可变:不能再指向别的对象默认就用它;分析器也会提示你把 var 改成 val
var x = 1可重新赋值确实要变的
val 不等于对象不可变:val list = mutableListOf(1) 仍然可以 list.add(2)——不可变的是「x 指向谁」,不是「那个东西的内容」(07 章)
const val MAX = 100编译期常量,值被内联进调用处只能是顶层或 object / 伴生对象里的基本类型与 String
lateinit var repo: Repo「稍后一定会赋值」只能用于非空引用类型,不能用于基本类型;没赋值就读 → UninitializedPropertyAccessException
val x by lazy { … }首次访问时才算,之后缓存贵的初始化(06 章)
typealias Json = Map<String, Any?>类型别名只是换个名字,不产生新类型(要新类型用 value class,13 章)
显式类型val n: Long = 1推断不出、或想让 API 签名稳定时写

基本类型与数字

类型范围 / 字面量要点
Int32 位;42 · 0xFF · 0b1010 · 1_000_000默认整数类型
Long64 位;42L时间戳、ID
Short / Byte16 / 8 位少用,没有字面量后缀,要写 val b: Byte = 1
Double64 位;1.5默认小数类型
Float32 位;1.5f—
UInt / ULong 等42u无符号;互操作时慎用
Char / Boolean'a' / trueChar 不能当数字用,要 c.code
转换a.toInt() · toLong() · toDouble() · toString()Kotlin 没有隐式加宽:val l: Long = intValue 编译不过,必须 intValue.toLong()
解析"12".toInt()(失败抛)· "x".toIntOrNull()(失败给 null)处理输入一律 toIntOrNull()
整除与取余7 / 2 得 3(两个 Int)· 7 % 2 · 7.0 / 2 得 3.5和 Java 一致
溢出静默回绕,不抛异常需要大数用 BigInteger / BigDecimal
装箱Int 在 JVM 上通常是原始 int;但放进泛型或可空类型(Int?、List<Int>)就变成 Integer由此而来的坑:== 比值没问题,=== 比引用在装箱后不可靠
相等== 调 equals(Kotlin 里就是「值相等」)· === 比引用和 Java 相反,不用再写 .equals()

字符串

写法例子说明
模板"你好 $name,共 ${list.size} 条"单个变量不用大括号,表达式要
输出 $ 本身"\$100" 或 "${'$'}100"—
原始字符串"""…"""不转义、可跨行,正则与 JSON 直接粘;用 .trimIndent() 去掉公共缩进
多行对齐""">abc""".trimMargin(">")默认边界符是 |
常用方法isEmpty() · isNullOrBlank() · trim() · split(",") · substring() · replace() · startsWith() · padStart(5, '0')—
拼接集合list.joinToString(", ", prefix = "[", postfix = "]")比循环拼快也好读
格式化"%.2f".format(x) · String.format(...)—
大量拼接buildString { append(...) }内部就是 StringBuilder
按字符for (c in s) · s.toCharArray()s.length 数的是 UTF-16 码元

类型推断能到哪一步

情况推断结果
val a = 1Int(不会因为后面赋 Long 就变宽)
val b = 1.0Double
val c = listOf(1, 2)List<Int>(只读接口)
val d = listOf(1, "a")List<Any>——推断成最近公共父类,常常不是你想要的
fun f() = 1 + 1表达式体函数的返回类型也能推断
公开 API建议显式写返回类型:实现改一行就可能悄悄改变对外签名
递归函数推断不了,必须显式写返回类型
Java 来的值平台类型 T!:可空性未知,编译器不拦你(05 / 12 章)
// ① val 管的是「指向谁」,不是「内容能不能改」
val list = mutableListOf(1, 2)
list.add(3)              // 合法:改的是内容
// list = mutableListOf()  // 不合法:改的是指向

// ② 没有隐式加宽,这是从 Java 过来最容易撞的一条
val i = 1
// val l: Long = i       → Type mismatch
val l: Long = i.toLong()    // 显式转换

// ③ 解析输入:一律 OrNull 版本
val n = readlnOrNull()?.trim()?.toIntOrNull() ?: 0

// ④ 原始字符串 + trimIndent:正则和 JSON 直接粘
val re = Regex("""\d{4}-\d{2}-\d{2}""")      // 不用写 \\d
val json = """
    { "name": "张三", "path": "C:\temp" }
""".trimIndent()

// ⑤ 模板:单变量不用大括号,表达式要
val name = "世界"
println("你好 $name,长度 ${name.length}")      // 你好 世界,长度 2

// ⑥ == 是值相等,=== 才是引用相等(和 Java 相反)
val a = "ab"; val b = StringBuilder("ab").toString()
println(a == b)    // true
println(a === b)   // false
Kotlin 不做隐式数字加宽:val l: Long = intValue 直接编译错。这是有意的——Java 的隐式转换在混合运算里制造过太多精度事故。全部走 .toLong() / .toDouble()。
val 只是「不能重新赋值」,不等于线程安全也不等于内容不可变。要真正的不可变集合,用 listOf() 而不是 mutableListOf(),并且注意只读接口也可能被别处持有的可变引用改掉(07 章)。
两条能立刻让代码像 Kotlin 的习惯:一律先写 val(编译器会告诉你哪个非改不可),字符串拼接用模板而不是 +。
公开 API 上显式写类型:fun load(): List<User> 比 fun load() = repo.all() 稳定得多,实现改动不会悄悄改变签名。

Kotlin 把「这个名字还能不能再指向别的东西」提到了声明的第一个词:val 只能赋值一次,var 可以反复赋值。这是全语言最先要建立的直觉,也是最容易被误解成「val = 不可变」的地方——它锁的是引用,不是引用指向的那个对象。

val 锁引用,不锁内容

  • val list = mutableListOf(1, 2, 3) 之后 list.add(4) 完全合法,打印出 [1, 2, 3, 4];被拒绝的只有 list = mutableListOf() 这种重新指向的写法;
  • 想让「内容也别动」,光靠 val 不够,还得选一个只读类型:val list: List<Int> = ...。但「只读」仍然不等于「不可变」——持有原始 MutableList 引用的人照样能改,细节见 07 章;
  • 一句话总结:val 管的是变量名,类型管的是能对对象做什么,两件事要分开选。

和 Java final 比,哪里一样、哪里不一样

  • 一样:都只禁止「重新赋值」,都不保证对象内部不变;
  • 不一样(一):Kotlin 的 val 属性可以带自定义 getter,于是每次读到的值都可能不同——它保证的是「没有 setter」,不是「值恒定」(属性与 getter 见 06 章);
  • 不一样(二):Kotlin 的局部 var 能被闭包捕获并修改,Java 要求被 lambda 捕获的局部变量必须是 final 或 effectively final。javap 可以看到,被捕获并修改的 var count 被编译器包进了 kotlin.jvm.internal.Ref$IntRef(见 04 章的局部函数卡);
  • 不一样(三):val 是默认姿势而不是额外修饰——写 var 是在向读者宣告「这里真的需要变」。

什么时候老老实实用 var

  • 循环累加器、状态机的当前状态、需要延后初始化又不方便用 lateinit / by lazy 的场景;
  • 硬把 var 改写成一串 val 中间变量(val a1、val a2…)反而更难读——可读性优先于教条。
val name = "Kotlin"      // 类型推断为 String
var count = 0              // 可重新赋值
count++                       // ✅ 合法
// name = "Java"            // ❌ val cannot be reassigned

// —— val 锁的是引用,不是对象(输出 [1, 2, 3, 4])——
val list = mutableListOf(1, 2, 3)
list.add(4)                  // ✅ 引用没变,内容变了
println(list)                  // [1, 2, 3, 4]
// list = mutableListOf()   // ❌ val cannot be reassigned

// 想连内容一起管住:换只读类型(只读 ≠ 不可变,见集合章)
val ro: List<Int> = listOf(1, 2, 3)
// ro.add(4)                // ❌ List 上根本没有 add

// 显式类型声明(public API 建议写出来)
val pi: Double = 3.14159
val msg: String = "Hello"
两个真会栽的地方。其一,val list: List<String> = mutableListOf() 看起来「双保险」,其实只是你手上这个引用没有 add——底层实例仍是可变的,把它交出去或强转回 MutableList 一样能改(07 章有)。其二,val 属性配自定义 getter 时,两次读取可以返回不同的值:val now: Long get() = System.currentTimeMillis() 是合法的 val。所以看到 val 不要直接推断「这值不会变」,要看它是局部变量(那确实定死了)还是属性。
默认写 val,需要改再退回 var——不是为了「函数式纯洁」,而是因为一个不会被重新赋值的名字,读代码时只要看它的初始化那一行就够了,不必扫完整个函数找「它后来变成了什么」。配合只读类型 List / Map(而不是 MutableList)作为函数参数与返回类型,能把「谁能改这个数据」这件事写进类型签名里。

Kotlin 源码层面一切皆对象——Int 是个类,能调 .toLong()、能当泛型实参写 List<Int>。但编译到 JVM 之后,能用原始类型的地方一律用原始类型:val i: Int = 42 的运行时类型就是 int,只有可空的 val ni: Int? = 42 才装箱成 java.lang.Integer。「写起来像对象、跑起来是原始类型」是理解本章后半段所有怪现象的钥匙。

基本类型与它们的 JVM 表示

Kotlin 类型不可空时的 JVM 表示可空 / 作泛型实参时
Intintjava.lang.Integer
Longlongjava.lang.Long
Double / Floatdouble / floatjava.lang.Double / Float
Booleanbooleanjava.lang.Boolean
Charcharjava.lang.Character
IntArrayint[](javap 里写作 [I)—
Array<Int>java.lang.Integer[]元素永远是装箱的
Stringjava.lang.String同左

要装一堆数字又在意开销时,选 IntArray 而不是 Array<Int>——后者每个元素都是对象。

不隐式拓宽:字面量和变量是两套规则

  • val x: Long = 42 合法——整数字面量按期望类型做静态检查,装得下就行(同理 val b: Byte = 1);
  • val n: Int = 42; val wide: Long = n 报错,报错原文:initializer type mismatch: expected 'Long', actual 'Int'.——变量之间不会自动拓宽,必须 n.toLong();
  • 为什么这么设计:Java 的隐式拓宽配上重载解析,是一类经典的「调到了另一个重载」事故;Kotlin 干脆把转换写在明面上。代价就是这条规则要背下来。

装箱缓存:可空数字千万别比引用

  • JVM 对 -128..127 的 Integer 有缓存,所以两个装箱的 Int? 是不是同一个对象取决于值的大小;
  • Kotlin 2.4 已经禁止你直接踩这个坑:val a: Int? = 1; val b: Int? = 1; a === b 会给出编译警告,报错原文 identity equality for arguments of types 'Int?' and 'Int?' is prohibited.;
  • 绕过警告强行观察(a as Any? === b as Any?)能看到结果:127 时为 true,128 时为 false——而 == 两次都是 true;
  • 结论很简单:数字一律用 ==。=== 留给「我确实要问是不是同一个对象」的场景。
val i: Int = 42
val l: Long = i.toLong()     // 必须显式转换
val d: Double = 3.14
val f: Float = 3.14f
val b: Boolean = true
val c: Char = 'K'

// 数字字面量的写法
val hex = 0xFF              // 十六进制
val bin = 0b1010            // 二进制
val big = 1_000_000         // 下划线只是给人看的

// 字面量按期望类型静态检查:42 直接赋给 Long 合法
val x: Long = 42

// 但变量之间不会隐式拓宽
val n: Int = 42
// val wide: Long = n
//   ❌ initializer type mismatch: expected 'Long', actual 'Int'.
val wide: Long = n.toLong()  // ✅

// —— 装箱缓存:可空数字比引用会出错——
val p: Int? = 127;  val q: Int? = 127
val r: Int? = 128;  val s: Int? = 128
println(p == q)                 // true
println(r == s)                 // true —— 用 == 永远对
// println(p === q)  ⚠️ 警告:identity equality for arguments
//                     of types 'Int?' and 'Int?' is prohibited.
println((p as Any?) === (q as Any?))  // true (127 在缓存里)
println((r as Any?) === (s as Any?))  // false(128 不在)

// —— 无符号类型——
val u: UInt = 4_000_000_000u   // Int 装不下,UInt 可以
println(UInt.MAX_VALUE)         // 4294967295
println(0u - 1u)                // 4294967295(回绕,不报错)
println(u.toInt())              // -294967296(同样的 bit,换个解释)

// —— 溢出与精度:静默发生,不抛异常——
println(Int.MAX_VALUE + 1)      // -2147483648
println(0.1 + 0.2)              // 0.30000000000000004
println(3.9.toInt())             // 3  —— 截断,不是四舍五入
println((-3.9).toInt())          // -3 —— 向零截断
数字的三个静默事故,全部验证过:①溢出不报错,Int.MAX_VALUE + 1 直接回绕成 -2147483648(算时间戳、累加计数时用 Long);②浮点不精确,0.1 + 0.2 是 0.30000000000000004,钱一律别用 Double;③toInt() 是截断不是四舍五入,3.9.toInt() 得 3、(-3.9).toInt() 得 -3(向零截断,要四舍五入用 roundToInt())。这三条都不会有任何警告,只会让结果悄悄错掉。
UInt / ULong / UByte / UShort 用于把位模式当无符号数解释的场合(协议解析、哈希、位运算)。它们不是「更大的整数」——UInt 照样 32 位,0u - 1u 得 4294967295(回绕而不是报错)。日常业务算数别用它,类型间不能隐式互转会传染到整条调用链;需要更大范围就上 Long,需要精确小数就上 java.math.BigDecimal。

Kotlin 的字符串插值不是运行时的格式化函数,而是编译期展开成拼接的语法——所以它没有「格式串和参数对不上」这种运行时事故。配上三引号原始字符串,SQL、JSON、多行文本都能直接贴进源码。

两种插值形态

  • $name:后面直接跟标识符时用,最常见;
  • ${expr}:要放表达式(方法调用、属性链、算术)时必须加花括号——"${name.length}" 而不是 "$name.length"(后者会被解析成「插入 name,再跟一个字面 .length」,这是最高频的输出错乱来源);
  • 插值也能用在原始字符串里,所以 JSON 模板可以直接写 "name": "$name"。

trimIndent() 与 trimMargin():两套裁剪方案,别混用

  • trimIndent():自动算出所有非空行的公共前导空白并整体去掉,同时丢弃首尾的空行(开头留一个空行、结尾留一个空行的原始字符串,结果里都不见了);
  • trimMargin():按你手写的边界符(默认 |)裁剪,每行的 | 及其左边的空白被去掉——好处是行首的相对缩进能自己控制;
  • 两者不通用:把 trimIndent() 用在带 | 的字符串上,得到的是 |SELECT * / |FROM t——竖线原样留在结果里。要么全用缩进 + trimIndent(),要么全用竖线 + trimMargin()。

怎么输出一个字面的 $

  • 普通字符串里可以转义:"cost: \$$price";
  • 原始字符串不认转义(\n 在里面就是反斜杠加 n),要打出 $ 只能用插值一个字符串字面量的写法:${'$'};
  • 两种写法都输出 cost: $5。写 shell 脚本模板、正则、Kotlin 代码生成器时,这个技巧躲不掉。
val name = "Kotlin"
val price = 5

// —— 插值的两种形态 ——
println("Hello, $name!")          // 标识符直接跟
println("Length: ${name.length}")  // 表达式必须加 {}
println("$name.length")           // ⚠️ 输出 "Kotlin.length"

// —— 输出字面的 $(两种写法,都是 cost: $5)——
println("cost: \$$price")         // 普通串:反斜杠转义
println("""cost: ${'$'}$price""")  // 原始串:插一个 '$' 字符

// —— 原始字符串 + trimIndent:去公共缩进,并丢掉首尾空行 ——
val json = """
    {
      "name": "$name"
    }
""".trimIndent()
// 结果(3 行,无前导空格):
// {
//   "name": "Kotlin"
// }

// —— trimMargin:按 | 裁剪,缩进自己控制 ——
val sql = """
    |SELECT *
    |FROM users
    |WHERE active = true
""".trimMargin()

// ⚠️ 混用的结果:竖线会被原样留下
val oops = """
    |SELECT *
    |FROM users
""".trimIndent()
// oops == "|SELECT *\n|FROM users"
"$obj.prop" 不会取属性。$ 后面只吃一个标识符,.prop 会被当成普通字符输出——"$name.length" 打印的是 Kotlin.length 而不是 6。凡是带点、带括号、带下标的,一律写 ${...}。还有一个是原始字符串里的转义序列全部失效:"""a\nb""" 得到的是 5 个字符的 a\nb 而不是两行,想在原始字符串里换行就真的换行,想要制表符只能 ${'\t'}。
绝大多数「格式化」需求用模板就够了,String.format 只在真的要控制宽度与小数位时才值得:"%.2f".format(x)、"%-10s".format(s)。模板是编译期展开的,参数个数和类型天然对得上;format 的格式串对不上参数则要到运行时才抛异常——能用模板就别用它。多行文本首选原始字符串 + trimIndent(),缩进跟着代码走,改动时不会破坏排版。

这里管两个「看起来很像、其实什么都没做」的关键字:const val 把值内联到调用点,typealias 把类型名换个写法——两者都在编译后消失,理解它们「不做什么」比理解它们做什么更重要。

const val 的三条限制(报错原文)

  • 位置:只能在顶层、具名 object、companion object 里。写进普通 class 报错:const 'val' is only allowed on top level, in named objects, in companion objects or companion blocks.;
  • 类型:只能是基本类型或 String。const val d = Date() 报错:const 'val' has type 'Date'. Only primitive types and 'String' are allowed.;
  • 值:必须编译期可知(字面量、其他 const 的算术、字符串拼接),任何函数调用都不行。

为什么值得区分 const val 和 val

  • const val 会被内联到每个使用它的地方——所以它才能出现在注解参数、when 的常量分支这些「必须编译期确定」的位置;
  • 代价是老问题:改了常量值,只重编译定义它的模块是不够的,依赖方还留着旧的字面量(Java 的 static final 一样如此)。跨模块发布的常量,值会变的那些用普通 val 更安全;
  • 普通 val(顶层或 object 里)编译成带 getter 的静态属性,运行时读取,改了就生效。

typealias 不是新类型

  • 它只是个别名:typealias UserId = String 之后,UserId 和 String 可以互相赋值,把订单号传进要用户 ID 的参数里,编译器一声不吭;
  • 它的价值只在可读性:把 (View) -> Unit 写成 OnClick、把 Map<String, List<Order>> 写成 OrdersByUser;
  • 真想要「编译器帮我拦住传错」的新类型,用 @JvmInline value class——它在类型系统里是独立类型,运行时又擦除成底层类型(它的 JVM 方法签名会变成参数擦除 + 方法名带哈希后缀的形态),详见 13 章。
// —— 编译期常量:顶层 / object / companion object ——
const val MAX_RETRY = 3
const val API_URL = "https://api.example.com"
const val FULL = API_URL + "/v1"   // ✅ const 之间可拼接

// ❌ const val d = Date()
//   const 'val' has type 'Date'. Only primitive types and 'String' are allowed.

class Holder {
    // ❌ const val INSIDE = 1
    //   const 'val' is only allowed on top level, in named objects,
    //   in companion objects or companion blocks.
    companion object {
        const val INSIDE = 1       // ✅ 放这儿就行
    }
}

// —— 类型别名:只换写法,不换类型 ——
typealias UserMap = Map<String, List<String>>
typealias Predicate<T> = (T) -> Boolean
typealias UserId = String

fun load(id: UserId) { /* ... */ }
val orderNo: String = "ORD-1"
load(orderNo)   // ⚠️ 编译通过!别名拦不住传错

fun filterUsers(users: UserMap): UserMap =
    users.filterValues { it.isNotEmpty() }
typealias 给人的安全感是假的。typealias UserId = String 之后,任何 String 都能传进 fun load(id: UserId)——包括订单号、手机号、用户输入的原始文本。IDE 里看到参数写着 UserId 很容易误以为编译器在把关,实际一点检查都没有。判断标准很简单:你想要的是「读起来清楚」还是「传错了编译不过」?后者只能用 value class(13 章)。另外 const val 的内联特性还有个隐蔽后果:在单元测试里改不掉它——它在使用处已经是字面量了,任何反射、mock 都够不着。
什么时候用 typealias:类型签名长到读不下去时(嵌套泛型、函数类型),或者想给一个函数类型起个业务名字。什么时候不要用:想靠它做「类型安全」的时候——那是 value class 的活。另外 typealias 只能写在顶层,不能定义在类或函数内部。跨模块的字符串常量,如果它是「协议的一部分、几乎不会改」(如请求头名字),用 const val;如果它是「配置、可能随版本调整」,用普通 val 避免内联带来的陈旧值。

Kotlin 的类型推断很强,强到容易让人以为它是动态类型——它不是。推断只发生在编译期,而且只发生一次:变量的类型在初始化那一行就定死了,此后再赋值只会撞上类型检查。这里把「什么时候可以省、什么时候必须写、省错了会看到什么报错」一次说清。

推断失败的四种典型报错(全部于 2.4.10)

你写的编译器说怎么修
var x = 1
后面 x = "hi"
assignment type mismatch: actual type is 'String', but 'Int' was expected.类型在第一行就定死了;真要装两种东西写 var x: Any = 1
var y = null
后面 y = "s"
assignment type mismatch: actual type is 'String', but 'Nothing?' was expected.裸 null 推断成 Nothing?(唯一的值就是 null);写 var y: String? = null
val empty = listOf()cannot infer type for type parameter 'T'. Specify it explicitly.右边没有任何线索;写 listOf<String>() 或 val empty: List<String> = listOf()
fun fact(n: Int) = if (n <= 1) 1 else n * fact(n - 1)type checking has run into a recursive problem. Easiest workaround: specify the types of your declarations explicitly.递归函数用表达式体时必须写返回类型:fun fact(n: Int): Int = ...

什么时候该主动写出类型

  • public API 的返回类型:推断出来的类型是实现细节泄漏成契约——把 listOf(...) 换成 mutableListOf(...),函数的公开返回类型就跟着变了,调用方可能悄悄编译不过。库项目可以开显式 API 模式让编译器强制这一点;
  • 想要的类型和推断的不同:val ids = mutableListOf<Long>()、val cfg: Map<String, Any?> = ...;
  • 数字字面量要窄类型或宽类型时:val b: Byte = 1、val big: Long = 1(不写就是 Int);
  • 初始化表达式很长时,写出类型比让读者推演一遍链式调用更友好。

lambda 参数与 Nothing

  • lambda 的参数类型由期望类型反向推断:list.map { it.length } 里 it 是 String 是因为 map 的签名说了算;一旦没有期望类型(比如 val f = { x -> x + 1 }),就必须自己标注 { x: Int -> x + 1 };
  • TODO() 返回 Nothing,而 Nothing 是所有类型的子类型——所以 fun area(): Double = TODO() 能编译通过,让你先把架子搭起来。调用时抛 kotlin.NotImplementedError: An operation is not implemented: ...;
  • Nothing 为什么能这样、还能怎么用,见 03 章的「返回、跳转与 Nothing」卡。
// —— 推断一次定死 ——
var x = 1            // 推断为 Int
// x = "hello"
//   ❌ assignment type mismatch: actual type is 'String',
//      but 'Int' was expected.

var y = null         // 推断为 Nothing?(唯一的值就是 null)
// y = "s"      ❌ ... but 'Nothing?' was expected.
var ok: String? = null  // ✅ 这才是你想要的

// —— 右边没线索时,推断不出来 ——
// val empty = listOf()
//   ❌ cannot infer type for type parameter 'T'. Specify it explicitly.
val empty = listOf<String>()          // ✅ 写死类型实参
val empty2: List<String> = listOf()   // ✅ 或者由左边提供期望类型

// —— 递归 + 表达式体:必须写返回类型 ——
// fun fact(n: Int) = if (n <= 1) 1 else n * fact(n - 1)
//   ❌ type checking has run into a recursive problem.
fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1)

// —— lambda 参数:有期望类型才能省 ——
val lens = listOf("a", "bb").map { it.length }  // it: String,由 map 决定
val inc: (Int) -> Int = { it + 1 }            // 左边给了期望类型
val inc2 = { n: Int -> n + 1 }                 // 没期望类型 → 自己标注

// —— TODO() 返回 Nothing,能顶替任何类型 ——
fun area(): Double = TODO("等几何库接进来")
// 调用时抛:
//   kotlin.NotImplementedError: An operation is not implemented: 等几何库接进来

// —— public API:把类型写出来,别让实现细节变成契约 ——
fun activeIds(): List<Long> = mutableListOf(1L, 2L)  // 返回类型是 List,不是 MutableList
最隐蔽的一个是 var y = null:它不报错,推断出来的类型是 Nothing?——这个类型唯一能装的值就是 null,所以后面任何赋值都会失败,报错写着 but 'Nothing?' was expected,第一次见很难联想到「问题出在初始化那行」。同类的还有 val list = mutableListOf():报错指向 listOf() 那行说推不出 T,而你以为问题在后面的 add。凡是初始化值本身不携带类型信息(null、空集合、空数组),就把类型写出来。
一条好用的分界线:函数体内部尽量省,跨越函数边界的一律写出来。局部变量的类型 IDE 一悬停就看到、改起来也没人依赖;而参数与返回类型是别人读你代码时唯一的说明书,也是二进制兼容的边界。写库的话可以在 Gradle 里开 explicitApi(),编译器会强制所有公开声明写出返回类型和可见性——一次性把这条规矩变成机器检查。

控制流

本章覆盖:if 与 when 都是表达式(有返回值)、when 的六种形态与穷尽性、范围与循环(没有 C 风格三段式)、标签跳转与 Nothing、控制台与文件 I/O。

Kotlin 控制流与 Java 最大的区别只有一条:if 和 when 是表达式,有返回值。由此 Java 里那些「先声明变量再在分支里赋值」的样板全部消失。

if 与 when

写法例子说明
if 作表达式val max = if (a > b) a else bKotlin 没有三元运算符,用这个代替
多行分支取值val x = if (c) { doA(); 1 } else 2块的最后一个表达式就是它的值
when 带参数when (x) { 1 -> "一"; 2, 3 -> "小"; else -> "多" }逗号并列多个值
范围与集合in 1..9 -> … · !in list -> …—
类型分支is String -> x.length分支内自动智能转换,不用再强转
when 不带参数when { a > 10 -> …; b.isEmpty() -> …; else -> … }取代一长串 else-if
guard 条件is String if x.isEmpty() -> …Kotlin 2.x:同一类型再按条件分流
绑定主语when (val r = load()) { … }省掉外面一行声明
穷尽性作表达式用时必须覆盖所有情况对 sealed 与 enum,补齐分支而不是加 else——将来新增类型时编译器会提醒你(06 章)

循环与范围

要做什么怎么写
0 到 9for (i in 0..9)(闭区间,含 9)· for (i in 0..<10) / until 10(不含)
倒着走for (i in 9 downTo 0)
步长for (i in 0..100 step 5)
遍历集合for (x in list)
带下标for ((i, x) in list.withIndex()) · list.forEachIndexed { i, x -> }
遍历 Mapfor ((k, v) in map)
遍历字符for (c in "abc") · for (c in 'a'..'z')
重复 n 次repeat(3) { println(it) }
条件循环while (c) { } · do { } while (c)
判断包含if (x in 1..10) · if (s in list)——in 就是 contains
没有 C 风格 forfor (int i = 0; …) 不存在,全部走「遍历某个东西」

跳转、标签与 Nothing

写法作用
break / continue只作用于最内层循环
标签outer@ for (…) { for (…) { break@outer } }
return@forEach从 lambda 返回——裸 return 在 lambda 里会返回整个外层函数(inline 的情况下)
return@run / return@let同理,作用域函数里常见(08 章)
Nothing「永不返回」:throw 与 exitProcess 的类型
它的用处能出现在任何类型的位置,所以 val x = y ?: throw E() 和 ?: return 才写得通
Unit「没有有意义的返回值」,相当于 void;可以省略不写
TODO()返回 Nothing 的占位,编译得过、跑到就抛——写骨架时很好用

控制台与文件 I/O

要做什么怎么写
输出println(x) · print(x) · System.err.println(x)
读一行readln()(EOF 抛异常)· readlnOrNull()(返回 String?)
读全部输入generateSequence(::readlnOrNull)
整个文件File("a.txt").readText() · writeText(s) · appendText(s)
按行File("a.txt").readLines()(全进内存)· useLines { it.filter { … }.count() }(流式,大文件用它)
自动关闭File(p).bufferedReader().use { … }——use 等价于 try-with-resources
编码默认 UTF-8;显式写 readText(Charsets.UTF_8)
路径File(dir, "a.txt") · Path("a", "b");别手拼分隔符
存在与创建f.exists() · f.mkdirs() · f.delete()
// ① when 作表达式:一次搞定判断 + 取值 + 类型转换
fun describe(x: Any?): String = when (x) {
    null            -> "空"
    is String if x.isEmpty() -> "空串"          // 2.x guard
    is String       -> "字符串,长 ${x.length}"       // 这里 x 已是 String
    in 1..9         -> "个位数"
    is List<*>       -> "列表,${x.size} 项"
    else            -> "其他"
}

// ② 不带参数的 when:取代一长串 else-if
val level = when {
    score >= 90 -> "优"
    score >= 60 -> "及格"
    else        -> "不及格"
}

// ③ 范围与遍历:没有 C 风格 for
for (i in 0..<list.size) { }              // 不含 size
for ((i, x) in list.withIndex()) { }        // 带下标
for (i in 10 downTo 1 step 2) { }          // 10 8 6 4 2
repeat(3) { println(it) }                    // 0 1 2

// ④ lambda 里的 return:裸 return 会返回外层函数
fun find(xs: List<Int>) {
    xs.forEach {
        if (it < 0) return@forEach            // 跳过这一项(相当于 continue)
        if (it == 99) return                   // 直接结束整个 find
    }
}

// ⑤ 大文件按行处理:useLines 流式,不把整个文件读进内存
val count = File("big.log").useLines { lines ->
    lines.filter { "ERROR" in it }.count()
}
0..9 是闭区间,含 9——和 Python 的 range(10) 不一样。要「小于 n」写 0..<n 或 until n,用错就是经典的差一错误。
lambda 里的裸 return 返回的是外层函数(非局部返回,只有 inline 函数允许)。想只结束这一次迭代必须写 return@forEach;这一条在 let / run 里同样成立(08 章)。
把 when 当返回值的来源而不是分支语句来用:val x = when (…) { … } 这种写法能消掉大量「先声明再赋值」的样板,也让编译器帮你检查有没有漏分支。
需要「跳过这一项」时写 return@forEach,不要为此把 forEach 改回 for。

Kotlin 没有三元运算符 ?:(那个符号在 Kotlin 里是 Elvis,含义完全不同)——因为不需要:if 本身就是表达式,能直接赋值、直接返回。when 则是加强版的 switch:不需要 break、不限于常量、还能匹配类型和范围。「分支能求值」这一条会渗透进后面所有代码风格。

表达式还是语句,差别在哪

  • val max = if (a > b) a else b——这就是 Kotlin 版的三元;
  • 当 if / when 被当作表达式使用(赋值、作为函数体、作为 return 的值)时,else 分支必须存在,否则「条件不成立时这个表达式等于什么」无解;
  • 当它只是语句时(不取值,纯粹执行副作用),else 可以省;
  • 分支体是代码块时,块的最后一个表达式就是它的值——不用 return(在 if/when 里写 return 是从整个函数返回,语义完全不同)。

when 的四种形态(都编译运行过)

形态写法适用场景
带主体 + 常量when (code) { 200 -> ...; 301, 302 -> ... }最接近 switch;逗号分隔即「或」
带主体 + inin 400..499 -> ... / !in list -> ...区间与集合成员判断
带主体 + isis String -> obj.length类型分派;命中后自动智能转换,分支里直接用具体类型的成员
无主体when { score >= 90 -> "A"; ... }替代 if-else 链;每个分支是任意布尔表达式

guard conditions:is X if 条件 ->

  • 过去「先按类型分,再按属性细分」只能写成嵌套 when 里套 if;现在可以写 is Circle if s.r > 10 -> ...,一行搞定;
  • 在 Kotlin 2.4.10 上不需要任何 opt-in 编译参数,直接编译通过;
  • 注意顺序:分支自上而下匹配,第一个命中就停——带 guard 的分支必须写在同类型的兜底分支上面,否则永远轮不到它。

穷尽性只在「当表达式用」时才强制

  • 对 sealed 类型做 when 表达式时,编译器会检查是否覆盖了所有子类型——覆盖全了就不用写 else(四个分支盖住 Circle/Rect 的两种情况后,不写 else 也编译通过);
  • 真正决定要不要穷尽的是主体的类型,不是你把 when 当表达式还是当语句用:sealed / enum 主体两种用法都强制穷尽(漏一个分支就报 'when' expression must be exhaustive. Add the 'is Triangle' branch or an 'else' branch.);而无主体的 when { n > 3 -> … } 和 Int/String 这类开放类型主体都不检查,条件不满足就什么都不做;
  • 所以「加了新的 sealed 子类,某处静默漏处理」只会发生在你用了 else 兜底、或者主体不是 sealed 的时候。sealed 与穷尽性的完整用法见 06 章。
// —— if 是表达式:Kotlin 的「三元」——
val max = if (a > b) a else b

// 分支是块时,块的最后一个表达式就是值
val level = if (score >= 60) {
    println("及格")
    "pass"            // ← 这就是 level 的值,不写 return
} else "fail"

// —— when:带主体的三种匹配 ——
val desc = when (code) {
    200 -> "OK"
    301, 302 -> "Redirect"          // 逗号 = 或
    in 400..499 -> "Client Error"   // 范围
    else -> "Unknown"
}

fun classify(x: Any): String = when (x) {
    is String -> "String(${x.length})"  // 命中后 x 自动是 String
    in 1..9 -> "small int"
    !is Int -> "not int"
    else -> "big int"
}

// —— when 无主体:替代 if-else 链 ——
val label = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    else -> "F"
}

// —— guard conditions(2.4.10 无需 opt-in)——
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape

fun describe(s: Shape): String = when (s) {
    is Circle if s.r > 10 -> "big circle"   // 带 guard 的要写在上面
    is Circle -> "small circle"
    is Rect if s.w == s.h -> "square"
    is Rect -> "rect"
}                        // ← sealed 覆盖全,无需 else(通过)
when 当语句用时不检查穷尽性,这是 sealed 类型最常见的漏网方式:when (shape) { is Circle -> ...; is Rect -> ...; else -> {} } 里那个随手写的 else,会把编译器的穷尽性检查整个关掉——明天有人加了 Triangle,这段代码照样编译通过,只是三角形被 else 静默吃掉。对 sealed 主体不要写 else,让编译器在新增子类时逼你去每一处补分支,这正是 sealed 的价值所在。另一处是 guard 分支的顺序:把 is Circle -> 写在 is Circle if r > 10 -> 前面,后者永远不会命中,而编译器不会提醒你。
能写成表达式就写成表达式。val x = when (...) { ... } 比「先声明 var x、再在各分支里赋值」好在两点:变量能是 val,而且编译器会强制你处理所有情况。对 sealed 类型尤其如此——只要主体是 sealed 且不写 else,就等于给「以后新增子类」装了一个编译期报警器(表达式和语句都一样,见 06 章)。另外 when 的主体可以带初始化:when (val r = compute()) { ... },r 只在 when 内可见。

Kotlin 没有 C 风格的三段式 for。所有循环都是「遍历某个东西」:范围、集合、字符串、任何提供了 iterator() 的类型。这个限制换来的是——下标越界这类错误,绝大多数可以从源头消掉,前提是你选对了范围构造。

范围构造对照(结果均为)

写法结果说明
1..51 2 3 4 5闭区间,含两端
1..<5 (等价 1 until 5)1 2 3 4半开区间,不含右端;遍历下标就用它
5 downTo 15 4 3 2 1递减必须用它
1..10 step 3[1, 4, 7, 10]步长;step 必须为正
5..1[](空)不会自动倒序,直接是空区间——想倒序只能 downTo
'a'..'e'[a, b, c, d, e]字符也能构成范围
list.indices0 .. size-1遍历下标的首选,天然不越界
0.0..1.0不能 for浮点范围只能用于 in 判断,没有迭代器

for 只能遍历「有 iterator() 的东西」

  • for (x in something) 会被编译成对 something.iterator() 的调用——成员函数或扩展函数都行;
  • 所以给任何自定义类加一个 operator fun iterator(),它就能被 for 遍历;
  • 遍历一个不支持的东西,报错信息会有点出人意料——for (ch in 42) 报的是 method 'iterator()' is ambiguous for this expression,还附带列出了标准库里所有 iterator() 扩展的候选。看到这个报错,真正的意思是「这个类型根本没有 iterator」。

标签:多层循环里的精确跳转

  • break / continue 默认只作用于最内层循环;
  • 给外层循环打标签 outer@ for (...),就能 break@outer / continue@outer 一次跳出多层;
  • 标签的名字随便起,@ 在定义处写在后面(outer@)、使用处写在前面(break@outer)——这个方向很容易记反;
  • lambda 里的 return@label 是另一回事(不是循环跳转),见本章后面。
// —— 范围(结果为输出)——
for (i in 1..5) print(i)          // 12345(闭区间)
for (i in 1..<5) print(i)         // 1234(半开,等价 1 until 5)
for (i in 5 downTo 1) print(i)   // 54321
println((1..10 step 3).toList())   // [1, 4, 7, 10]
println((5..1).toList())            // [] ← 空!倒序必须用 downTo
println(('a'..'e').toList())        // [a, b, c, d, e]
println(0.5 in 0.0..1.0)           // true(浮点范围只能这么用)

// —— 遍历下标:用 indices 或 ..< ,别用 ..size ——
val list = listOf("a", "b", "c")
for (i in list.indices) print(list[i])   // ✅ abc
for (i in 0..<list.size) print(list[i]) // ✅ abc
// for (i in 0..list.size) print(list[i])
//   ❌ 抛 java.lang.ArrayIndexOutOfBoundsException:
//      Index 3 out of bounds for length 3

// 大多数时候连下标都不需要
for (s in list) print(s)
for ((i, s) in list.withIndex()) println("$i: $s")

// —— 没有 C 风格 for,重复固定次数用 repeat ——
repeat(3) { i -> print(i) }        // 012

// —— while / do-while 照旧 ——
var n = 3
while (n > 0) { print(n); n-- }
do { print(n) } while (n > 0)

// —— 标签跳转:定义处 outer@,使用处 break@outer ——
outer@ for (i in 1..3) {
    for (j in 1..3) {
        if (i == 2 && j == 2) break@outer
        print("$i$j ")
    }
}                                     // 11 12 13 21
for (i in 0..list.size) 必定越界。.. 是闭区间,会一路数到 size 本身。在 listOf("a","b","c") 上跑,第四次迭代抛 java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3(注意异常类型是 ArrayIndexOutOfBoundsException——listOf 底层是数组包装,不是你以为的 IndexOutOfBoundsException,按后者去搜索会走弯路)。正确写法是 0..<list.size 或 list.indices。还有一个是 5..1 不会倒序:它是空区间,循环体一次都不执行,也不报错——「循环莫名其妙没跑」十有八九是这个,倒序必须写 5 downTo 1。
三条选择规则:① 只要不需要下标,就 for (item in list);② 需要下标就 list.indices(或 withIndex() 一次拿到两者);③ 只有真的要「从 m 数到 n」时才手写范围,而且优先 ..<。..< 是 until 的运算符形式,两者完全等价,新代码建议用 ..<(读起来和 < 一致,不容易看成闭区间)。范围对象本身是值,可以先存起来再遍历、也可以用 in 做判断——if (code in 200..299) 比两个比较式清楚得多。

return 在普通函数里没什么可讲的,一旦进了 lambda 就完全变了味:能不能写、写了从哪里返回,取决于那个接收 lambda 的函数是不是 inline 的。下面把 Kotlin 里所有「提前离开」的手段串起来,顺便讲清 Nothing 这个看着诡异、实则处处都在用的类型。

lambda 里的 return:inline 与否,天差地别

  • 非 inline 函数的 lambda 里写裸 return,直接编译不过。报错原文:'return' is prohibited here.——因为那个 lambda 会被编译成一个对象,它的 return 无处可返;
  • inline 函数的 lambda 里可以写裸 return,而且它是非局部返回:直接从外层函数返回。在 inline fun myInline(xs, f) 里对 listOf(1,2,3) 遍历、命中 2 时 return "early",外层函数当场返回 "early",后面的语句不再执行;
  • 标准库的 forEach / let / run / apply 都是 inline 的,所以在它们里面写 return 会跳出整个函数——这既是惯用法(提前退出),也是高频事故(以为只是跳过这一项);
  • inline 到底做了什么、为什么它能支持这个,见 09 章。

标签返回:return@forEach 才是「continue」

  • 想「跳过这一项、继续遍历」,要写带标签的返回:return@forEach;
  • 标签默认就是函数名:return@forEach、return@map、return@let;也可以自己起:xs.forEach loop@ { ... return@loop };
  • 对 listOf(1,2,3) 用 forEach { if (it == 2) return@forEach; sb.append(it) },结果是 "13"——2 被跳过,遍历继续。

Nothing:一个没有实例的类型

  • Nothing 没有任何值,所以「一个类型为 Nothing 的表达式」只可能意味着:它根本不会正常求值完成(抛异常或永不返回);
  • 正因为它不可能有值,把它定义成所有类型的子类型是安全的——于是 Nothing 表达式可以出现在任何期望某类型的位置,这就是 val s: String = if (x) fail("...") else "ok" 能编译的原因;
  • throw 在 Kotlin 里是表达式,类型就是 Nothing;标准库的 TODO()、error() 返回类型也是 Nothing。error("boom") 抛 java.lang.IllegalStateException: boom,TODO("later") 抛 kotlin.NotImplementedError: An operation is not implemented: later;
  • Nothing? 则是只装得下 null 的类型——这正是 var y = null 推断出来的东西(见 02 章)。

Elvis 右侧放 return / throw:最常用的守卫写法

  • val v = map["k"] ?: return null、val v = map["k"] ?: throw IllegalArgumentException("missing key")——因为 return 和 throw 的类型都是 Nothing,它们能合法地充当 Elvis 的右操作数;
  • 结果是:这一行之后 v 已经是非空类型,不用再判空。这是 Kotlin 里替代「层层嵌套 if」的标准姿势,05 章会展开。
// —— 非 inline 的 lambda 里不能裸 return ——
fun <T> List<T>.myForEach(action: (T) -> Unit) {
    for (e in this) action(e)
}
fun bad(xs: List<Int>) {
    xs.myForEach {
        // if (it == 2) return   ❌ 'return' is prohibited here.
    }
}

// —— inline 的 lambda 里可以,而且是「非局部返回」——
inline fun <T> myInline(xs: List<T>, f: (T) -> Unit) { for (x in xs) f(x) }

fun find2(xs: List<Int>): String {
    myInline(xs) { if (it == 2) return "早退" }  // 从 find2 返回!
    return "跑完了"
}
// find2(listOf(1,2,3)) == "早退";find2(listOf(9)) == "跑完了"

// —— 想「跳过这一项」:带标签返回 ——
val sb = StringBuilder()
listOf(1, 2, 3).forEach {
    if (it == 2) return@forEach     // = continue,不是退出函数
    sb.append(it)
}
println(sb)                          // 13

// —— Nothing:没有实例,因此是所有类型的子类型 ——
fun fail(msg: String): Nothing = throw IllegalStateException(msg)

val s: String = if (list.isEmpty()) fail("空的") else "ok"
// 编译器知道 fail() 不会返回 → 整个 if 的类型仍是 String

// 标准库里返回 Nothing 的三个常客(异常类型均为)
// TODO("later") → kotlin.NotImplementedError: An operation is not implemented: later
// error("boom") → java.lang.IllegalStateException: boom
// throw ...     → 本身就是 Nothing 类型的表达式

// —— Elvis 右侧放 return / throw:守卫写法 ——
fun firstOrDefault(xs: List<Int>): Int {
    val v = xs.firstOrNull() ?: return -1   // 这行之后 v 是 Int,不是 Int?
    return v
}
fun load(map: Map<String, String>): String =
    map["key"] ?: throw IllegalArgumentException("missing key")
// load(emptyMap()) 抛 java.lang.IllegalArgumentException: missing key
forEach { ... return } 退出的是整个函数,不是这一轮。因为 forEach 是 inline 的,裸 return 合法且是非局部返回——「命中就 return」会让外层函数当场结束,后面的代码一行都不跑。想跳过单项必须写 return@forEach。反过来,在自己写的、没加 inline 的高阶函数里做同样的事,会撞上 'return' is prohibited here.——同一段代码在两种函数里一个静默改变控制流、一个直接编译失败,这是最容易困惑的一处。判断方法:按住跳转到那个函数的声明,看有没有 inline。
区分三个「返回」的速记:return = 退出最近的具名函数(在 inline lambda 里就是外层那个);return@label = 退出这个 lambda,效果相当于 continue;break / continue = 只对循环有效,在 lambda 里根本用不了(forEach 里写 break 编译不过——这也是「能用 for 就别用 forEach」的一个理由)。需要在遍历中提前结束,与其在 forEach 里绕,不如换成 first { } / any { } / takeWhile { } 这些本来就表达「提前停止」的函数(见 07 章)。

Kotlin 没有发明新的 I/O 体系,而是给 JVM 标准库套上一层极简封装:控制台输出用 print/println,读输入用 readln(),文件读写靠 java.io.File 的 Kotlin 扩展函数——小任务一行搞定。下面只用到最基础的语法(变量、字符串模板、if/while/for),更健壮的写法在后面章节解锁。

控制台输出与输入

  • 输出:println 带换行、print 不带;多数场景不需要 printf——直接用 02 章的字符串模板即可(仅宽度/精度对齐才用 "%.2f".format(x))。错误信息走 System.err.println,与标准输出分流(重定向 / 管道时不会混在一起)。
  • 读一行:readln()(Kotlin 1.6 起)返回 String,拿不到输入(EOF)直接抛异常——「输入必须存在」的场景先用它。老写法 readLine() 返回 String?,输入流到头时给 null,配 != null 判断正好写出「逐行读到结束」的 while 循环。
  • 转数字:.toInt(),遇 "abc" 这类非法输入会抛 NumberFormatException。
  • 预告:readLine() 的 String? 正是 05 章空安全的实战入口:在那一章里,readlnOrNull() 配 Elvis 兜底、toIntOrNull() 循环重试这些健壮姿势自然就会写(旧教程里满屏的 readLine()!! 是典型坏味道,届时会展开);08 章的 lambda 到手后,还能用 07 章的 generateSequence(::readLine) 把逐行读取变成惰性流水线。

文件读写

  • 小文件一步到位:File("a.txt").readText() 整个读入、readLines() 按行读成 List<String>(用本章刚学的 for-in 遍历),writeText() 覆盖写,appendText() 追加——无需手动开关流。
  • 大文件别用 readText 一次读进内存;惰性逐行的 useLines 要用到 lambda 语法(08 章),它本身在 07 章的序列卡里。
  • 路径分隔符别手写 "dir/name.txt" 拼接跨平台路径,用 File(dir, "name.txt") 这个双参构造;更现代的 java.nio.file.Path 同样可以在 Kotlin 里直接用。

上面这些写法只适合小工具

  • 上面所有 API 都是「出错就抛异常」的直球风格,适合脚本、命令行练习题、一次性数据处理;
  • 面向不可信输入的正式程序,要等 05 章的 xxxOrNull 家族和异常处理姿势齐了再写——知道雷在哪里就够。
// —— 控制台输出 ——
print("不换行 ")
println("自动换行")
val user = "Kotlin"
println("Hello, $user!")      // 格式化直接用字符串模板
System.err.println("出错了")  // 错误信息走 stderr,与标准输出分流

// —— 读一行 + 转数字:先用最朴素的形态 ——
print("你的名字: ")
val name = readln()               // EOF 会抛异常,健壮版见空安全章
print("年龄: ")
val age = readln().trim().toInt() // "abc" 会抛 NumberFormatException
val next = age + 1
println("$name 明年 $next 岁")

// —— 逐行读到 EOF:readLine() 到头返回 null ——
var line = readLine()
while (line != null) {
    println("读到: $line")
    line = readLine()
}

// —— 文件:小文件一步到位(文件顶部需 import java.io.File)——
val f = File("notes.txt")
f.writeText("第一行\n")      // 覆盖写入
f.appendText("第二行\n")     // 追加
println(f.readText())         // 一次读回整个文件
for (l in f.readLines()) {    // readLines(): List<String>,for-in 遍历
    println(l)
}

// 跨平台拼路径:别手写分隔符
val nested = File(File("data"), "notes.txt")
readln() 和 toInt() 都是「失败就抛异常」:输入流到头(管道重定向、Ctrl+D)时 readln() 抛异常,用户输入 "abc" 时 toInt() 抛 NumberFormatException。处理不可信的外部输入,健壮姿势是 05 章的 readlnOrNull() / toIntOrNull()——知道这两颗雷在哪就够。另外 readText() 会把整个文件一次读进内存,日志等大文件等 08 章学了 lambda 后改用 07 章的 useLines 惰性逐行,内存占用与文件大小无关。
这些 File 扩展默认 UTF-8 编码,读旧系统的 GBK 文件需显式传 charset("GBK") 参数。另外 writeText() 是整体覆盖、appendText() 才是追加——两者写反会静默丢数据。想边写边看效果,把上面的代码贴进 01 章跑通的那个 main 里即可;控制台程序被管道重定向(echo hi | java ...)时,readln() 读的就是管道内容,这是测试交互逻辑最省事的办法。

函数基础

本章覆盖:函数的六种声明形态、默认参数与具名实参(取代 Java 的重载爆炸)、vararg、顶层与局部函数、中缀与函数引用,以及默认参数在 Java 侧看不到这类互操作摩擦。

Kotlin 的函数比 Java 的方法自由得多:能脱离类存在、能嵌套、能带默认值、能当值传。这张表把所有形态列齐。

声明形态

形态写法说明
块体fun f(a: Int): Int { return a * 2 }返回类型必须写(除非是 Unit)
表达式体fun f(a: Int) = a * 2返回类型可推断;单表达式一律用它
无返回值fun f() { }返回 Unit,可以不写
顶层函数直接写在文件里,不需要类工具函数的默认位置;Java 侧看到的是 文件名Kt 的静态方法(12 章)
局部函数函数里再定义函数能访问外层变量;只在一个地方用的辅助逻辑放这里
扩展函数fun String.isEmail() = …08 章
中缀infix fun Int.pow(n: Int) = … → 2 pow 3只能有一个参数;别滥用,可读性收益有限
泛型fun <T> first(xs: List<T>): T = xs[0]09 章
挂起suspend fun load(): Data10 章

参数

写法调用要点
默认参数fun f(a: Int, b: Int = 0) → f(1)取代 Java 的重载爆炸;默认值可以是表达式,甚至引用前面的参数
具名实参f(b = 2, a = 1)多个同类型参数或 boolean 参数时一定要用,可乱序
混用规则位置实参必须在具名实参之前—
varargfun f(vararg xs: Int) → f(1, 2, 3)函数内 xs 是 IntArray;传数组要展开:f(*arr)
lambda 参数fun f(block: (Int) -> String)放最后一个,调用时可以写在括号外:f { it.toString() }
可空参数fun f(a: String? = null)—
返回多值用 data class(推荐)或 Pair / TriplePair 只适合就地用完,进了 API 就该定义类型
单表达式 + 默认值fun greet(name: String = "世界") = "你好 $name"—

函数当值传

写法含义
val f: (Int) -> String函数类型
::println顶层函数引用
String::length属性/成员引用,等价于 { s: String -> s.length }
obj::method绑定到某个实例的引用
::User构造器引用:list.map(::User)
lambda{ x: Int -> x * 2 };单参数可用 it
尾随 lambdalist.filter { it > 0 }——最后一个参数是 lambda 时括号可省
返回函数fun adder(n: Int): (Int) -> Int = { it + n }

与 Java 互操作的摩擦

问题Java 侧看到什么怎么办
默认参数只看得到全参数那一个重载——Java 调用方必须每个参数都传加 @JvmOverloads,编译器会生成全套重载
顶层函数UtilKt.foo()@file:JvmName("Utils") 换个好名字
具名实参Java 没有参数顺序对 Java 调用方仍然重要
Unit 返回void—
可空性Kotlin 编译出的字节码带 @Nullable / @NotNull 注解Java 侧 IDE 能据此提示(12 章)
// ① 默认参数 + 具名实参:取代一堆重载
fun connect(
    host: String,
    port: Int = 443,
    useTls: Boolean = true,
    timeoutMs: Int = 5_000,
) { }

connect("api.example.com")                          // 全用默认
connect("api.example.com", timeoutMs = 30_000)       // 只改一个
// connect("h", 443, true, 30000)   ← 三个月后没人知道 true 是什么

// ② vararg:传数组要展开
fun log(tag: String, vararg parts: String) = println("[$tag] ${parts.joinToString(" ")}")
val arr = arrayOf("a", "b")
log("net", *arr)                                 // ← 星号展开

// ③ 局部函数:只在这里用的逻辑不必暴露出去
fun process(items: List<Item>): Report {
    fun score(i: Item) = i.weight * factor      // 能用外层的 factor
    return Report(items.sumOf(::score))
}

// ④ 函数引用:能写方法名就别写 lambda
val nums = listOf("1", "2").map(String::toInt)      // 比 { it.toInt() } 更直接
val users = rows.map(::User)                     // 构造器引用

// ⑤ 给 Java 调用方留门
@JvmOverloads
fun retry(times: Int = 3, delayMs: Long = 100) { }  // Java 侧才有 retry()/retry(n)
Java 调用方看不到你的默认参数:Kotlin 只编译出一个全参数方法,Java 侧必须每个都传。库要给 Java 用就加 @JvmOverloads(12 章)。
vararg 传数组必须写 *arr,直接 log("net", arr) 会把整个数组当成一个元素——编译能过,行为不对。
默认参数 + 具名实参能替掉 Java 里九成的重载:先写一个参数最全的函数,再靠默认值收窄,比维护五个重载省事得多。
调用处只要出现两个以上同类型参数、或任何一个布尔参数,就用具名实参写出来——这是可读性投入产出比最高的习惯之一。

Kotlin 的函数声明有两种形态,以及一对能消灭大量样板代码的特性:默认参数把「重载爆炸」压成一个函数,命名实参让调用点自己说明每个值是什么意思。这两者配合起来,才是 Kotlin 函数比 Java 省代码的真正原因。

块体与表达式体:什么时候能省返回类型

  • 块体 fun f(): T { ... return x }——除非返回 Unit,否则必须写返回类型;
  • 表达式体 fun f() = x——返回类型可以推断出来,因此可以省;
  • 但 public API 建议写出来:推断出的返回类型会随实现变化(把 listOf 换成 mutableListOf,公开签名就变了),而且读者不必点进实现才知道你返回什么;
  • 有一种情况是硬性必须写:递归的表达式体函数。fun fact(n: Int) = if (n <= 1) 1 else n * fact(n - 1) 报错 type checking has run into a recursive problem. Easiest workaround: specify the types of your declarations explicitly.;
  • 选择标准很朴素:一个表达式说得完就用 =,需要局部变量、多步骤、早返回就用块体,别为了「一行」把表达式挤成天书。

默认参数:一个函数顶一串重载

  • Java 里三个可选参数意味着要手写一串重载,还得让它们互相转调;Kotlin 一个声明搞定;
  • 默认值是表达式,而且在每次调用时求值——fun mk(a: Int, b: Int = a * 2, id: Int = nextId()),连调两次拿到的 id 是 1 和 2(不是同一个值),说明它不是「定义时算一次」;
  • 默认值可以引用前面的参数:上面的 b = a * 2 有效,mk(3) 得到 b=6;
  • 代价:默认参数在 Java 侧看不见,需要 @JvmOverloads——本章最后一卡用 javap 这件事。

命名实参:给调用点加注释

  • connect(secure = true, port = 443) 比 connect("localhost", 443, true) 强的地方在于:读的人不用去翻函数签名,尤其是连续几个 Boolean 参数时;
  • 用了命名实参就可以打乱顺序(connect(secure = true, port = 443) 正常工作);
  • 混用位置实参和命名实参时,把位置实参放在前面最稳妥;
  • 一条实用规矩:调用点出现裸 true / false / 魔法数字时,就该用命名实参。
// —— 块体:必须写返回类型 ——
fun greet(name: String): String {
    val t = name.trim()
    return "Hello, $t!"
}

// —— 表达式体:可以省返回类型(public API 仍建议写)——
fun shout(name: String) = "HELLO, ${name.uppercase()}!"
fun shout2(name: String): String = "HELLO, ${name.uppercase()}!"

// ❌ 递归 + 表达式体 + 省返回类型:
//   type checking has run into a recursive problem.
fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1)

// —— 默认参数 + 命名实参 ——
fun connect(
    host: String = "localhost",
    port: Int = 8080,
    secure: Boolean = false
) = "$host:$port secure=$secure"

connect()                            // localhost:8080 secure=false
connect(port = 3000)                 // localhost:3000 secure=false
connect(secure = true, port = 443)  // localhost:443  secure=true(可乱序)

// —— 默认值是表达式:每次调用求值,且能引用前面的参数——
var calls = 0
fun nextId(): Int { calls++; return calls }
fun mk(a: Int, b: Int = a * 2, id: Int = nextId()) = "a=$a b=$b id=$id"

println(mk(3))          // a=3 b=6 id=1
println(mk(3))          // a=3 b=6 id=2 ← id 每次重新算
println(mk(3, 99))      // a=3 b=99 id=3
默认值不是常量,是每次调用都会执行的表达式。fun mk(a: Int, id: Int = nextId()) 连调两次拿到的 id 分别是 1 和 2。写 fun log(msg: String, time: Long = System.currentTimeMillis()) 时这正是你要的;但写 fun add(x: Int, into: MutableList<Int> = shared) 这种默认值指向共享可变对象时,就是个隐蔽的状态泄漏。另一处:在覆盖函数上不能重新指定默认值——默认值属于基类声明,子类 override 时只能沿用,这一点与「重载可以随便改」的直觉相反。
先想默认参数,再想重载。需要重载的真正场景其实只剩两类:参数类型不同(parse(String) / parse(ByteArray)),或者参数个数与语义都不同。其余「可选参数」的情况一律用默认值——不但代码少,而且新增一个可选参数时所有旧调用点都不用改。命名实参还有个隐藏用途:给只有一个 lambda 参数之外的其它参数命名,能让带尾随 lambda 的调用读起来像 DSL(见 14 章)。

vararg、Unit、Nothing 这三样东西的共同点是:它们在 Kotlin 里都比在 Java 里更「实在」——vararg 在函数内部就是个数组,Unit 是一个真的单例对象而不是关键字,Nothing 是一个真的类型而不是注释。

vararg:函数内部它就是数组

  • 声明 fun sum(vararg nums: Int),函数体里 nums 的类型是 IntArray——运行时类型是 [I(即 int[]);对象类型的 vararg s: String 则是 Array<out String>;
  • 一个函数只能有一个 vararg 参数,但它不必是最后一个——后面的参数用命名实参传即可;
  • 传 0 个参数完全合法:sum() 返回 0;
  • 展开操作符 *:已有一个数组要喂给 vararg,必须写 sum(*arr)。不加 * 直接报错:argument type mismatch: actual type is 'Array<String>', but 'String' was expected.;
  • * 只能用在数组上(List 要先 .toTypedArray()),而且可以和普通实参混着写:sum(1, *arr, 9)。

Unit:它是一个对象,不是「无」

  • 返回 Unit 的函数可以省略 : Unit——但省的只是写法,值是真实存在的:Unit::class.java.name 是 kotlin.Unit,Unit === Unit 为 true(它是 object 单例);
  • val u: Any = println("x") 之后 u === Unit 为 true——println 是真的返回了那个单例;
  • 为什么要这么设计:Java 的 void 不能当泛型实参,于是有了 Runnable / Consumer / Function 一整套割裂的接口;Kotlin 里 (Int) -> Unit 和 (Int) -> String 是同一套类型系统里的东西,List<Unit> 也合法。这是「函数类型能统一」的地基(见 08 章)。

Nothing:永不正常返回

  • fun fail(msg: String): Nothing 告诉编译器「调用之后的代码不会执行」,于是它能出现在任何期望值的位置:val v = map[k] ?: fail("not found") 之后 v 是非空类型;
  • 与 Unit 的区别一句话:Unit 是「返回了,但没有有意义的值」,Nothing 是「根本没返回」;
  • 完整讲法(包括为什么 Nothing 是所有类型的子类型)见 03 章的「返回、跳转与 Nothing」卡。
// —— vararg:函数内部是数组 ——
fun sum(vararg nums: Int): Int = nums.sum()
println(sum(1, 2, 3))     // 6
println(sum())            // 0 ← 传 0 个也合法

fun varargType(vararg n: Int) = n::class.java.name
println(varargType(1, 2))  // [I  (就是 int[])

// —— 展开操作符 * ——
val arr = intArrayOf(4, 5, 6)
println(sum(*arr))         // 15
println(sum(1, *arr, 9))   // 可以和普通实参混写

fun takesStrings(vararg s: String) = s.size
val names = arrayOf("a", "b")
println(takesStrings(*names))  // 2
// takesStrings(names)
//   ❌ argument type mismatch: actual type is 'Array<String>',
//      but 'String' was expected.

// vararg 不必在最后,后面的参数用命名实参传
fun join(vararg parts: String, sep: String = "-") = parts.joinToString(sep)
println(join("a", "b", sep = "/"))   // a/b

// —— Unit 是真实的单例对象——
println(Unit::class.java.name)   // kotlin.Unit
println(Unit === Unit)           // true
val u: Any = println("x")        // println 返回 Unit
println(u === Unit)              // true ← 真的是那个单例

// 两种写法等价
fun log1(msg: String): Unit { println(msg) }
fun log2(msg: String) { println(msg) }

// —— Nothing:永不正常返回 ——
fun fail(msg: String): Nothing = throw IllegalStateException(msg)
val v = map[key] ?: fail("Key not found")  // v 是非空类型
把数组直接传给 vararg 不会「自动展开」,而是编译错误。takesStrings(names) 报 argument type mismatch: actual type is 'Array<String>', but 'String' was expected.——必须写 takesStrings(*names)。这个坑在从 Java 迁移代码时尤其常见,因为 Java 允许直接传数组。List 更麻烦:* 只对数组有效,得先 *list.toTypedArray(),而这会再复制一遍——如果你发现自己在到处写这个转换,说明这个函数本来就该接收 List 而不是 vararg。
vararg 用起来顺手,但每次调用都会现场创建一个数组。写在热点路径上的小函数(尤其是日志、断言这类会被大量调用的),常见做法是给最常用的 1~2 个参数个数提供普通重载,把 vararg 版本留作兜底——标准库里 listOf(x) 就有专门的单元素重载。另外,Array<out T> 里的 out 是型变标注,意思是「只读出、不写入」,09 章会讲。

Kotlin 允许函数不属于任何类:可以写在文件顶层,也可以写在另一个函数内部。这两件在 Java 里都做不到的事,背后是同一套 JVM 机制——编译器帮你造了类和静态方法。看一眼 javap,很多「Java 侧怎么调」「为什么这里有个奇怪的对象」的疑问就一起解决了。

顶层函数编译成什么(javap)

  • 文件 funcs2.kt 里的顶层函数,会被打包进一个以文件名命名的类:public final class Funcs2Kt——命名规则就是 01 章讲过的「foo.kt → FooKt」,main 能被 JVM 找到也是靠它;
  • 每个顶层函数是一个 public static final 方法。fun topLevel(x: Int): Int 出现在 javap 输出里就是 public static final int topLevel(int);
  • 所以 Java 侧调用写 Funcs2Kt.topLevel(21)。嫌类名难看,可以在文件第一行写 @file:JvmName("Utils") 改成 Utils.topLevel(21)(互操作细节见 12 章);
  • 结论:「工具函数一定要塞进一个 object Utils」是 Java 习惯的残留——Kotlin 里直接写顶层函数就好,编译出来的东西是一样的,还少一层。

局部函数:捕获外层变量(javap)

  • 函数里可以再定义函数,它能直接读写外层函数的局部变量,不用当参数传;
  • 这在 JVM 上并不免费。一个捕获了 StringBuilder sb 和 var count 的局部函数 emit,编译成了 private static final void report$emit(kotlin.jvm.internal.Ref$IntRef, java.lang.StringBuilder, java.lang.String)——注意两点:① 捕获的变量变成了显式参数;② 被修改的 var count 被包进了 Ref$IntRef 这个可变盒子;
  • 这正是 Kotlin 能突破「Java lambda 只能捕获 effectively final 变量」的方式——它不捕获变量本身,而是捕获一个装着变量的盒子。

该用局部函数,还是私有方法?

  • 用局部函数:这段逻辑只在这一个函数里用到、并且要读外层的局部变量——提成私有方法就得把一堆参数传来传去;
  • 提成私有方法 / 顶层函数:逻辑超过几行、需要单独写单元测试、或者其它地方也想用;
  • 提成私有方法的额外好处:局部函数会随着外层函数一起长,容易把一个 20 行的函数养成 100 行——嵌套只是把复杂度藏起来了,不是消掉了。
// ============ 顶层函数(文件名 funcs2.kt)============
fun topLevel(x: Int): Int = x * 2

// javap -p -cp out Funcs2Kt 输出(节选):
//   public final class Funcs2Kt {
//     public static final int topLevel(int);
//     ...
//   }
// Java 侧调用:Funcs2Kt.topLevel(21)
// 想改类名:文件第一行写 @file:JvmName("Utils") → Utils.topLevel(21)

// ============ 局部函数:捕获外层变量 ============
fun report(items: List<String>): String {
    val sb = StringBuilder()
    var count = 0
    fun emit(tag: String) {   // 直接用外层的 sb 和 count
        count++
        sb.append("[$tag]")
    }
    for (i in items) emit(i)
    return "$sb count=$count"
}
println(report(listOf("a", "b")))   // [a][b] count=2

// javap 里它长这样:
//   private static final void report$emit(
//       kotlin.jvm.internal.Ref$IntRef,   ← 被修改的 var count 装进了盒子
//       java.lang.StringBuilder,          ← 捕获的 sb 变成了参数
//       java.lang.String);

// 局部函数也能递归
fun demo() {
    fun fib(k: Int): Int = if (k < 2) k else fib(k - 1) + fib(k - 2)
    println(fib(10))     // 55
}
局部函数捕获 var 时,编译器会创建 Ref$IntRef 这类包装对象(可在 javap 里看到),并把它当参数传进去——写在热点循环里、每轮都定义一个捕获了可变状态的局部函数,就会产生本可避免的对象分配。真在意时,把它改成接收显式参数的私有函数即可。另一个容易撞的限制:局部函数只能写在块体函数里——表达式体函数 fun f() = ... 的函数体是一个表达式,没有地方放声明。最后,局部函数的可见性关键字写不了(它天然只在外层函数内可见),给它加 private 是无效的。
Kotlin 里顶层函数是一等公民,不是权宜之计。标准库自己就是这么写的——listOf、println、maxOf 全是顶层函数。判断要不要放进类:这个函数是否需要访问某个对象的状态?不需要就放顶层(或者写成扩展函数——机制见 08 章,放哪合适见 13 章)。同一个包下的顶层函数不需要 import 就能互相调用;跨包时 IDE 会自动补 import,和调用类方法没有体感差别。

这里讲两个「让函数名出现在意想不到的位置」的特性:infix 让函数调用不写点和括号,函数引用 :: 让函数作为值传递。前者是 DSL 的基础零件,后者是把 Kotlin 代码从「到处写 lambda」精简到「直接指名要哪个函数」的关键。(运算符重载是另一套机制,见 09 章。)

infix 的三个硬条件(报错原文)

  • ① 必须是成员函数或扩展函数——顶层的普通函数不行;
  • ② 必须恰好一个参数;
  • ③ 参数不能是 vararg;
  • 违反任何一条,报错都是同一句:'infix' modifier is inapplicable to this function.(顶层普通函数与 vararg 两种情况都是这句);
  • 调用时不能省略接收者:在类内部调用自己的 infix 成员,必须写 this combine other。省掉 this 会报 function invocation 'combine(...)' expected.;
  • 优先级要注意:中缀调用低于算术运算符。2 pow2 1 + 1 的结果是 4,也就是被解析成了 2 pow2 (1 + 1),而 (2 pow2 1) + 1 是 3——混写时务必加括号。

函数引用::: 的三种形态

写法得到的类型说明
::greet(String) -> String顶层 / 局部函数的引用;::greet.name 得到 "greet"
g::hi(g 是实例)(String) -> String绑定引用:接收者已经定死是 g
Greeter::hi(Greeter, String) -> String未绑定引用:接收者变成第一个参数

典型用法是替代「只是转发一下」的 lambda:list.map(String::length) 比 list.map { it.length } 更直白(listOf("x","yy").map(String::length) 得 [1, 2])。构造函数同样可以引用:::User。

::class 与 ::class.java

  • x::class 得到 Kotlin 的 KClass,x::class.java 得到 Java 的 java.lang.Class;
  • 基本的类型信息不需要额外依赖,但深度反射需要 kotlin-reflect。在没有这个依赖时打印 "s"::class,输出是 class java.lang.String (Kotlin reflection is not available)——括号里那句提示就是在告诉你缺依赖;
  • 同样地,::greet.name 这类轻量属性能用,而取 returnType 会直接抛 kotlin.jvm.internal.KotlinReflectionNotSupportedError,提示 Make sure you have kotlin-reflect.jar in the classpath。
// ============ infix ============
infix fun Int.pow2(times: Int): Int {          // ✅ 扩展函数 + 一个参数
    var r = 1; repeat(times) { r *= this }; return r
}
println(2 pow2 10)      // 1024,等价于 2.pow2(10)

// ❌ infix fun bad(a: Int, b: Int) = a + b        (顶层普通函数)
// ❌ infix fun Int.bad(vararg xs: Int) = 0        (vararg)
//   两者报错都是:'infix' modifier is inapplicable to this function.

class Box(val v: Int) {
    infix fun combine(o: Box) = Box(v + o.v)
    fun test(o: Box) = this combine o   // ✅ this 不能省
    // fun bad(o: Box) = combine o
    //   ❌ function invocation 'combine(...)' expected.
}

// ⚠️ 中缀调用的优先级低于算术运算符:
//    2 pow2 1 + 1    → 4  (= 2 pow2 (1 + 1))
//    (2 pow2 1) + 1  → 3  (想要这个就得自己加括号)

// ============ 函数引用 ============
fun greet(name: String) = "Hello, $name!"
class Greeter(val prefix: String) {
    fun hi(name: String) = "$prefix $name"
}

val f: (String) -> String = ::greet          // 顶层函数引用
println(f("a"))                             // Hello, a!
println(::greet.name)                       // greet

val g = Greeter(">>")
val bound: (String) -> String = g::hi       // 绑定引用(接收者定死)
println(bound("b"))                         // >> b

val unbound: (Greeter, String) -> String = Greeter::hi
println(unbound(g, "c"))                    // >> c(接收者成了第一个参数)

// 替代「只是转发」的 lambda
println(listOf("x", "yy").map(String::length))  // [1, 2]

// ============ ::class ============
println("s"::class.java)          // class java.lang.String
println(String::class.qualifiedName) // kotlin.String
println("s"::class)
// (无 kotlin-reflect 依赖时):
//   class java.lang.String (Kotlin reflection is not available)
中缀调用的优先级低于算术运算符,2 pow2 1 + 1 得 4(即 2 pow2 (1 + 1)),而 (2 pow2 1) + 1 得 3——和多数人的直觉(「先算左边这个中缀」)相反,混写数学表达式时务必加括号。第二处是 ::class 能用不代表反射能用:x::class.java 这类基本操作不需要额外依赖,但一旦碰 returnType、memberProperties 这些,运行时会抛 kotlin.jvm.internal.KotlinReflectionNotSupportedError,提示 Make sure you have kotlin-reflect.jar in the classpath——它是编译期看不出来的运行时错误,必须显式加 kotlin-reflect 依赖。
infix 要克制。它能提升可读性的场景很窄:读起来像自然语言的二元操作(1 to "a"、x shl 2、测试库的 result shouldBe 42),或者构建 DSL(14 章)。给普通业务函数加 infix 只会让人找不到它在哪定义。函数引用则可以放心多用:map(String::length)、filter(::isValid)、forEach(::println) 都比等价的 lambda 更短也更明确;IDE 也会主动提示你把 { it.length } 换成 String::length。

默认参数是 Kotlin 侧的语法糖,JVM 字节码里并没有「默认参数」这回事。于是一个纯 Kotlin 项目里最便利的特性,到了要被 Java 调用的边界上就变成了摩擦。下面用 javap 把生成的方法数量摊开来看,是理解 12 章一整章的最好切入点。

javap 加不加 @JvmOverloads 差多少

同一个签名 fun connect(host: String = "localhost", port: Int = 8080, secure: Boolean = false),编译后:

生成的方法Java 能怎么调
不加connect(String, int, boolean)
connect$default(String, int, boolean, int, Object)
只能全部参数都传
加 @JvmOverloads上面两个,外加
connect2(String, int)
connect2(String)
connect2()
省略尾部参数的每种组合都有对应重载

也就是从 2 个方法变成 5 个。多出来的三个才是 Java 侧真正能用的「有默认值」的调用方式。

Java 侧报错

  • 不加注解时,Java 写 Funcs2Kt.connect(),javac 报错:method connect in class Funcs2Kt cannot be applied to given types; required: String,int,boolean; found: no arguments;
  • 加了注解的 Funcs2Kt.connect2() 和 Funcs2Kt.connect2("h") 则直接编译通过;
  • 注意那个 connect$default:它是实现细节——多出来的 int 参数是「哪些参数用了默认值」的位掩码,最后的 Object 是占位。Java 侧硬调它要自己算掩码、还要传 null,永远不要这么干,它的签名不保证稳定。

什么时候该加

  • 会被 Java 调用的公开 API:库的入口函数、Android 自定义 View 的构造函数(框架会通过反射调三参构造)——这类地方几乎必加;
  • 纯 Kotlin 内部代码:不要加。它只会凭空多出几个方法、把 API 面撑大,Kotlin 侧一个都用不上;
  • 构造函数上的写法是 class Conn @JvmOverloads constructor(host: String = "localhost", ...)——注意注解放在 constructor 关键字前,而且这时 constructor 不能省;
  • 加了就不好去掉:那些重载已经是 Java 侧的公开 API,删掉会破坏二进制兼容。
// ============ Kotlin 侧 ============
fun connect(host: String = "localhost", port: Int = 8080, secure: Boolean = false) =
    "$host:$port secure=$secure"

@JvmOverloads
fun connect2(host: String = "localhost", port: Int = 8080, secure: Boolean = false) =
    "$host:$port secure=$secure"

// ============ javap -p -cp out Funcs2Kt 输出(节选)============
// public static final java.lang.String connect(java.lang.String, int, boolean);
// public static java.lang.String connect$default(java.lang.String, int,
//                                boolean, int, java.lang.Object);
//   ↑ 只有这两个 —— Java 侧必须把三个参数全传
//
// public static final java.lang.String connect2(java.lang.String, int, boolean);
// public static java.lang.String connect2$default(...);
// public static final java.lang.String connect2(java.lang.String, int);
// public static final java.lang.String connect2(java.lang.String);
// public static final java.lang.String connect2();
//   ↑ 五个 —— 后三个才是 Java 能享受到的「默认参数」

// ============ Java 侧(javac)============
// Funcs2Kt.connect2();              ✅ 编译通过
// Funcs2Kt.connect2("h");           ✅ 编译通过
// Funcs2Kt.connect("h", 1, false);  ✅ 全传就行
// Funcs2Kt.connect();               ❌
//   error: method connect in class Funcs2Kt cannot be applied to given types;
//     required: String,int,boolean
//     found:    no arguments

// ============ 构造函数上的写法 ============
class Conn @JvmOverloads constructor(
    val host: String = "localhost",
    val port: Int = 8080
)   // 注解在 constructor 前,且这时 constructor 关键字不能省
别去调 connect$default。它是编译器的实现细节:倒数第二个 int 参数是位掩码(哪一位为 1 表示对应参数「用默认值」),最后的 Object 是占位。曾经有人为了绕开 @JvmOverloads 直接从 Java 调它,结果 Kotlin 版本升级后掩码约定或签名一变就崩了。还有一处:@JvmOverloads 生成的重载会参与 Java 侧的重载解析——如果那个类里已经有同名的手写方法,加上注解后可能突然产生「引用有歧义」的编译错误,或者更糟,Java 调用点静默改调了另一个重载。在已发布的 API 上加这个注解,要和加一个新的公开方法同等对待。
判断要不要加 @JvmOverloads 的问题只有一个:这个函数会被 Java 代码调用吗?不会就别加。混合项目里的常见做法是——只在模块的公开边界(对外暴露的 API 类、facade)上加,内部实现一律不加。顺带一提,同样的「Kotlin 特性在 Java 侧消失」的还有:@JvmStatic(companion 里的函数在 Java 侧要写 Foo.Companion.bar())、@JvmName(改类名 / 方法名)、@JvmField(暴露字段而非 getter)。这一整套见 12 章。

空安全机制

本章覆盖:可空类型的全部符号(?. ?: !! as? let)、智能转换什么时候失效、lateinit 与 by lazy、平台类型(从 Java 来的值没有可空性)、以及异常与前置条件。空安全是 Kotlin 的头号卖点,缺口只有平台类型一处。

空安全是 Kotlin 的头号卖点:「可能为 null」被写进类型里,编译期就能拦住。这一章的全部内容就是「怎么标注」「编译器认不认」「从 Java 来的值怎么办」。

全部符号一次看完

写法含义例子
String不可空,默认就是这样赋 null 编译不过
String?可空—
?.安全调用:为 null 就整体求值为 nulluser?.name?.length 得 Int?
?:Elvis:左边为 null 就取右边name ?: "匿名"
?: return / ?: throw提前退出——右边是 Nothing 类型所以合法val n = x ?: return(最常用的收窄手法)
!!「我保证不是 null」,错了当场 NPE能不用就不用;它是唯一会主动制造 NPE 的语法
?.let { }非空时执行一段name?.let { println(it) }
?.also / ?.run同理,区别见 08 章—
as?安全转换,失败给 nullval s = x as? String ?: return
lateinit var延后初始化;只能是非空引用类型没赋值就读 → UninitializedPropertyAccessException;::x.isInitialized 可判断
by lazy首次访问才算,天然是 val比 lateinit 安全,能用就用它
集合里的可空List<String?> vs List<String>?位置不同意思完全不同;去空用 listOfNulls.filterNotNull()
空判断工具s.isNullOrEmpty() · s.isNullOrBlank() · list.orEmpty()省掉一层判空

智能转换:什么时候生效,什么时候不

情形编译器认吗说明
val x: String? 判空后认局部 val 是最理想的情况
if (x is String) x.length认类型检查后自动转换
when (x) { is Foo -> x.bar }认—
x != null && x.length > 0认&& 右侧已经知道左侧成立
var 局部变量通常认但中间调用了可能改它的代码就不认
类的 var 属性不认「Smart cast is impossible」最常见的原因:别的线程可能改它
自定义 getter 的属性不认每次读可能返回不同值
别的模块的 open 属性不认子类可能覆盖成计算属性
解法先赋给局部 val:val n = obj.name ?: return——之后 n 就是非空的 String

平台类型:空安全的唯一缺口

要点说明
它是什么从 Java 来的值,可空性未知,IDE 里显示为 String!
编译器态度不拦你:既能当 String 用也能当 String? 用
后果Kotlin 代码里照样能出 NPE,而且报错位置在你「确信非空」的那一行
规矩在边界上立刻表态:接收 Java 返回值时显式写类型 val s: String? = javaObj.getName(),然后按可空处理
让 Java 侧表态对方加 @Nullable / @NotNull(JSpecify 或 JetBrains 注解),Kotlin 就会当真
常见来源Java 库、Android 框架、反射、Gson 这类基于反射的反序列化(它能往非空字段塞 null)

异常

写法说明
try { } catch (e: IOException) { } finally { }try 也是表达式:val x = try { parse(s) } catch (e: Exception) { null }
没有受检异常Kotlin 不强制 throws 声明——好处是没有样板,代价是调用方不知道会抛什么,靠文档与命名表达
抛throw IllegalArgumentException("…")
前置条件require(cond) { "msg" }(参数不合法)· check(cond)(状态不对)· error("msg") · requireNotNull(x)(13 章)
资源use:File(p).bufferedReader().use { … }
包装结果runCatching { … }.getOrElse { … }——注意它会吞掉 CancellationException,协程里慎用(10 章)
给 Java 用@Throws(IOException::class) 才会生成 throws 声明(12 章)
// ① 四种处理可空的姿势,按推荐度排
val name: String? = load()

val a = name ?: "匿名"                  // 给兜底
val b = name ?: return                    // 提前退出(之后 name 已收窄)
name?.let { println(it.length) }          // 非空才做
// val c = name!!.length                 // 最后手段:错了就是 NPE

// ② 「Smart cast is impossible」的标准解法
class Box(var value: String?) {
    fun show() {
        // if (value != null) println(value.length)   → 编译错:value 是 var 属性
        val v = value ?: return            // ← 固定成局部 val
        println(v.length)
    }
}

// ③ 平台类型:Java 来的值要在边界上表态
val raw = javaConfig.getName()            // 推断成 String!(可空性未知)
val safe: String? = javaConfig.getName()  // ← 显式标成可空,后面就有保护
println(safe?.uppercase() ?: "(无)")

// ④ try 是表达式;runCatching 更紧凑
val n = try { s.toInt() } catch (e: NumberFormatException) { 0 }
val m = s.toIntOrNull() ?: 0            // 这个场景其实不该用异常

// ⑤ 集合里的 null:一次过滤干净
val names: List<String?> = listOf("a", null, "b")
val clean: List<String> = names.filterNotNull()
!! 是 Kotlin 里唯一会主动制造 NPE 的语法,而它常常被当成「消警告」的快捷键用。代价是:NPE 的现场被挪到了 !! 这一行,真正的原因(为什么这里会是 null)反而看不见了。
平台类型是空安全的缺口:Gson 这类基于反射的库能往声明为非空的字段里塞 null,编译器完全不知情——结果是一个「不可能为 null」的字段在运行时是 null。边界处一律当可空处理,或者改用 kotlinx.serialization(它认 Kotlin 的可空性)。
空安全用得顺的标志是几乎不写 !!。遇到「我确信非空」时,优先换成:?: return、?: error("…")、requireNotNull(x)——前两者让控制流显式,后者出错时还有一句说明。
处理来自 Java 的值,在第一行就把类型写成 T?,之后整段代码都有编译器护着。

Kotlin 把「可不可以是 null」提升成了类型的一部分:String 和 String? 是两个不同的类型,编译器不允许你把后者当前者用。于是「忘了判空」这类 bug 不再需要靠测试碰运气,而是当场编译不过。

可空类型:一个问号改变一切

  • var name: String = "Kotlin"——非空类型,赋 null 直接编译错误;
  • var nick: String? = null——可空类型,但此后不能直接 nick.length,编译器会拦下来;
  • 拦下来之后你只有两条路:安全地处理它(?. / ?: / let),或者断言它不是 null(!!)并承担后果。

六种写法,各管一段

写法为 null 时的行为什么时候用
x?.f()整个表达式短路成 null,不调用「有就做,没有就算了」,结果还允许是 null
x ?: 默认值取右边的值要一个非空结果,null 有合理兜底
x ?: return / ?: throw直接退出当前函数前置校验,避免整段代码往右缩进
x?.let { }整块不执行,返回 null要对非空值做一段逻辑,或需要智能转换失效时的替代(见本章后面)
requireNotNull(x)抛 IllegalArgumentException校验外部传进来的参数
checkNotNull(x)抛 IllegalStateException校验自己对象的内部状态

最后两个是 !! 的「文明版」:同样是断言,但异常类型分得清是谁的错,还能带上说明消息(requireNotNull(x) { "nick required" } 抛出的正是 IllegalArgumentException: nick required;不给消息时默认消息是 Required value was null.)。

var name: String = "Kotlin"  // 非空,name = null 编译不过
var nick: String? = null      // 可空类型

// 安全调用 ?.(null 时整个表达式短路成 null)
val len: Int? = nick?.length   // null(不是 0!类型是 Int? 不是 Int)

// Elvis ?:(null 时用右边的值)
val display = nick ?: "匿名"   // "匿名",类型是 String(非空)

// 链式安全调用:任一环为 null 就整条短路
val city = user?.address?.city ?: "未知"

// Elvis + return:前置校验的惯用写法,代码不用往右缩进
fun handle(params: Map<String, String>) {
    val id = params["id"] ?: return          // 没有就直接退出
    val n  = id.toIntOrNull() ?: error("id 不是数字")
    // 到这里 id 是 String、n 是 Int,都非空
}

// ?.let:对非空值执行一整段逻辑
nick?.let { n ->
    println("昵称长度 ${n.length}")
    save(n)
}

// 断言:三种强度,异常类型不同(均已)
val a = requireNotNull(nick) { "nick 必填" }  // IllegalArgumentException: nick 必填
val b = checkNotNull(nick)                    // IllegalStateException: Required value was null.
val c = nick!!.length                        // java.lang.NullPointerException(message 为 null)

// 实战:安全读取输入(兑现 03 章 I/O 的预告)
val port = readlnOrNull()?.toIntOrNull() ?: 8080  // EOF 或非数字都兜底
!! 抛的不是 KotlinNullPointerException,是 java.lang.NullPointerException。这是流传极广的误解——很多资料(包括本页的旧版本)都写成前者。catch (e: Throwable) 打印 e.javaClass.name 得到 java.lang.NullPointerException,且 e is KotlinNullPointerException 为 false。KotlinNullPointerException 这个类型确实存在,但 !! 不用它。更麻烦的是它的 message 是 null(msg=[null])——异常里除了行号什么线索都没有,所以别指望 !! 崩了以后日志能告诉你什么。真要断言就用 requireNotNull / checkNotNull 并写上消息。另外 x?.length 的类型是 Int? 不是 Int,拿去做算术还得再兜一次底,别以为 ?. 之后就万事大吉了。
?: 右边可以是 return / throw / continue——因为它们的类型是 Nothing,能塞进任何位置。这就是 val id = params["id"] ?: return 成立的原因,也是 Kotlin 代码普遍比 Java 平坦的原因:把「不满足就走人」写在一行里,剩下的代码全在主干上,不用嵌在 if 里。选断言函数时按「谁的错」分:参数不对是调用方的错,用 requireNotNull(IllegalArgumentException);状态不对是自己的错,用 checkNotNull(IllegalStateException)。

判过一次 null(或类型)之后还要手动 cast,是 Java 里最繁琐的样板。Kotlin 的智能转换让编译器自己记住这个结论——但它只在能证明值不会中途变掉时才敢这么做,这条边界是这里的重点。

智能转换:编译器帮你记住结论

  • if (obj is String) { obj.uppercase() }——分支内 obj 直接当 String 用;
  • 对 null 同理:if (x != null) { x.length }——分支内 x 从 String? 收窄成 String;
  • as? 是安全转换:转不动返回 null 而不是抛 ClassCastException,天然和 ?: 搭配。

三种智能转换失效的情况(报错原文均为)

  • 可变属性 var——检查完到使用之间,别的线程可能改掉它:
    error: smart cast to 'String' is impossible, because 'v' is a mutable property that could be mutated concurrently.
  • 自定义 getter 或 open 属性——每次读都在执行代码 / 可能被子类改写,两次读未必是同一个值,编译器把这两种情况合并成一条消息:
    error: smart cast to 'custom' is impossible, because 'custom' is a property that has an open or custom getter.
  • 被 lambda 修改过的局部 var——闭包捕获后编译器放弃推断,报的是最常见的那条:
    error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.

而普通 val 属性和没被 lambda 改过的局部 var智能转换都正常工作——这两边都验证过。解法只有一个套路:先用局部 val 接住(val v = b.v; if (v != null) …),或者直接 b.v?.let { }——?.let 是把值取出来传进 lambda 的,天然不受影响。

lateinit:非空、但没法在构造时赋值

依赖注入、测试的 setUp()、框架回调里初始化的字段,都属于「逻辑上一定非空、但构造时确实没有值」。lateinit var 让你跳过 ?,代价是把检查推迟到运行时。它的限制很硬:

不能用于编译器原话
基本类型(Int/Boolean…)'lateinit' modifier is not allowed on properties of primitive types.
可空类型(String?)'lateinit' modifier is not allowed on properties of a type with nullable upper bound.
val'lateinit' modifier is allowed only on mutable properties.

基本类型不行是因为它的实现就是「用 null 表示未初始化」,而 Int 在 JVM 上没有 null 可用;可空类型不行是因为 null 已经是合法值,没法再拿它当哨兵。

// 智能转换:is 之后直接当目标类型用
fun describe(obj: Any): String = when (obj) {
    is String     -> "字符串,长 ${obj.length}"   // obj 已是 String
    is Int        -> "整数 ${obj + 1}"
    is List<*>    -> "列表,${obj.size} 项"
    else          -> "未知"
}

// 安全转换 as?:转不动返回 null,不抛 ClassCastException
val s: String = obj as? String ?: "不是字符串"

class Box {
    var v: String? = null              // 可变属性
    val custom: String? get() = compute()  // 自定义 getter
    val stable: String? = "hi"        // 普通 val(有幕后字段)
}

fun f(b: Box) {
    if (b.v != null) b.v.length          // ❌ mutable property that could be mutated concurrently
    if (b.custom != null) b.custom.length // ❌ property that has an open or custom getter
    if (b.stable != null) b.stable.length // ✅ 普通 val,智能转换成立

    // 解法一:先用局部 val 接住(快照)
    val v = b.v
    if (v != null) println(v.length)     // ✅

    // 解法二:?.let —— 值是被传进 lambda 的,不存在「再读一次」的问题
    b.v?.let { println(it.length) }       // ✅
}

// lateinit:非空但构造时没法赋值
class UserTest {
    lateinit var service: UserService

    fun setUp() { service = UserService() }

    fun check() {
        if (::service.isInitialized) println(service)   // 只能在类内部这么问
    }
}
// 未初始化就读,抛:
//   kotlin.UninitializedPropertyAccessException:
//   lateinit property service has not been initialized
::x.isInitialized 只能在能访问该属性幕后字段的地方用,也就是基本上只能在声明它的类内部。在类外写 h::s.isInitialized 编译报 error: backing field of 'var s: String' is not accessible at this point.——所以别指望在外面「先问一下初始化了没」。还有一个是把 lateinit 当可空用:它没有「没值」这个合法状态,读到未初始化就是 UninitializedPropertyAccessException 崩溃。如果「可能一直没有值」是正常业务情形,那它本来就该是 String? 或 by lazy,不是 lateinit。
把可空属性赋给局部 val 是万能解法。val v = b.v 之后,v 是不可变的局部变量,编译器随便怎么推断都安全,而且这也如实反映了语义——你要处理的本来就是「读到的那一刻的那个值」。lateinit 需要基本类型时用 Delegates.notNull<Int>() 顶上(见本页 06 章属性委托卡),它抛的是 IllegalStateException: Property xxx should be initialized before get.。

上面两卡的编译期保证有一个前提:编译器知道某个值可不可以为 null。可 Java 的类型系统里根本没有这个信息——String 就是 String。于是 Kotlin 只能设一个特殊档位:平台类型。这是整个空安全体系唯一的缺口,也是 Kotlin 项目里 NPE 的主要来源。

String!:既不是 String 也不是 String?

  • 调用没有可空性注解的 Java 方法,返回值的类型是 String!——意思是「编译器不知道,交给你负责」;
  • 这个类型你写不出来,只能在错误消息里看到。把它赋给 Int 让编译器报错,原文是:
    error: initializer type mismatch: expected 'Int', actual 'String!'.
    泛型也一样传染:返回 List<String> 的 Java 方法在 Kotlin 侧是 (Mutable)List<String!>!;
  • 平台类型既能当非空用、也能当可空用——两种写法都编译通过、都不警告。这不是 bug,是刻意的:全部当可空会让每一行互操作代码都淹没在 ?. 里。

运行时检查插在哪:三种报错长得完全不一样

Kotlin 会在平台类型进入非空 Kotlin 类型的那一刻插入检查(编译器生成 Intrinsics.checkNotNullExpressionValue),而不是在你使用它的时候。所以崩溃点常常在赋值行,离「真正用它」的地方很远。三种情况如下:

写法异常与消息
val n: String = Legacy.find("k")
(平台类型赋给非空类型)
java.lang.NullPointerException: find(...) must not be null
——Kotlin 插入的检查,消息里点名了是哪个方法
Legacy.find("k").length
(直接当非空用)
java.lang.NullPointerException: Cannot invoke "String.length()" because the return value of "Legacy.find(String)" is null
——这条是 JVM 自己给的详细 NPE 消息
x!!(Kotlin 自己的断言)java.lang.NullPointerException,message 为 null——什么线索都没有

认这三条消息很有用:看到 xxx(...) must not be null 就知道是平台类型漏了判空,而不是自己的 Kotlin 代码写错。

怎么堵这个缺口

  • 给 Java 侧加注解:@Nullable / @NotNull(org.jetbrains.annotations,或 JSpecify 等)。加了之后类型不再是平台类型——给 find 标 @Nullable 后,同一行赋值当场变成编译错误:
    error: initializer type mismatch: expected 'String', actual 'String?'.
    问题从运行时前移到了编译期,这正是它的价值所在;
  • 在边界上一次性标注:Java 库改不了时,自己写一层薄封装,把每个进来的值显式声明成 String?,让缺口只存在于这一层;
  • 别在中间层用 !! 图省事——它只是把崩溃点从有信息的地方挪到了没信息的地方。
// ── Java 侧(没有任何可空性注解)───────────────
// public class Legacy {
//     public static String find(String key) { return null; }
// }

// ── Kotlin 侧 ────────────────────────────────
// 编译器完全不拦,因为 find 的返回类型是平台类型 String!
val name: String = Legacy.find("user")
// 运行时
//   Exception in thread "main" java.lang.NullPointerException:
//       find(...) must not be null
//   at MainKt.main(main.kt:3)   ← 崩在赋值这一行,不是使用它的那一行

// 同一个调用,也允许当可空用——编译器一视同仁,不给任何提示
val safe: String? = Legacy.find("user")
println(safe?.length ?: 0)              // 这才是对的写法

// 想看平台类型长什么样?故意写错类型,让编译器打印出来:
val wrong: Int = Legacy.find("k")
//   error: initializer type mismatch: expected 'Int', actual 'String!'.
val wrong2: Int = Legacy.tags()
//   error: initializer type mismatch: expected 'Int',
//          actual '(Mutable)List<String!>!'.

// ── 加了注解的 Java 侧 ────────────────────────
// @Nullable public static String find(String key) { return null; }
val n2: String = Legacy2.find("k")
//   error: initializer type mismatch: expected 'String', actual 'String?'.
//   ✅ 同一个 bug,从运行时崩溃变成编译期错误

// ── 边界封装:让缺口只存在一层 ─────────────────
object LegacyGateway {
    // 进来的一律显式标可空,后面全是 Kotlin 的地盘
    fun find(key: String): String? = Legacy.find(key)
}
「我全项目 Kotlin,没有 Java」不代表没有平台类型。只要依赖里有一个未注解的 Java 库(绝大多数 JVM 生态库的底层都是),它的返回值就是平台类型。更隐蔽的是:崩溃行号会骗你。上面第一个例子崩在 val name: String = ... 这一行,而不是几十行之后真正 name.length 的地方——如果那次赋值发生在构造函数或者某个初始化方法里,栈顶指向的会是一个「看起来什么都没干」的位置。看到 must not be null 就顺着方法名往 Java 侧找,别在 Kotlin 代码里绕圈。
判断一个 Java API 会不会给你平台类型,看它有没有可空性注解就行。主流库(JDK 部分类、Guava、Spring、Android SDK)近年都补了 @Nullable / @NonNull,IDE 会据此给出正确的可空性;而你公司内部那些十年前的 Java 工具类多半是裸的。真正危险的正是后者——恰恰又是最常被调用的。写 Java 代码时顺手加注解,比在 Kotlin 侧一处处补 ?. 划算得多。反射、JSON 反序列化等绕过构造器写字段的场景同样能把 null 塞进非空属性,机制与此同源,见 12 章。

前面几卡都在教「拿到 null 之后怎么办」。但工程上真正有效的顺序是反的:第一步永远是问「这个值能不能一开始就不为 null」。Kotlin 的可空类型好用到会让人上瘾——满屏 ?. 的代码不是空安全做得好,而是 API 设计得差。

第一层:让 null 根本不出现

场景别这么写该这么写
可选参数fun f(timeout: Int?) 里面 timeout ?: 30fun f(timeout: Int = 30)——默认参数
「没有元素」返回 List<T>?,调用方 ?.forEach返回 emptyList()——空集合就是「没有」
「没有字符」返回 String?返回 "",用 isEmpty() 判断
「查不到」和「查到了但是空的」要区分用 null 硬表示其中一种这时 T? 才是对的——它真的表达了两种状态

判据很简单:null 是否携带了别的值表达不了的信息。「超时时间没配」和「超时时间是 30」不是两种状态,是同一种;「用户不存在」和「用户存在但没有昵称」才是两种。

第二层:失败不要用 null 表示

  • fun parse(s: String): Config? 返回 null 时,调用方只知道失败了,不知道为什么——所有失败原因被压成了同一个值;
  • 标准库的 *OrNull 系列(toIntOrNull、firstOrNull)之所以合适,是因为它们的失败原因只有一种,null 已经说全了;
  • 失败原因不止一种时,用 Result<T>(runCatching { })或自己的 sealed 类型把原因带出来——见 13 章;
  • 但也别过度:私有的小函数返回 null 完全够用,值不值得建模看的是调用方要不要区分。

第三层:真要处理时,?.let 还是 if?

  • 单个值、一段短逻辑 → ?.let { };
  • 要写 else 分支 → 用 if。x?.let { A } ?: B 看着对称,实则有坑:当 A 自己求值为 null 时,B 也会执行(s?.let { null } ?: "fallback" 得到 "fallback",尽管 s 非空);
  • 多个可空值 → 别嵌套 let。a?.let { x -> b?.let { y -> ... } } 两层就已经很难读,三层就没人愿意维护了。用 if (a != null && b != null)(局部变量能智能转换)或者提前 ?: return 逐个剥掉。
// ── 第一层:设计成不会有 null ─────────────────
// ❌ 可选参数用可空类型
fun connect(host: String, timeout: Int?) {
    val t = timeout ?: 30            // 每个实现里都要兜一次底
}
// ✅ 默认参数
fun connect(host: String, timeout: Int = 30) { /* t 一定有值 */ }

// ❌ 「没有结果」返回可空集合
fun search(q: String): List<Item>? = null
search("kt")?.forEach { /* 调用方被迫处理两种「空」*/ }
// ✅ 空集合就是「没有」
fun search2(q: String): List<Item> = emptyList()
for (i in search2("kt")) { /* 空就是不进循环,天然正确 */ }

// ── 第二层:失败原因不止一种时,别用 null ────────
// ❌ 三种失败压成一个 null,调用方无从判断
fun load(path: String): Config? = null

// ✅ 用 sealed 把原因带出来(详见 13 章)
sealed interface LoadResult {
    data class Ok(val cfg: Config) : LoadResult
    data class NotFound(val path: String) : LoadResult
    data class Malformed(val line: Int) : LoadResult
}

// ✅ 或者用标准库的 Result(只关心成功/失败时够用)
val cfg = runCatching { parse(text) }
    .getOrElse { Config.default() }

// ── 第三层:?.let 与 if 的取舍 ────────────────
// ✅ 单值、无 else:let 最顺
nick?.let { render(it); track(it) }

// ⚠️ let ?: 的陷阱——lambda 返回 null 时右边也会执行
val s: String? = "x"
val r = s?.let { null } ?: "fallback"   // r == "fallback",尽管 s 非空

// ✅ 有 else 分支就老实用 if
if (s != null) render(s) else renderEmpty()

// ❌ 多个可空值嵌套 let:两层已难读
a?.let { x -> b?.let { y -> combine(x, y) } }
// ✅ 局部变量 + 条件判断,或提前退出
if (a != null && b != null) combine(a, b)
// ✅ 函数里最清爽的写法:逐个剥掉
fun go(a: String?, b: String?): String {
    val x = a ?: return "缺 a"
    val y = b ?: return "缺 b"
    return combine(x, y)          // 主干上全是非空值
}
x?.let { A } ?: B 不是三元表达式。它的语义是「先算 x?.let{A},结果为 null 就取 B」——所以只要 A 求值成 null,哪怕 x 完全非空,B 照样执行。"x"?.let { null } ?: "fallback" 得到 "fallback"。当 A 是有副作用的一整块代码(打日志、发请求)时,这个坑会表现成「明明走了 if 分支,else 也执行了」,能查很久。需要 if/else 语义就写 if,这是唯一安全的做法。另一个高频误用是 x?.let { } 只为了当判空的 if 用却不使用 it——那还不如直接写 if (x != null),读者不用去猜 lambda 的返回值是什么。
把可空性当作 API 契约来设计,而不是当作实现细节。函数签名里每出现一个 ?,就是在对所有调用方说「这里你必须处理没有值的情况」——这个负担要么值得,要么就该被消掉。一个实用的自检:如果每个调用方都在用同一个 ?: 默认值,那这个默认值就该搬进函数里当默认参数。返回类型同理:List<T>?、String?、Boolean? 出现时先问一遍「空集合 / 空串 / false 能不能表达同一件事」。

Kotlin 的异常语法看着和 Java 一模一样,但有两处关键差异:没有受检异常,以及 try 是表达式。前者是设计取舍,后者是日常写法的分水岭。

没有受检异常:所有异常都是「不受检」的

  • Java 里调用 throws IOException 的方法,不 try 就不给编译;Kotlin完全不管——在 Kotlin 里调用一个 Java 的 throws IOException 方法,不写任何 try 也照常编译运行;
  • Kotlin 的函数签名里根本没有 throws 这个位置,也就无从声明;
  • 取舍在哪:受检异常在实践中催生了大量 catch (e: Exception) { } 的空吞,以及被迫在函数签名上层层传染 throws;但代价是你从签名上看不出一个函数会不会抛,只能靠文档和 KDoc;
  • 反过来,Java 调用 Kotlin 时若需要 throws 声明(比如让 Java 侧能 catch),要在 Kotlin 函数上加 @Throws(IOException::class)——详见 12 章。

try 是表达式

  • 整个 try/catch 有值:正常路径取 try 块最后一行,异常路径取命中的 catch 块最后一行,可以直接赋给 val;
  • finally 块的值不参与——它只负责收尾,改不了整个表达式的结果(try { "12".toInt() } catch { -1 } finally { println(...) } 结果仍是 12,finally 照常执行);
  • 但绝大多数情况下,你要的其实是标准库的 *OrNull:toIntOrNull() 比三行 try 表达式清楚得多。

finally 与 use:资源一定要关

  • 手写 try { } finally { close() } 是能用,但样板多、还容易在 close() 自身抛异常时出岔子;
  • use 是 Closeable 上的扩展函数:reader.use { ... } 块结束(正常或异常)自动关闭,并且它也是表达式,块的值就是 use 的值;
  • 这就是 Kotlin 版的 try-with-resources,StringReader("abc").use { it.read().toChar() } 返回 'a' 并在返回后关闭。
// ── 没有受检异常 ──────────────────────────────
// Java 侧:public static String read() throws IOException
println(Throwy.read())     // ✅ 不写 try 也编译通过(Java 里这样写不给编译)

// ── try 是表达式 ──────────────────────────────
val n: Int = try {
    input.toInt()
} catch (e: NumberFormatException) {
    -1                              // 失败时整个表达式取 -1
} finally {
    println("收尾")                 // 照常执行,但不影响 n 的值
}

// 同一件事,标准库写法更短更清楚 —— 优先用这个
val n2 = input.toIntOrNull() ?: -1

// ── 主动抛出 ─────────────────────────────────
fun divide(a: Int, b: Int): Int {
    require(b != 0) { "除数不能为 0" }   // IllegalArgumentException
    return a / b
}
// require → IllegalArgumentException(参数不对,调用方的错)
// check / error → IllegalStateException(状态不对,自己的错)

// ── use:Kotlin 的 try-with-resources ─────────
val text = File("a.txt").bufferedReader().use { reader ->
    reader.readText()             // 块的值就是 use 的值
}                                    // 到这里 reader 一定已关闭(正常或异常都关)

// 等价的手写版本,样板明显更多
val reader = File("a.txt").bufferedReader()
val text2 = try { reader.readText() } finally { reader.close() }

// ── 把异常包成值:runCatching(详见 13 章)──
val r: Result<Int> = runCatching { input.toInt() }
println(r.getOrDefault(0))
没有受检异常意味着「会不会抛」只存在于文档里。你调用一个第三方 Kotlin 库的函数,编译器不会提醒它可能抛 IOException——这是 Kotlin 用编译期安全换来的便利,代价必须自己承担:写库函数时用 KDoc 的 @throws 标注,写应用时在架构边界(HTTP handler、消息消费者、协程根作用域)统一兜底,别指望在中间层被编译器提醒。另一个真会咬人的坑:runCatching 会连 CancellationException 一起吞掉,在协程里用它包 delay 之类的挂起函数,协程被取消后 onFailure 会拿到 JobCancellationException、而且后面的代码照常继续执行——这与「取消应该终止执行」的直觉完全相反,见 10 章。
「可预期的失败」和「意外」要分开处理。用户输入不是数字、文件不存在、网络超时——这些都在预期之内,用 *OrNull + ?: 或返回类型建模(见本章前面);数组越界、状态机走到不可能的分支——这些是 bug,让它抛出去、别 catch。判据:如果你 catch 之后写不出比「记个日志继续跑」更有意义的处理,那就不该 catch。另外 try 是表达式这件事也适用于 when 和 if,Kotlin 里绝大多数控制结构都有值,这是它比 Java 少一半临时变量的原因。

面向对象编程

本章覆盖:class / data / sealed / enum / object 五选一、构造器与属性的全部写法、默认 final 与 open、接口与伴生对象、by 的两种用法(接口委托与属性委托)。Kotlin 的类体系是对 Java 的一次「默认值重置」。

Kotlin 的类体系是对 Java 的一次「默认值重置」:类和方法默认 final、主构造器写在类头、属性不是字段。选型表先回答「这东西该是哪一种」。

五种类型怎么选

关键字什么时候用白送什么
class有行为、有身份的东西(服务、控制器)—
data class纯数据:DTO、配置、状态equals / hashCode / toString / copy / componentN(解构)——全部按主构造器里的属性生成
sealed class / sealed interface「有限几种情况之一」:结果、状态、事件when 的穷尽性检查——新增一种时所有分支处都会报错提醒你
enum class固定的一组常量,每个只有一个实例entries · valueOf · ordinal;可以带属性与方法
object单例线程安全的懒汉初始化
data objectsealed 层级里的无数据分支有意义的 toString(打印 Loading 而不是 Route$Loading@1b6d)
companion object类内部的「静态区」工厂方法、常量;Java 侧要加 @JvmStatic 才像静态方法
value class给原始类型套语义(UserId 而不是 Long)13 章
sealed 与 enum 怎么分每种情况要带不同的数据 → sealed;只是一组名字 → enum

构造器与属性

要什么怎么写
主构造器class User(val id: Long, var name: String)——参数前加 val / var 就同时成了属性
不当属性class User(id: Long) { … }——只在初始化时能用
初始化块init { require(id > 0) },按书写顺序执行
次构造器constructor(s: String) : this(s.toLong())——必须委托给主构造器
默认值取代重载class C(val a: Int = 0, val b: String = "")
属性不是字段每个属性自带 getter(var 还有 setter),Java 侧看到的是方法
自定义访问器val area get() = w * h(每次都算)· var n = 0; set(v) { field = v.coerceAtLeast(0) }
field访问器里代表那个隐藏的幕后字段;只在自定义访问器中可用
延迟lateinit var · val x by lazy { }
可见性public(默认)· internal(同一模块)· protected · private
嵌套类class A { class B } 是静态嵌套;要引用外部实例得写 inner class

继承与多态

关键字作用
openKotlin 的类与成员默认 final,要被继承/覆盖必须显式 open
override覆盖必须写,写错名字编译器会拦住;override 的成员默认仍可再被覆盖,要封死加 final override
abstract抽象类/成员,隐含 open
super.f()调父类实现;多个父接口时写 super<Base>.f()
接口可以有默认实现与属性声明(不能有幕后字段)
构造顺序父类 init 与属性初始化先于子类——父类构造里调 open 方法会读到子类还没初始化的字段
接口委托class Logger(impl: Writer) : Writer by impl——自动把接口方法转发给 impl,只覆盖要改的那几个
组合优于继承Kotlin 默认 final + 委托语法,就是在往这个方向推

属性委托(by 的另一种用法)

写法作用
by lazy { }首次访问才计算并缓存;默认线程安全(可传 LazyThreadSafetyMode.NONE 省开销)
by Delegates.observable(init) { _, old, new -> }值变化时回调
by Delegates.vetoable(init) { … }回调返回 false 则拒绝这次赋值
by Delegates.notNull()lateinit 的基本类型版本
by map属性值从 Map 里按名字取,解析动态配置时好用
自己写实现 getValue / setValue 操作符即可
// ① 数据用 data class:一行换来五样东西
data class User(val id: Long, val name: String, val vip: Boolean = false)

val u  = User(1, "张三")
val u2 = u.copy(vip = true)                  // 复制并改一个字段
val (id, name) = u                            // 解构
println(u == User(1, "张三"))                  // true(比内容)
println(u)                                    // User(id=1, name=张三, vip=false)

// ② 「有限几种情况」用 sealed:when 不用写 else
sealed interface UiState {
    data object Loading : UiState
    data class  Ok(val items: List<User>) : UiState
    data class  Err(val msg: String)      : UiState
}

fun render(s: UiState) = when (s) {
    UiState.Loading -> "加载中"
    is UiState.Ok  -> "共 ${s.items.size} 条"
    is UiState.Err -> "出错:${s.msg}"
}   // 将来加一种 Empty,这里会立刻编译报错 —— 这正是目的

// ③ 属性:自定义访问器与 field
class Rect(val w: Int, val h: Int) {
    val area get() = w * h                   // 每次访问都算,不占存储
    var label: String = ""
        set(v) { field = v.trim() }          // field = 幕后字段
}

// ④ 接口委托:只改要改的那一个方法
interface Repo { fun find(id: Long): User?; fun all(): List<User> }

class CachingRepo(private val inner: Repo) : Repo by inner {   // all() 自动转发
    private val cache = mutableMapOf<Long, User>()
    override fun find(id: Long) = cache.getOrPut(id) { inner.find(id) ?: return null }
}

// ⑤ 属性委托:贵的初始化推迟到第一次用
val config: Config by lazy { parse(File("app.yaml").readText()) }
data class 只按主构造器里的属性生成 equals / hashCode。写在类体里的属性不参与比较,两个「看起来不同」的对象会判定相等——这是很隐蔽的一类 bug。
父类构造函数里调用 open 方法:子类的覆盖版本会在子类属性初始化之前执行,读到的全是 null 或 0。Kotlin 默认 final 正是为了压住这类问题,所以给类加 open 之前先想清楚有没有别的办法(比如接口委托)。
选型两句话:装数据用 data class,「几种情况之一」用 sealed。这两个用对了,Kotlin 的建模能力就发挥了一大半。
sealed + when 表达式不要写 else——放弃 else 换来的是「以后加一种情况时,编译器把所有该改的地方都列给你」。

Kotlin 的类头本身就是构造器,属性也不是「字段 + getter/setter」的简写——它就是一对访问器,字段只是可能存在的实现细节。这两点决定了初始化顺序和 field 关键字的行为。

初始化顺序:属性初始化器和 init 块按书写顺序交替执行

不是「先所有属性、再所有 init」,而是从上到下依次执行。一个交替书写的类,输出是:

执行顺序来源
1第一个属性初始化器
2第一个 init 块
3第二个属性初始化器
4第二个 init 块
5次构造器的函数体(在委托给主构造器之后)

推论:init 块里只能用到写在它上面的属性;次构造器体里能用全部属性,因为主构造器已经跑完了。

属性不是字段:field 只在访问器里存在

  • Kotlin 语言层面没有字段,只有属性。val x = 1 生成的是一个私有字段 + 一个公有 getX();
  • 幕后字段(backing field)只在需要时才生成:写了自定义 getter 且不引用 field 的属性,根本没有字段——它每次读都在执行代码(这正是 05 章「智能转换对自定义 getter 失效」的原因);
  • field 这个标识符只在自定义 getter/setter 内部可见,用来读写那个幕后字段;在别处写 field 只是个普通名字。

默认 final:和 Java 相反

  • 类不加 open 不能被继承,方法不加 open 不能被重写——这是 Kotlin 把「Effective Java 第 19 条:要么为继承而设计并写好文档,要么禁止继承」直接做进了语言默认值;
  • 代价是大量 Java 框架不能开箱即用:Spring 的 @Transactional、JPA 实体、Mockito 的 mock 都要求能被子类化。解法是 all-open / kotlin-spring / kotlin-jpa 编译器插件,它们按注解自动给类加 open;
  • const val 是唯一会变成真正 JVM 静态常量的属性形式(见本章伴生对象卡的 javap 输出)。
// 主构造器写在类头,参数前加 val/var 就直接成为属性
class User(
    val name: String,              // 只读属性
    var age: Int,                   // 可变属性
    val role: String = "user"      // 默认值
) {
    // ↓ 属性初始化器和 init 块按书写顺序交替执行
    val slug = name.lowercase()      // ① 
    init { require(age > 0) { "年龄必须大于 0" } }   // ②
    val label = "$slug#$age"          // ③ 能用 slug,因为它写在上面
    init { println(label) }            // ④

    // 次构造器必须委托给主构造器;它的函数体最后执行(⑤)
    constructor(name: String) : this(name, age = 18) {
        println("次构造器体")
    }

    // 自定义 getter:没有幕后字段,每次读都在算
    val isAdmin: Boolean
        get() = role == "admin"

    // 自定义 setter:用到了 field,所以有幕后字段
    var email: String = ""
        set(value) {
            require("@" in value)
            field = value.lowercase()   // field 只在这里可见
        }
}

// 默认 final:不加 open 就继承不了
open class Animal(val name: String) {
    open fun sound() = "..."
}
class Dog(name: String) : Animal(name) {
    override fun sound() = "汪汪"
}

// ⚠️ 基类构造期读 open 属性 —— 输出见 pitfall
open class Base {
    open val tag: String = "base"
    init { println("Base.init tag=$tag") }
}
class Sub : Base() {
    override val tag: String = "sub"
    init { println("Sub.init tag=$tag") }
}
基类构造期间读 open 属性会读到「还没赋值」的状态——即使它的类型是非空的。上面的 Base/Sub,输出是:
Base.init tag=null
Sub.init tag=sub
注意第一行:tag 声明成非空 String,却打印出 null。原因是基类构造器先跑,而此时子类的 override val tag = "sub" 尚未执行,读到的是子类字段的零值。这个 null 能悄悄流进你的非空类型里,是 Kotlin 空安全少数几个被绕过的地方(和 05 章的平台类型并列)。规避方式:基类的 init 块和属性初始化器里不要碰 open 成员;确实需要就改用 by lazy 延迟到第一次真正使用时求值,或者把初始化挪到一个显式的 initialize() 方法里。
次构造器在 Kotlin 里很少需要。「几种不同的构造方式」用默认参数就够了——class User(val name: String, val age: Int = 18) 一句顶 Java 的两个重载构造器;「从别的类型转过来」用伴生对象里的工厂函数(User.fromJson(...))比次构造器语义更清楚、还能起名字、还能返回缓存实例。真正需要次构造器的场景基本只剩「继承一个有多个构造器的 Java 类」。要给参数很多的类提供便利,先想默认参数 + 具名实参,别写 Builder。

Kotlin 的继承规则可以概括成一句:凡是要被继承 / 重写的,必须显式说出来。这条规则贯穿类、方法、属性三层,加上 override 强制标注,把 Java 里靠约定和 @Override 注解维持的东西变成了编译器强制。

三个修饰符的分工

修饰符含义默认
open允许被继承 / 重写不写就是 final(与 Java 相反)
abstract没有实现,子类必须实现;隐含 open抽象类不能实例化
override确认这是在重写父类成员,不写就编译错误重写后的成员仍然是 open 的,除非再标 final override

属性也能重写

  • open val 可以被 override val 或 override var 重写(val → var 是加能力,允许;反过来不行);
  • 甚至可以用一个自定义 getter 重写一个有幕后字段的属性,反之亦然——因为对外看到的只是访问器;
  • 构造器参数也能带 override:class Emp(override val name: String) : Person(name)。

多重继承的歧义:Kotlin 强制你选

  • 类只能有一个父类,但接口可以有多个,而接口能带默认实现——于是两个接口给出同名默认实现时会冲突;
  • Kotlin 不像 Java 那样只在某些情况下报错,而是要求你显式重写并用 super<接口名>.方法() 指定调哪个;
  • 这个 super<T> 语法是 Kotlin 独有的,Java 里对应的是 接口名.super.方法()。
// 抽象类:不能实例化,抽象成员隐含 open
abstract class Shape(val name: String) {
    abstract fun area(): Double          // 无实现,子类必须给
    abstract val sides: Int               // 抽象属性
    open fun describe() = "$name 面积 ${area()}"  // 有实现,允许重写
    fun id() = name.hashCode()             // 不加 open → 子类改不了
}

class Circle(val r: Double) : Shape("圆") {
    override fun area() = Math.PI * r * r
    override val sides = 0
    // 不写 override 的报错:
    //   error: 'area' hides member of supertype 'Shape' and needs an 'override' modifier.
}

// 重写后仍是 open,除非封死
open class Rect(val w: Double, val h: Double) : Shape("矩形") {
    final override fun area() = w * h    // 到此为止,孙子类不能再改
    override val sides = 4
}

// 属性重写:val 可以被 var 重写(加能力)
open class Person(open val name: String)
class Employee(override var name: String, val no: Int) : Person(name)

// 多接口默认实现冲突:必须显式选
interface A { fun hello() = "A" }
interface B { fun hello() = "B" }
class C : A, B {
    override fun hello() = super<A>.hello() + super<B>.hello()  // "AB"
    // 不重写的报错:
    //   error: class 'C' must override 'hello' because it inherits multiple interface methods for it.
}

// 多态调用:静态类型是 Shape,实际执行子类实现
val shapes: List<Shape> = listOf(Circle(1.0), Rect(2.0, 3.0))
shapes.forEach { println(it.describe()) }
Kotlin 默认 final 会让一批 Java 框架在运行时才炸。Spring 的 @Transactional、@Cacheable 等靠 CGLIB 生成子类做代理,JPA 需要给实体生成代理,Mockito 的 mock() 也要子类化——类是 final 的时候,这些要么直接抛异常,要么更糟:注解静默失效(事务根本没开,测试还全绿)。别为此把业务类全标 open,正确做法是上 all-open 编译器插件(Spring 项目直接用 kotlin-spring,JPA 用 kotlin-jpa),它按注解自动放开对应的类。另一个容易忘的点:override 的成员默认仍然是 open 的——你以为重写完就封死了,实际孙子类还能继续改,要封死得写 final override。
先问「这里真的需要继承吗」。Kotlin 默认 final 不是为了给你添麻烦,是在提醒继承是最强的耦合——父类改一行,所有子类的行为都可能变。Kotlin 给了两个更轻的替代品:接口 + 类委托(by,见下下卡,装饰器零样板)和 sealed 类型 + when(见本章后面,把「多种情况」摊平成数据而不是继承树)。真需要继承时,把可重写的点刻意挑出来标 open,而不是整个类 open 完事——那等于放弃了 Kotlin 在这里给你的全部保护。

data class 一个关键字换来五组方法。但它生成的规则有一条硬边界——只看主构造器里的属性——不知道这条的人几乎都会踩一次坑。

它到底生成了什么(javap)

对 data class User(val id: Int, val name: String) { var note: String = "" },javap -p 输出的成员是:

生成的成员作用
component1() / component2()支撑解构声明 val (id, name) = user
copy(int, String) + copy$default(...)带默认值的拷贝,$default 是默认参数的实现机制
toString() / hashCode() / equals(Object)值语义三件套
getNote() / setNote(String)普通属性的访问器,但没有 component3,copy 也没有它的参数

坑一:类体里的属性完全不算数

两个 User,id/name 相同但 note 一个是 "AAA"、一个是 "BBB":

  • a == b → true;a.hashCode() == b.hashCode() → true;
  • a.toString() → User(id=1, name=Ann),note 根本不出现;
  • a.copy().note → "",拷贝出来的对象 note 被重置成了默认值,不是 "AAA"。

最后一条尤其阴——copy() 表面上是「复制一份」,实际只复制主构造器里的东西。判据很干脆:要参与相等性 / 拷贝的属性,必须写在主构造器里。

坑二:copy 是浅拷贝

data class Team(val name: String, val members: MutableList<String>):t2 = t1.copy(name = "t2") 之后 t2.members.add("y"),t1.members 也变成了 [x, y],且 t1.members === t2.members 为 true——两个对象共享同一个列表。copy 只是把引用照抄一遍。所以 data class 的属性应该是不可变类型(List 而非 MutableList、val 而非 var),否则「值语义」只是个错觉。

data class 的四条限制(编译器原话)

写法报错
open data classmodifier 'open' is incompatible with 'data'.
data class NoArgs()data class must have at least one primary constructor parameter.
data inner classmodifier 'data' is incompatible with 'inner'.
data class Emp(...) : Person(...)合法——data class 不能被继承,但可以继承别的 open 类(通过)
data class User(val id: Int, val name: String) {
    var note: String = ""            // ⚠️ 不在主构造器里 → 不参与任何生成方法
}

val a = User(1, "Ann").apply { note = "AAA" }
val b = User(1, "Ann").apply { note = "BBB" }

println(a == b)              // true  ← note 不同,仍然相等
println(a)                   // User(id=1, name=Ann)  ← note 不出现
println(a.copy().note)       // ""    ← 被重置成默认值,不是 "AAA"

// 解构:靠生成的 componentN
val (id, name) = a

// copy 的正确用法:不可变数据的「改一个字段」
val renamed = a.copy(name = "Bob")   // User(id=1, name=Bob)

// ⚠️ copy 是浅拷贝:可变容器会被共享
data class Team(val name: String, val members: MutableList<String>)
val t1 = Team("t", mutableListOf("x"))
val t2 = t1.copy(name = "t2")
t2.members.add("y")
println(t1.members)          // [x, y]  ← t1 也变了!
println(t1.members === t2.members)   // true —— 同一个对象

// ✅ 正确做法:属性用不可变类型
data class Team2(val name: String, val members: List<String>)
val u1 = Team2("t", listOf("x"))
val u2 = u1.copy(members = u1.members + "y")   // 生成新列表,u1 不受影响

// javap -p User 生成的成员:
//   public final int component1();
//   public final java.lang.String component2();
//   public final User copy(int, java.lang.String);
//   public static User copy$default(User, int, java.lang.String, int, java.lang.Object);
//   public java.lang.String toString();
//   public int hashCode();
//   public boolean equals(java.lang.Object);
//   (note 只有 getNote/setNote,没有 component3)
把可变字段放在类体里,会得到一个「相等但不相同」的类。两个 note 不同的 User 判等为 true、哈希相同——这意味着把它们放进 HashSet 只会留下一个,放进 Map 当键会互相覆盖,而 toString() 打出来还看不见差异,调试时盯着日志也看不出问题在哪。同理 copy() 会静默丢掉这些字段。只要一个属性影响对象的身份,就必须写进主构造器;只是缓存/派生的中间值放类体里没问题,但要意识到 copy 之后它是空的。还有一条:data class 的 equals 用的是各属性的 equals,数组属性会按引用比较(ByteArray 等),这时必须手写 equals/hashCode,IDE 生成的模板会用 contentEquals。
data class 是给「值」用的,不是给「实体」用的。判据是相等性该怎么定义:两个坐标点,字段一样就是同一个点 → data class;两个用户,就算所有字段都一样也可能是不同的人(数据库里 id 不同)→ 普通 class + 手写按 id 的 equals。另外 data class 的 copy + 具名实参组合起来,就是不可变数据结构的标准更新姿势:state.copy(loading = false, items = newItems)——比手写 Builder 短,还是类型安全的。嵌套很深时可以配合 arrow-kt 的 optics,但多数项目直接写两层 copy 就够。

enum 和 sealed 解决的是同一类问题:「这个东西只可能是这么几种」。区别在于 enum 的每种情况是一个值(单例),sealed 的每种情况是一个类型(可以有自己的数据、可以有多个实例)。选错了会在需求变化时很麻烦。

穷尽 when:编译器替你盯着分支

  • 对 sealed 类型或 enum 做 when,漏了分支直接编译错误,不写 else 也不放过。报错原文:
    error: 'when' expression must be exhaustive. Add the 'Loading' branch or an 'else' branch.
    enum 同理:Add the 'GREEN' branch or an 'else' branch.
  • 作为语句用(不取值)也一样强制——fun f(s: S) { when (s) { S.A -> ... } } 照样报同一条错误。这点和早期 Kotlin 不同,别按老经验以为「不当表达式用就不检查」;
  • 这条规则的全部价值在于「加一种新情况时,编译器把所有该改的地方指给你」。所以:能不写 else 就不要写——一个 else -> throw ... 就把这份保护完全废掉了。

怎么选

enum classsealed class / interface
每种情况是一个单例值一个类型,可有多个实例
能不能带不同数据不能(所有值共用同一组属性)能(Success(data) vs Error(msg))
能不能遍历所有情况能(entries)不能(可以有无限个实例)
典型用途星期、状态码、配置枚举UI 状态、AST 节点、解析结果
子类位置—同一模块内即可(1.5 起放宽,不必同文件)

sealed interface 比 sealed class 更常用:接口能被多重实现,于是一个类型可以同时属于两个封闭层次(比如既是 UiState 又是 Loggable);而且没有构造器,做纯标记更干净。只有需要在父层放共享状态/字段时才用 sealed class。

enum 的 entries

  • entries(1.9 起稳定)返回的是 kotlin.enums.EnumEntriesList,它是一个 List,可以 [1] 索引、可以直接进集合 API;
  • 关键差别是它每次返回同一个实例(Color.entries === Color.entries 为 true),而 values() 每次都新建一个数组(Color.values() === Color.values() 为 false)——旧 API 每次调用都在拷贝,正是为了防止调用方改动内部数组;
  • 准确说法:在 kotlinc 2.4.10 上 values() 仍然正常编译、不产生任何弃用警告。官方文档把 entries 定为推荐写法、values() 为遗留写法,但它并没有被 @Deprecated 标记。新代码用 entries,旧代码不必为此专门改。
// sealed interface:每种情况带自己的数据
sealed interface UiState {
    data class Success(val data: String) : UiState
    data class Error(val msg: String) : UiState
    data object Loading : UiState        // 无数据的情况用 data object
}

// 穷尽 when:不写 else,加新情况时编译器会点名所有该改的地方
fun render(s: UiState): String = when (s) {
    is UiState.Success -> s.data          // 智能转换:s 已是 Success
    is UiState.Error   -> "错误:${s.msg}"
    UiState.Loading    -> "加载中…"        // object 用 == 比较,不需要 is
}
// 漏掉 Loading 的报错:
//   error: 'when' expression must be exhaustive.
//          Add the 'Loading' branch or an 'else' branch.
// 注意:当语句用(不取返回值)也照样报这条错

// enum:每个值是单例,可以带属性和方法
enum class Color(val hex: String) {
    RED("#FF0000"),
    GREEN("#00FF00"),
    BLUE("#0000FF");

    fun isWarm() = this == RED
}

// entries vs values()(均已)
println(Color.entries)                      // [RED, GREEN, BLUE]
println(Color.entries.javaClass.name)       // kotlin.enums.EnumEntriesList
println(Color.entries[1])                   // GREEN —— 它是 List,可索引
println(Color.entries === Color.entries)   // true —— 同一个实例
println(Color.values() === Color.values())   // false —— 每次新建数组
Color.entries.filter { it.isWarm() }         // 直接用集合 API,不用先 toList()

// 什么时候 enum 不够用:需要不同数据 → 改 sealed
// ❌ enum 塞不下差异化字段
// enum class Result { SUCCESS(data?), ERROR(msg?) }   // 所有值共用同一组属性
// ✅ sealed 各带各的

// sealed 也能建模递归结构(表达式树等)
sealed interface Expr {
    data class Num(val v: Int) : Expr
    data class Add(val l: Expr, val r: Expr) : Expr
}
fun eval(e: Expr): Int = when (e) {
    is Expr.Num -> e.v
    is Expr.Add -> eval(e.l) + eval(e.r)
}
别把自己的 sealed 类型命名成 Result。Kotlin 标准库里已经有一个 kotlin.Result(runCatching 的返回类型,本页 13 章会用到),自定义一个同名类型后,同一文件里两者会互相遮蔽:你的 Result 在本文件优先,但 runCatching { } 的返回值仍是标准库那个,于是 val r: Result<Int> = runCatching { ... } 会报出一条看起来毫无道理的类型不匹配;跨文件时行为还取决于导入顺序。改成 UiState、LoadOutcome、ApiResponse 这类领域名字,既不撞名也更表意。同一条原则适用于所有 stdlib 高频名:Result、Pair、Triple、Sequence、Comparator、Error。另外别为了「让编译器闭嘴」给穷尽 when 加 else —— 那正好废掉了 sealed 的唯一价值:以后加一种情况时,编译器不会再告诉你哪里漏改了,bug 会安静地跑到线上。
把子类嵌在 sealed 类型内部(UiState.Success)而不是平铺在顶层。好处是名字自带命名空间——UiState.Error 比一个孤零零的顶层 Error 清楚得多,也不会和别的模块撞名。data object(1.9 起)用于「没有数据的情况」:它比 object 多生成一个像样的 toString()——同一个 sealed 层次里,data object Loading 打印出 Loading,而普通 object Plain 打印出 S$Plain@ 加一串哈希,日志里差别很明显。sealed 的子类现在只要在同一模块内即可(1.5 放宽),所以按功能拆文件完全可行,不必挤在一个文件里。

这三样东西各自替掉了 Java 的一块样板:接口默认实现替掉抽象类、by 委托替掉手写转发方法、companion object 替掉 static。前两个几乎全是收益,第三个则藏着 Kotlin 最常见的认知偏差——它不是静态。

接口:可以有默认实现和属性,但没有幕后字段

  • 方法可以带默认实现,所以「抽象类只是为了共用几个方法」这种场景直接用接口就行;
  • 接口可以声明属性,但不能有幕后字段——只能是抽象属性(实现类提供)或带自定义 getter 的派生属性。所以接口里放不了状态,这是它和抽象类的实质区别;
  • object : 接口 { } 是匿名对象(Java 匿名内部类的对应物),常用于一次性的回调实现。

by 类委托:装饰器零样板

class Prefixed(private val inner: Logger) : Logger by inner 让编译器为接口的每个方法生成一个转发方法。javap -p

生成的成员说明
private final Logger inner;持有被委托对象的字段
public void log(String);你自己重写的
public void warn(String);编译器生成的转发——直接调 inner.warn(...)

注意 warn 在接口里是有默认实现的,委托依然为它生成了转发方法。这就引出了下面 pitfall 里那个必踩的坑。

companion object:它是一个单例对象,不是 static

class Cfg { companion object { const val MAX = 50; fun make() = Cfg() } } 的 javap

  • Cfg 里只有 public static final Cfg$Named Named; 和 public static final int MAX;——只有 const val 变成了真正的静态字段;
  • make() 落在 Cfg$Named 这个独立的类上,签名是 public final Cfg make();——实例方法,Java 侧得写 Cfg.Named.make();
  • 正因为它是对象而非静态,它能实现接口、能被扩展函数扩展、能作为参数传递——这是 Java static 做不到的。要在 Java 侧看到真静态方法,加 @JvmStatic(见 12 章)。
// 接口:默认实现 + 属性(无幕后字段)
interface Logger {
    val prefix: String                       // 抽象属性,实现类提供
    val banner: String get() = "[$prefix]"    // 派生属性(自定义 getter)
    // val count: Int = 0                     ❌ 接口里不能有幕后字段,报
    //   error: property initializers in interfaces are prohibited.

    fun log(msg: String)                      // 抽象
    fun warn(msg: String) = log("W: $msg")   // 默认实现
}

class Console : Logger {
    override val prefix = "con"
    override fun log(msg: String) { println("C: $msg") }
}

// 类委托:未重写的方法自动转发给 inner
class Prefixed(private val inner: Logger) : Logger by inner {
    override fun log(msg: String) { inner.log("[p] $msg") }
}

val p = Prefixed(Console())
p.log("hi")        // C: [p] hi   ← 走了重写
p.warn("careful")  // C: W: careful  ← ⚠️ 不是 "C: [p] W: careful"!见 pitfall

// 伴生对象:类的单例,不是 static
class Cfg private constructor(val id: Long) {
    companion object {                        // 可以起名字:companion object Named
        const val MAX_LEN = 50                // 唯一会变成 JVM 静态字段的形式
        fun create(id: Long) = Cfg(id)        // 能访问私有构造器 → 工厂
    }
}
val c = Cfg.create(1L)

// 伴生对象能实现接口 —— static 做不到的事
interface Factory<T> { fun of(id: Long): T }
class Item private constructor(val id: Long) {
    companion object : Factory<Item> {
        override fun of(id: Long) = Item(id)
    }
}
fun <T> build(f: Factory<T>) = f.of(1L)
build(Item)      // ✅ 直接把伴生对象当参数传

// object:单例 与 匿名对象
object Registry {                    // 单例,首次访问时初始化
    private val items = mutableListOf<String>()
    fun add(s: String) { items += s }
}
Registry.add("a")

val once = object : Logger {         // 匿名对象,一次性实现
    override val prefix = "tmp"
    override fun log(msg: String) { println(msg) }
}
by 委托不产生多态:被委托对象调用自己的方法时,不会走到你的重写上。上面的 Prefixed:p.warn("careful") 输出的是 C: W: careful 而不是 C: [p] W: careful。原因是编译器给 warn 生成的转发实现是 inner.warn(...),而 Logger.warn 的默认实现里调的 log 是 inner 自己的 log——它根本不知道有个 Prefixed 包在外面。这和继承里 this 会动态分派完全不同,也是「委托 ≠ 继承」最锋利的一刀。凡是接口的默认实现内部会回调其它方法,用 by 装饰时都必须把这些方法一起重写,否则装饰逻辑会被静默绕过。另外一个类只能有一个 companion object(第二个报 only one companion object is allowed per class),需要多组静态成员就用具名 object。
by 委托最实用的场景是「给一个接口加一层横切逻辑」——日志、计时、重试、缓存。相比继承,它不要求原类是 open、不受单继承限制、还能在运行时换掉被委托对象。搭配「委托给构造器参数」的写法 class A(b: B) : B by b,几十个方法的接口也只需写你关心的那几个。object 单例是线程安全的懒加载(靠 JVM 的类初始化锁,等价于 Java 的 holder idiom),不用自己写双重检查锁。伴生对象里的 const val 才是编译期常量,会被内联到调用方——所以改了它必须重新编译所有调用方,跨模块公开的 const val 要谨慎。

前面讲的 by 委托的是接口实现,这里的 by 委托的是属性的 get/set。写法都是 by,机制完全不同:编译器把 val x by d 翻译成「读 x 时调 d.getValue(thisRef, ::x)」,于是「怎么存、怎么算」被抽出来变成了可复用的对象。

by lazy:三种线程模式,代价不同

模式行为什么时候用
SYNCHRONIZED(默认)加锁,保证只计算一次不确定线程情况时的安全默认值
PUBLICATION允许多线程并发计算,但只有第一个完成的结果被采用计算便宜且无副作用,想省掉锁
NONE完全不同步确定单线程访问时——多线程下会重复计算甚至读到半初始化的值
  • lazy 只能修饰 val;要可变的延迟属性用 lateinit var(见 05 章)或 Delegates.notNull();
  • 它缓存异常吗?不缓存——初始化抛异常后,下次访问会重新尝试计算。

标准库的三个 Delegates(行为均为)

  • Delegates.observable(初值) { prop, old, new -> }——赋值之后回调,改不了结果,适合发通知/记日志;
  • Delegates.vetoable(初值) { prop, old, new -> Boolean }——赋值之前回调,返回 false 则丢弃这次赋值。score 用 vetoable { new >= 0 },赋 10 后再赋 -5,读出来仍是 10;
  • Delegates.notNull<T>()——lateinit 的基本类型版本。未赋值就读,抛 java.lang.IllegalStateException: Property <属性名> should be initialized before get.

by map 与自定义委托

  • val host: String by map——标准库给 Map 加了 getValue 扩展,属性名即为键。键不存在时抛 java.util.NoSuchElementException: Key port is missing in the map.,不是返回 null;
  • 自定义委托有两条路:① 提供 operator fun getValue(thisRef, property)(var 再加 setValue),靠约定,不用实现任何接口;② 实现 ReadOnlyProperty<T, V> / ReadWriteProperty<T, V> 接口——它们是 SAM(只有单个抽象方法的接口),可以直接写成 lambda:val cfg: String by ReadOnlyProperty { _, p -> "from-" + p.name }(得到 from-cfg);
  • 第二个参数 KProperty<*> 带着属性的元信息(name、注解等),by map 和各种「按属性名去查」的委托全靠它。
import kotlin.properties.Delegates
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

// by lazy:首次访问才算,之后缓存
class Repo {
    val db: Database by lazy {            // 默认 SYNCHRONIZED
        println("建立连接…")                  // 只在第一次用到 db 时打印
        Database.connect()
    }
    // 确定单线程访问时可省掉锁(多线程下会重复计算,慎用)
    val cache: Index by lazy(LazyThreadSafetyMode.NONE) { buildIndex() }
}

// observable:赋值之后回调(改不了结果)
class Form {
    var name: String by Delegates.observable("") { _, old, new ->
        println("name: '$old' → '$new'")
    }
}

// vetoable:赋值之前回调,返回 false 就丢弃这次赋值
class Player {
    var score: Int by Delegates.vetoable(0) { _, _, new -> new >= 0 }
}
val pl = Player()
pl.score = 10;  println(pl.score)   // 10
pl.score = -5;  println(pl.score)   // 10 —— 被否决,值没变

// notNull:lateinit 的基本类型替代品
class Conn { var port: Int by Delegates.notNull() }
// 未赋值就读,抛:
//   java.lang.IllegalStateException: Property port should be initialized before get.

// by map:属性名即键,解析配置/JSON 时省事
class Config(map: Map<String, Any?>) {
    val host: String by map
    val port: Int    by map
}
Config(mapOf("host" to "localhost", "port" to 8080)).host   // localhost
// 缺键时抛:
//   java.util.NoSuchElementException: Key port is missing in the map.

// 自定义委托 ①:约定式,实现 getValue / setValue 即可
class Trimmed {
    private var v = ""
    operator fun getValue(o: Any?, p: KProperty<*>) = v
    operator fun setValue(o: Any?, p: KProperty<*>, value: String) {
        v = value.trim()                    // 存入前自动去空白
    }
}
class Post { var title: String by Trimmed() }

// 自定义委托 ②:实现 ReadOnlyProperty 接口(SAM,可写成 lambda)
class T {
    val cfg: String by ReadOnlyProperty { _, p -> "from-${p.name}" }
}
println(T().cfg)      // from-cfg —— KProperty 带着属性名
by lazy 的默认模式是 SYNCHRONIZED,换成 NONE 前必须真的确定只有单线程访问。NONE 下多个线程同时首次访问,会各算各的、还可能读到另一个线程写了一半的对象——这类 bug 只在高并发下偶发,几乎无法复现。此外 lazy 不缓存异常:初始化块抛异常后,下一次访问会重新执行整个块,如果这个块有副作用(写文件、发请求),就会执行多次。by map 缺键时抛的是 NoSuchElementException 而不是返回 null(Key port is missing in the map.),拿它解析外部 JSON 时必须先保证键齐全,或者把属性声明成可空类型并用 withDefault 包一层 map。最后注意这里的 by 和前面的类委托 by 是两套完全不同的机制:类委托要求右边实现了同一个接口,属性委托要求右边有 getValue/setValue——看到 by 先看它出现在类头还是属性声明上。
选型口诀:初始化贵、可能用不到 → by lazy;赋值时要插逻辑(校验 / 通知 / 归一化)→ observable / vetoable / 自定义;非空但构造时没法赋值 → lateinit var(引用类型)或 Delegates.notNull()(基本类型);同一套「按名字取值」的逻辑要在很多属性上重复 → 自定义委托。自定义委托的杀手锏是拿得到属性名(KProperty.name):SharedPreferences / 环境变量 / 配置中心的读取器写成委托后,val timeout: Int by env() 就自动去读 timeout 这个键,键名再也不会和属性名写岔。别为炫技给普通属性套委托——每个委托属性都会多一个对象和一次间接调用,直接写字段最省心。

集合与序列

本章覆盖:容器选型与复杂度、「只读不等于不可变」、集合操作符按用途反查(map / filter / fold 全族谱)、Sequence 什么时候真的更快、以及可变性与并发下的拷贝。

Kotlin 集合有两条 Java 没有的线:只读接口与可变接口分开,以及惰性的 Sequence。先看选型,具体操作符翻下一张表。

选哪个容器

类型创建查找什么时候用
ListlistOf(1, 2) · mutableListOf() · buildList { }按下标 O(1),按值 O(n)默认选它
SetsetOf() · mutableSetOf()O(1)去重、判存在;linkedSetOf() 保插入顺序,sortedSetOf() 排序
MapmapOf("a" to 1) · mutableMapOf() · buildMap { }O(1)按键查——循环里 find { it.id == x } 时就该换它
ArrayarrayOf(1, 2) · IntArray(10)O(1)只在互操作、性能敏感、vararg 时用;日常用 List
ArrayDequeArrayDeque<Int>()两端 O(1)队列与栈
Sequencelist.asSequence() · generateSequence { } · sequence { yield(x) }惰性见下
空集合emptyList() · listOfNotNull(a, b)—返回空集合而不是 null
并发ConcurrentHashMap · CopyOnWriteArrayList(JDK 的)—Kotlin 标准库没有并发集合,直接用 Java 的

只读 ≠ 不可变

要点说明
List<T>只读接口:没有 add / remove
MutableList<T>可变接口,是前者的子接口
关键一条listOf() 返回的对象在 JVM 上仍是普通 ArrayList:如果别处持有同一个对象的 MutableList 引用,它照样会被改——你手上的只是一个「不能改」的视角,不是「不会变」的保证
要真正的快照list.toList() 复制一份
对外暴露内部 MutableList、对外 List:private val _items = mutableListOf<T>(); val items: List<T> get() = _items(更严可用 .toList())
不可变集合库kotlinx.collections.immutable 提供真正的持久化集合
并发下只读接口不提供任何线程安全保证

Sequence:什么时候值得

List 上的链式调用Sequence
执行方式每一步都生成一个新集合(急切)逐元素穿过整条链(惰性)
中间开销n 个操作符 = n 个临时集合0 个
什么时候更快数据量小(几百以内)时 List 反而更快:没有迭代器开销元素多、操作符多、或能提前结束(first / take / any)
无限序列做不到generateSequence(1) { it * 2 }.take(10)
怎么用—list.asSequence().filter { }.map { }.toList()——末尾必须有终端操作,否则什么都不会发生
判据别默认加 asSequence():只有在「元素上万 + 多个操作符」或「要提前退出」时才有收益,其余情况是负优化
// ① 只读接口挡不住别人改:需要快照就复制
class Cart {
    private val _items = mutableListOf<Item>()
    val items: List<Item> get() = _items          // 外部看到只读视角
    fun snapshot(): List<Item> = _items.toList()  // 真正的快照
}

// ② 循环里按条件找 = O(n²),先建 Map
val byId = users.associateBy { it.id }         // Map<Long, User>
orders.forEach { o -> byId[o.userId]?.let { use(it) } }
// 慢写法:orders.map { o -> users.first { it.id == o.userId } }

// ③ Sequence:元素多又要提前退出时才有意义
val firstBig = hugeList
    .asSequence()                               // 惰性
    .map { expensive(it) }
    .first { it > 1000 }                         // 找到就停,后面的 expensive 不会执行

val powers = generateSequence(1) { it * 2 }      // 无限序列
    .take(10)
    .toList()                                  // [1, 2, 4, …, 512]

// ④ 构建集合:buildList 比先建可变再转只读干净
val rows = buildList {
    add(header())
    if (showTotal) add(total())
    addAll(items.map(::toRow))
}                                                // 结果是 List(只读)
listOf() 给的不是不可变集合,只是一个不带修改方法的接口。把内部的 MutableList 直接 get() 出去,调用方一个 as MutableList 就能改你的内部状态——真要防护得 toList() 复制。
asSequence() 后忘了终端操作,整条链一行都不会执行,而且不报错。看到「代码明明写了却没效果」,先检查末尾有没有 toList() / count() / forEach。
两条默认动作:声明与返回类型一律用只读接口 List / Map(要可变的那一面只留在内部),以及「循环里按条件找元素」立刻换 associateBy + Map。
拿不准该不该上 Sequence:数据量没到上万就别加,它的收益需要足够多的元素来摊薄迭代器开销。

这是一张反查表:从「我要干的事」查「叫什么名字」。为什么这么选、有什么坑,前四卡已经讲透,这里只管查得快。

变换与过滤

我想……用
每个元素换个样子map { }
换个样子,顺手丢掉转换失败的mapNotNull { }
一个元素展开成多个,再拉平flatMap { };已经是 List<List<T>> 就 flatten()
只留符合条件的filter { } / 反过来 filterNot { }
把 List<T?> 变成 List<T>filterNotNull()
只留某个子类型的元素filterIsInstance<Dog>()(结果类型也收窄了)
去重distinct() / 按某个字段去重 distinctBy { }
知道下标的 map / filtermapIndexed { i, v -> } / filterIndexed
两个列表配对zip(other)(按短的截断)/ zip(other) { a, b -> }

聚合与判断

我想……用
累加一个数字字段sumOf { it.price }
自定义累加,有初始值fold(init) { acc, x -> }
自定义累加,无初始值(集合必须非空)reduce { acc, x -> }
要每一步的中间结果runningFold(init) { } / runningReduce { }
数个数size(集合)/ count { }(带条件)
找最大/最小maxOrNull() / maxByOrNull { } / maxWithOrNull(cmp)
拼成字符串joinToString(", ", prefix = "[", postfix = "]") { }
「有没有 / 是不是全都 / 一个都没有」any { } / all { } / none { }
判空isEmpty() / isNotEmpty() / 可空集合用 isNullOrEmpty()

分组、排序、取元素

我想……用
按字段分堆(一键多值)groupBy { it.dept } → Map<K, List<V>>
按字段建索引(一键一值,重复键会丢)associateBy { it.id }
自己指定键和值associate { it.a to it.b }
元素当键、算个值associateWith { compute(it) }
按类别计数groupingBy { }.eachCount()
一刀切两堆partition { } → Pair(满足, 不满足)
排序sorted() / sortedBy { } / sortedByDescending { }
多级排序 / 处理 nullsortedWith(compareBy({ }, { }))、nullsLast(naturalOrder())
原地排(仅 MutableList)sortBy { }(没有 ed 的是原地版)
取第一个/最后一个firstOrNull() / lastOrNull()(裸版本空集合抛异常)
按条件找一个find { }(= firstOrNull { })/ indexOfFirst { }
断言「有且仅有一个」singleOrNull { }
取前 n / 跳过 ntake(n) / drop(n) / takeWhile { } / dropWhile { }
定长分块 / 滑动窗口chunked(n)(不重叠)/ windowed(n)(重叠)

Map 专属 · 拷贝 · 序列

我想……用
取值,键不存在时给默认map.getOrElse(k) { default }(map[k] 返回 null)
取值,键不存在时抛异常getValue(k) → NoSuchElementException: Key z is missing in the map.
取值,没有就算一个并存进去mutableMap.getOrPut(k) { compute() }
只改值 / 只改键mapValues { (k, v) -> } / mapKeys { }
过滤 MapfilterKeys { } / filterValues { } / filter { (k, v) -> }
拿一份互不影响的快照toList() / toMutableList() / toMap()
攒一个只读集合buildList { add(...) } / buildMap { put(...) }
转惰性 / 造无限序列 / 按需产出asSequence() / generateSequence(seed) { } / sequence { yield(x) }
逐行读大文件File(p).useLines { it.filter { } .take(10).toList() }
// 一屏体感:同一批数据,六族各来一句
data class Emp(val name: String, val dept: String, val salary: Int)
val es = listOf(Emp("Ann", "eng", 100), Emp("Bob", "eng", 120), Emp("Cy", "ops", 90))

es.map { it.name }                          // [Ann, Bob, Cy]
es.filter { it.salary > 95 }                // Ann, Bob
es.sumOf { it.salary }                       // 310
es.groupBy { it.dept }                       // {eng=[Ann, Bob], ops=[Cy]}
es.groupingBy { it.dept }.eachCount()        // {eng=2, ops=1}
es.sortedByDescending { it.salary }.first()  // Emp(Bob, eng, 120)
es.maxByOrNull { it.salary }                 // 同上,但不排序、也不会抛
es.joinToString("; ") { "${it.name}(${it.salary})" }
// Ann(100); Bob(120); Cy(90)

// Map 的四个高频招
val m = mapOf("a" to 1)
m["z"]                       // null
m.getOrElse("z") { -1 }      // -1
m.getValue("z")              // NoSuchElementException: Key z is missing in the map.
mutableMapOf<String, Int>().getOrPut("k") { 9 }   // 9,并写回 map
sorted() 和 sort()、reversed() 和 reverse() 只差一个字母,行为却相反:前者返回新集合、原集合不动,后者原地改、返回 Unit。val sorted = list.sort() 会得到一个 Unit,编译器不一定拦得住你继续往下写。同理,链式调用的中途出现 sortBy 就是断链。
命名有规律,猜得出来。①后缀 OrNull = 失败给 null,没有它 = 失败抛异常;②后缀 By = 传一个「取键/取值」的 lambda,With = 传一个 Comparator;③过去分词是拷贝、动词原形是原地改(sorted() 返回新集合、sortBy { } 就地排 MutableList);④前缀 as = 视图,to = 拷贝。四条规则覆盖标准库九成的集合 API。

Kotlin 把集合拆成「只读接口」和「可变接口」两套,这是接口层面的区分,不是运行时的不可变保证。把 List 当成「不可变列表」来理解,迟早会在某个下午查上两小时。

一条继承线,两个名字

  • MutableList<E> 继承 List<E>(MutableSet/MutableMap 同理)。所以可变的能当只读的用,反过来不行——这是编译期的单向门;
  • List<out E> 声明处协变,List<String> 可以直接赋给 List<Any>;MutableList<E> 不变,同样的赋值编译器直接拒绝:
    initializer type mismatch: expected 'MutableList<Any>', actual 'List<String>'.(型变的来龙去脉见 09 章);
  • 只读接口没有 add/remove/set——「只读」保证的是你拿这个引用改不了它,不是「没人能改它」。

三大集合的构造函数

类型只读构造可变构造要点
ListlistOf · emptyList · listOfNotNull · buildList { }mutableListOf · arrayListOflistOfNotNull(1, null, 3) 得 [1, 3],过滤 null 的最短写法
SetsetOf · emptySet · buildSet { }mutableSetOf · hashSetOf · linkedSetOfsetOf 落到 LinkedHashSet,保留插入顺序;hashSetOf 不保
MapmapOf · emptyMap · buildMap { }mutableMapOf · hashMapOf · linkedMapOfmapOf("a" to 1),to 是造 Pair 的中缀函数

buildList/buildSet/buildMap 是「用可变的方式攒、拿到手是只读的」——比先 mutableListOf 再 toList() 少一次拷贝。

它们运行时到底是什么

表达式运行时 javaClass.name
emptyList<Int>()kotlin.collections.EmptyList
listOf(1)java.util.Collections$SingletonList
listOf(1, 2, 3)java.util.Arrays$ArrayList
mutableListOf(1, 2, 3)java.util.ArrayList
buildList { add(1) }kotlin.collections.builders.ListBuilder

注意没有一个是「Kotlin 不可变列表」——除了 EmptyList 和 ListBuilder,其余全是 JDK 自带的类。Kotlin 只读集合是编译期的类型约定,运行时并没有一层新的数据结构。

「只读 ≠ 不可变」的四种出错现场

  • 能拦住的:listOf(1,2,3) 强转成 MutableList 再 add → java.lang.UnsupportedOperationException(因为底层 Arrays$ArrayList 本来就不支持增删);
  • 拦不住的:底层实例本来就是 mutableListOf、只是声明成 List——强转回 MutableList 再 add 成功,底层列表真的多了一个元素;
  • 连强转都不用的:谁还握着原来的 MutableList 引用,谁改一下,你手里的只读引用立刻看到新内容——只读引用是视图不是快照;
  • Java 来的完全裸奔:java.util.ArrayList 声明成 List,强转 add 照样成功。跨 Java 互操作边界(12 章)时,这层保护根本不存在。
// ① 只读接口没有 add,编译期就拦住
val ro: List<Int> = listOf(1, 2, 3)
// ro.add(4)                       // ❌ Unresolved reference 'add'

// ② 强转 listOf 的结果 —— 运行时炸
(ro as MutableList<Int>).add(4)
// java.lang.UnsupportedOperationException  (底层是 Arrays$ArrayList)

// ③ 但底层若本来就是 mutableListOf —— 强转成功,真的改掉了
val backing = mutableListOf(1, 2, 3)
val view: List<Int> = backing        // 只是换了个「只读」的声明
(view as MutableList<Int>).add(4)
println(view)                         // [1, 2, 3, 4]   ← 没有异常

// ④ 连强转都不用:别人改,你的只读引用跟着变
val b = mutableListOf("a")
val v: List<String> = b
b.add("b")
println(v)                            // [a, b]   ← 只读引用是视图,不是快照

// ⑤ toList() 才是真拷贝:源再怎么改,拷贝不动
val snapshot = b.toList()
b.add("c")
println(snapshot)                     // [a, b]
println(b)                            // [a, b, c]

// ⑥ List<out E> 协变 vs MutableList<E> 不变
val names: List<String> = listOf("a", "b")
val anys: List<Any> = names           // ✅ List 是 out E
// val m: MutableList<Any> = names   // ❌ initializer type mismatch:
//    expected 'MutableList<Any>', actual 'List<String>'.
「我把它声明成 List 了,所以它安全了」是本页最贵的误解。只读只约束这个引用,不约束那个对象。典型事故:函数收了一个 List<T> 参数就把它存进字段当缓存,调用方手里的 MutableList 一改,你的缓存跟着变——存之前必须 toList()。反过来,别以为强转一定会抛异常:能不能抛完全取决于底层实例是谁,这让「靠异常发现误用」变得不可靠。
接口返回只读、内部实现用可变是最省心的分工:函数签名一律写 List<T>/Map<K,V>,函数体里想怎么攒怎么攒(或直接 buildList { })。要真正交出一个「谁都改不了」的快照,出口处补一个 .toList()——它是拷贝,不是转换。

Kotlin 标准库有一百多个集合扩展函数,但它们只分成六族。记住每族的判据,比背名字有用得多——真正会咬人的是几对「长得像、行为差一截」的兄弟 API。

六族与判据

族代表怎么选
变换map mapNotNull flatMap flatten zip输出元素数 = 输入数 → map;转换可能失败/产出 null 且要丢掉 → mapNotNull;每个元素展开成多个 → flatMap
过滤filter filterNot filterNotNull filterIsInstance distinct distinctBy只挑不改 → filter 族;要按类型收窄(顺带改类型)→ filterIsInstance<T>()
聚合fold reduce sumOf count joinToString runningFold要初始值 / 结果类型与元素不同 → fold;类型相同且集合一定非空 → reduce;要中间过程 → runningFold
分组groupBy associateBy associate associateWith partition groupingBy一键对多值 → groupBy(值是 List);一键一值 → associateBy;只要计数 → groupingBy { }.eachCount();只分两堆 → partition
排序sorted sortedBy sortedByDescending sortedWith单一 key → sortedBy;多级 / 要处理 null / 要复用比较逻辑 → sortedWith(compareBy(...))
取元素first firstOrNull find single take drop chunked windowed集合可能为空 → 一律 *OrNull;要断言「有且仅有一个」→ single

会咬人的几对(异常与消息均为报错原文)

对比空集合 / 冲突时的实际行为
first() vs firstOrNull()前者抛 java.util.NoSuchElementException: List is empty.;后者返回 null
reduce() vs fold()前者抛 java.lang.UnsupportedOperationException: Empty collection can't be reduced.;fold(0) 返回初始值 0
max() vs maxOrNull()前者抛 java.util.NoSuchElementException;后者返回 null
single()元素超过一个时抛 java.lang.IllegalArgumentException: List has more than one element.
groupBy vs associateBy键重复时 associateBy 静默丢数据:3 条记录、2 个不同键,groupBy 得到 {eng=[Ann, Bob], ops=[Cy]},associateBy 只剩 {eng=Bob, ops=Cy}——Ann 无声消失,size 从 3 变 2,不报错不警告
map vs mapNotNulllistOf("1","x","3").map { it.toIntOrNull() } → [1, null, 3](类型 List<Int?>);mapNotNull → [1, 3](类型 List<Int>),连类型一起清干净
data class Emp(val name: String, val dept: String, val salary: Int)
val es = listOf(Emp("Ann", "eng", 100), Emp("Bob", "eng", 120), Emp("Cy", "ops", 90))

// —— 变换 ——
listOf("1", "x", "3").map { it.toIntOrNull() }        // [1, null, 3]  : List<Int?>
listOf("1", "x", "3").mapNotNull { it.toIntOrNull() }  // [1, 3]        : List<Int>
listOf("ab", "cd").flatMap { it.toList() }         // [a, b, c, d]
listOf(listOf(1, 2), listOf(3)).flatten()           // [1, 2, 3]
listOf(1, 2, 3).zip(listOf("a", "b"))              // [(1, a), (2, b)] ← 按短的截断

// —— 聚合:fold 有初始值,reduce 没有 ——
listOf(1, 2, 3).fold(10) { acc, n -> acc + n }        // 16
listOf(1, 2, 3).reduce { acc, n -> acc + n }           // 6
emptyList<Int>().fold(0) { a, b -> a + b }             // 0   ← 安全
emptyList<Int>().reduce { a, b -> a + b }              // UnsupportedOperationException:
//                                       Empty collection can't be reduced.
listOf(1, 2, 3).runningFold(0) { a, b -> a + b }   // [0, 1, 3, 6] ← 要中间过程

// —— 取元素:空集合的分水岭 ——
emptyList<Int>().first()      // NoSuchElementException: List is empty.
emptyList<Int>().firstOrNull()// null
listOf(1, 2).single()         // IllegalArgumentException: List has more than one element.

// —— 分组:associateBy 会静默丢重复键 ——
es.groupBy { it.dept }
// {eng=[Emp(Ann..), Emp(Bob..)], ops=[Emp(Cy..)]}   3 条全在
es.associateBy { it.dept }
// {eng=Emp(Bob..), ops=Emp(Cy..)}                   Ann 没了!size 3 -> 2
es.groupingBy { it.dept }.eachCount()          // {eng=2, ops=1}  只要计数别 groupBy 再 size
es.associate { it.name to it.salary }          // {Ann=100, Bob=120, Cy=90}
es.partition { it.salary > 95 }                // Pair(满足的, 不满足的)

// —— 排序:多级 / null 用 sortedWith ——
es.sortedBy { it.salary }                        // 单 key
es.sortedWith(compareBy({ it.dept }, { it.name })) // 先部门后姓名
listOf("b", null, "a").sortedWith(nullsLast(naturalOrder()))  // [a, b, null]

// —— 切窗 ——
(1..7).toList().chunked(3)     // [[1,2,3], [4,5,6], [7]]  定长分块,不重叠
(1..5).toList().windowed(3)    // [[1,2,3], [2,3,4], [3,4,5]]  滑动窗口,重叠
associateBy 的静默丢数据是最难查的一类 bug。它把「一键一值」当成前提,键重复时后来者直接覆盖前者,不报错、不警告、不留痕,只有下游发现「怎么少了几条」才暴露。判据很简单:只要不能 100% 保证键唯一,就用 groupBy;确实唯一的话,用 associateBy 之余顺手断言 result.size == input.size。另一个近亲坑是 zip:两个列表长度不等时按短的截断,长的那截也是无声消失。
凡是名字里没有 OrNull 的「取元素」函数,都要先问一句「这集合可能空吗」。Kotlin 的命名很守规矩:xxx() 抛异常、xxxOrNull() 给 null——这跟 toInt()/toIntOrNull() 是同一套约定(见 05 章)。团队里统一「默认写 OrNull,确实要断言非空时才写裸版本」,能省掉一大类线上崩溃。

普通集合操作是逐步、急切的:每一步都建一个完整的中间集合。Sequence 是逐元素、惰性的:整条链只走一遍,且不到终端操作不动一根手指。差别不是「快慢」,是求值顺序——它决定了什么时候该换。

求值顺序的差别,一眼看穿

同样是 map 再 filter,把每次调用记下来(输出):

写法回调实际调用顺序含义
listOf(1,2,3).map{}.filter{}m1 m2 m3 f1 f2 f3横着走:map 把三个全做完、建出中间 List,filter 再从头扫一遍
...asSequence().map{}.filter{}.toList()m1 f1 m2 f2 m3 f3竖着走:每个元素一路走到底,零个中间集合

早停:序列最大的赢面

从 1..100 里找第一个平方大于 20 的数:

  • Sequence 版:map 只被调用了 5 次(1 2 3 4 5),拿到 25 就停;
  • List 版:map 被调用 100 次——先把一百个平方全算出来、建成 List,然后才去 first。

集合越大、链越长、早停越早,这个差距就越夸张。反过来,没有早停、集合又小时,Sequence 每个元素都要过一层迭代器包装,反而是净亏。

怎么造一条序列

写法用途能重复消费吗
list.asSequence()已有集合转惰性能——每次重新从底层集合取迭代器
generateSequence(seed) { next }由上一项推下一项的无限序列,返回 null 结束能
generateSequence { ... }(无种子)从外部源不断拉数据(读行、读队列)不能,第二次抛异常
sequence { yield(x) }用挂起式写法按需产出能——builder 块每次消费都重新执行一遍
seq.constrainOnce()显式声明「只能消费一次」不能,第二次抛异常

「Sequence 只能消费一次」是半句真话

常见说法是「序列是一次性的」,下来取决于数据源:由集合支撑的序列(asSequence、带种子的 generateSequence、sequence { })随便消费几次都行;只有包着一个外部一次性源的才会炸——iterator().asSequence()、无种子的 generateSequence { }、以及显式 constrainOnce(),第二次消费抛:

java.lang.IllegalStateException: This sequence can be consumed only once.

更阴的是 sequence { }:它不报错,而是把 builder 块整个重跑一遍——块里若有副作用(写日志、发请求、自增计数器),会被悄悄执行两次。

// —— 求值顺序:横着走 vs 竖着走 ——
listOf(1, 2, 3).map { log("m${it}"); it }.filter { log("f${it}"); true }
// 顺序:m1 m2 m3 f1 f2 f3      ← map 全做完,建中间 List,filter 再扫

listOf(1, 2, 3).asSequence()
    .map { log("m${it}"); it }.filter { log("f${it}"); true }.toList()
// 顺序:m1 f1 m2 f2 m3 f3      ← 每个元素一路走到底,零中间集合

// —— 早停:map 只跑 5 次 vs 100 次 ——
(1..100).asSequence().map { it * it }.first { it > 20 }   // 25,map 调用 5 次
(1..100).map { it * it }.first { it > 20 }                // 25,map 调用 100 次

// —— 三种造法 ——
val a = listOf(1, 2, 3).asSequence()
val b = generateSequence(1) { it + 1 }          // 无限序列,靠 take/first 收口
val c = sequence { yield(1); yieldAll(listOf(2, 3)) }

// 无限 + 早停:只有惰性才写得出来
generateSequence(1) { it + 1 }.filter { it % 7 == 0 }.take(3).toList()  // [7, 14, 21]

// —— 一次性:取决于源,不是取决于 Sequence 本身 ——
val reusable = listOf(1, 2, 3).asSequence().map { it * 2 }
reusable.toList()   // [2, 4, 6]
reusable.toList()   // [2, 4, 6]   ← 再来一次,完全没问题

val src = listOf("a", "b").iterator()
val once = generateSequence { if (src.hasNext()) src.next() else null }
once.toList()       // [a, b]
once.toList()       // IllegalStateException: This sequence can be consumed only once.

// —— 逐行读文件:内存占用与文件大小无关 ——
File("big.log").useLines { lines ->          // lines: Sequence<String>
    lines.filter { it.startsWith("ERROR") }.take(10).toList()
}                                             // 块结束自动关流
忘了终端操作,整条链一行都不会跑。seq.map { println(it) } 单独写一句是彻底的空操作——没有 toList()/count()/forEach 收口,惰性链根本不启动,而且编译器不会提醒你。这在「用 map 做副作用」时尤其致命。顺带一个:sequence { } 重复消费时会把 builder 块整块重跑——块里若有网络请求或计数器,就会被静默执行两遍(消费两次 → 块执行 2 次)。
判据(按顺序问三句):①「链上有 first/find/take/any 这类会早停的终端操作吗?」有 → 用 Sequence。②「集合很大 且 中间步骤有两步以上吗?」是 → 用 Sequence,省下的是那些中间集合。③ 数据源本来就是无限的或流式的(无限递推、逐行读文件)→ 只能用 Sequence。三个都不是,就老老实实用 List——小集合上 Sequence 多出来的迭代器包装是净开销,代码还更长。

上一步弄清了「只读不等于不可变」,这里回答实操问题:什么时候必须拷贝、遍历时怎么安全地改、以及 Kotlin 的只读跟 Java 的 unmodifiableList 到底差在哪。

拷贝 vs 视图:一张表分清

调用拷贝还是视图源被改动后
toList() / toMutableList() / toSet()拷贝(新建 ArrayList 等)结果不受影响,是一份快照
reversed() / sorted() / filter() / map()拷贝(新集合)不受影响
asReversed()视图源 add(4) 后视图变成 [4, 3, 2, 1]
subList(a, b)视图源发生结构性修改后,读视图抛 ConcurrentModificationException
把 MutableList 赋给 List 变量视图(同一个对象)跟着变,见本章前面
Collections.unmodifiableList(m)视图(只读包装)源 add(3) 后包装读出 [1, 2, 3]

口诀:名字是 toXxx / 过去分词(reversed/sorted)的是拷贝,asXxx 的是视图。

遍历中改集合:ConcurrentModificationException

名字里有 Concurrent,但跟多线程没关系——单线程里 for (x in list) { list.add(...) } 就会抛(java.util.ConcurrentModificationException,message 为 null)。原因是 JDK 集合的迭代器带一个 modCount 快照,发现集合被绕过迭代器改动就立刻失败(fail-fast)。Map 的 for ((k, v) in map) 里往 map 塞新键,同样抛。三种正确写法:

  • removeIf { } —— 批量删除的首选(OK);
  • 显式 iterator() + it.remove() —— 要边看边删时用(OK);
  • filter { } 造一个新集合再整体替换 —— 最不容易错,也最符合 Kotlin 风格。

Kotlin 只读 vs Java unmodifiableList

Kotlin List<T>Collections.unmodifiableList
拦截时机编译期(接口上没有 add)运行期(抛 UnsupportedOperationException)
强转绕过看底层实例:底层可变就能绕过去强转成 MutableList 再 add 仍然抛 UnsupportedOperationException
源被改动跟着变(同一对象)跟着变([1, 2, 3])——两者都不是快照
运行时开销零(就是同一个对象)多一层包装对象

两者互补:Kotlin 的强在「早」(编译期就写不出来),Java 的强在「硬」(强转也拦得住)。但都不防「源被别人改」——要那个保证,只有 toList() 拷贝一份。

// —— 拷贝 vs 视图 ——
val m = mutableListOf(1, 2, 3)
val viewR = m.asReversed()      // 视图
val copyR = m.reversed()        // 拷贝
m.add(4)
println(viewR)                 // [4, 3, 2, 1]   ← 跟着变了
println(copyR)                 // [3, 2, 1]      ← 没变

// toMutableList 是拷贝:改副本不动原件
val orig = listOf(1, 2)
val copy = orig.toMutableList()
copy.add(3)
println("${orig} / ${copy}")      // [1, 2] / [1, 2, 3]

// —— 遍历中改:单线程也会炸 ——
val xs = mutableListOf(1, 2, 3)
for (x in xs) { if (x == 2) xs.add(99) }
// java.util.ConcurrentModificationException   ← 没有第二个线程

val mp = mutableMapOf("a" to 1, "b" to 2)
for ((k, _) in mp) { mp["c"] = 3 }
// java.util.ConcurrentModificationException   ← Map 同理

// ✅ 写法一:removeIf
val a = mutableListOf(1, 2, 3, 4)
a.removeIf { it % 2 == 0 }               // [1, 3]

// ✅ 写法二:显式迭代器
val b = mutableListOf(1, 2, 3, 4)
val iter = b.iterator()
while (iter.hasNext()) { if (iter.next() % 2 == 0) iter.remove() }

// ✅ 写法三:造新集合(最 Kotlin,也最不容易错)
var c = listOf(1, 2, 3, 4)
c = c.filter { it % 2 != 0 }

// —— unmodifiableList:运行期硬拦,但仍是视图 ——
val backing = mutableListOf(1, 2)
val unmod = Collections.unmodifiableList(backing)
unmod.add(3)                    // UnsupportedOperationException
(unmod as MutableList<Int>).add(9) // 仍然 UnsupportedOperationException(强转也没用)
backing.add(3)
println(unmod)                 // [1, 2, 3]   ← 但源一改,它还是跟着变

// 只有拷贝才是真快照
val snapshot = backing.toList()
ConcurrentModificationException 的名字在骗人——它跟并发无关,单线程 for 里加一行 add 就能触发,很多人因此往「线程」方向查半天。而更隐蔽的是 subList:它是视图,只要原列表发生结构性修改(增删),再去读这个 subList 就抛 ConcurrentModificationException——「我明明只是取了个子列表存起来」是常见的困惑源。要留着用,就 subList(a, b).toList()。
跨边界一律拷贝。集合进出「你控制的代码」与「别人控制的代码」的交界处——构造函数存参数、getter 返回内部字段、往缓存里塞、发到另一个线程——都补一个 .toList()。这一次拷贝换掉的是一整类「远处的代码改了我的数据」的悬案。至于线程安全:Kotlin 的只读接口一点也不提供,多线程共享可变集合仍需 ConcurrentHashMap、CopyOnWriteArrayList 或把状态收进协程(见 10 章)。

函数式编程

本章覆盖:Lambda 的写法与 it、高阶函数与 inline、五个作用域函数一张表选完(let / run / with / apply / also)、扩展函数与它的三条边界(静态分派、成员优先、看不到私有成员)。

这一章最常被查的只有一件事:let / run / with / apply / also 到底用哪个。一张表就能定,判据只有两条:lambda 里怎么称呼它,以及整个表达式返回什么。

Lambda 与高阶函数

写法说明
{ x: Int -> x * 2 }完整形式
{ it * 2 }单参数时隐式叫 it
list.filter { it > 0 }最后一个参数是 lambda 时,括号可以省(尾随 lambda)
f(a) { … }还有别的参数时,lambda 仍能提到括号外
{ (k, v) -> … }解构参数(遍历 Map 时常用)
{ _, v -> v }用不到的参数写下划线
return@filter从 lambda 返回;裸 return 会退出外层函数(03 章)
函数类型(Int) -> String · suspend (Int) -> Unit · Item.() -> Unit(带接收者,14 章)
高阶函数fun <T> retry(times: Int, block: () -> T): T
inline把 lambda 的代码直接展开到调用处,省掉对象分配;标准库的 filter / map / let 全是 inline(09 章)

五个作用域函数:一张表选完

函数lambda 里怎么称呼它整体返回典型用途
letitlambda 的结果配 ?. 做空安全;把一个值变换成另一个
runthislambda 的结果在对象上下文里算出一个值;run { } 无接收者版可用来限定作用域
with(x) { }thislambda 的结果对同一个对象连续调用多次(它不是扩展函数,写法上 x 在括号里)
applythis对象自己配置对象后返回它:Builder().apply { a = 1 }
alsoit对象自己做点副作用不打断链:记日志、校验
怎么记:名字里带 a 的两个(apply / also)返回对象自己,另外三个返回 lambda 结果;let / also 用 it,其余用 this
相关工具作用
takeIf { }满足条件返回自己,否则 null——val valid = s.takeIf { it.isNotBlank() } ?: return
takeUnless { }反过来
use { }自动关闭资源(try-with-resources)
repeat(n) { }重复 n 次

扩展函数与扩展属性

要点说明
写法fun String.isEmail(): Boolean = "@" in this → "a@b".isEmail()
扩展属性val String.firstChar: Char get() = this[0]——不能有幕后字段,只能是计算属性
可空接收者fun String?.orDash() = this ?: "—"——可以在 null 上调用,标准库的 isNullOrEmpty() 就是这么实现的
作用域要 import 它所在的包才可见;Unresolved reference 常因此而来
静态分派扩展函数按「变量的声明类型」决定调哪个,不是运行时类型——它不是多态
优先级成员函数永远优先:类型自己加了同名方法,你的扩展就静默失效
访问权限看不到 private 成员——它本质上是带接收者的静态函数
放哪小工具放对应的 XxxExt.kt;别给通用类型加太宽泛的扩展,会污染整个项目的补全列表
Java 侧看到的是 XxxKt.isEmail(s) 这样的静态方法(12 章)
// ① 五个作用域函数,各自最典型的一次用法
val len = name?.let { it.trim().length } ?: 0        // let:空安全 + 变换

val req = Request().apply {                         // apply:配置后返回它自己
    url = "https://api.example.com"
    timeout = 30
}

val desc = with(user) { "$name ($age)" }              // with:连续访问同一对象

val data = load().also { log.info("拿到 ${it.size} 条") }  // also:副作用不打断链

val port = run {                                     // run:限定一小段作用域
    val raw = env("PORT")
    raw?.toIntOrNull() ?: 8080
}

// ② 链式里插一步校验:takeIf + ?: return
fun parse(s: String): Config? {
    val text = s.takeIf { it.isNotBlank() } ?: return null
    return Config(text)
}

// ③ 扩展函数:给别人的类型加方法
fun String?.orDash(): String = if (this.isNullOrBlank()) "—" else this
println(nullableName.orDash())                      // null 上也能调

// ④ 扩展是静态分派 —— 这一条很容易踩
open class A; class B : A()
fun A.who() = "A"
fun B.who() = "B"

val x: A = B()
println(x.who())                                   // A —— 按声明类型,不是 B

// ⑤ 高阶函数:把「怎么做」交给调用方
inline fun <T> retry(times: Int, block: () -> T): T {
    repeat(times - 1) { runCatching { return block() } }
    return block()                                  // 最后一次让异常抛出去
}
扩展函数不是多态:它按变量的声明类型静态分派。父类与子类上定义同名扩展时,val x: A = B() 调到的是 A 的版本。需要多态就老老实实用成员函数。
成员函数优先于扩展函数:你给某个类写了扩展 fun Foo.size(),哪天这个库自己加了 size() 成员,你的扩展就静默失效,编译不报错、行为变了。给第三方类型写扩展时,名字尽量具体一点。
作用域函数别硬套,只在它确实让代码更短更清楚时用。最值回票价的两个:?.let { }(空安全)与 apply { }(配置对象)。
嵌套两层以上的 let / apply 会让 it 和 this 指向谁变得难判断——这时候给 lambda 参数起个名字:?.let { user -> … }。

Kotlin 里函数是一等公民:能当参数传、能当返回值、能存进变量。围绕这一点有四件事必须搞清——语法怎么省、类型怎么写、跟 Java 接口怎么对接、以及闭包捕获了什么。

语法:从完整到最省

  • 完整:{ n: Int -> n * 2 };有目标类型时省掉类型:{ n -> n * 2 };
  • 只有一个参数时连参数名都能省,用隐式的 it:{ it * 2 };
  • 尾随 lambda:lambda 是最后一个参数时可以挪到括号外,只剩它时括号整个省掉——list.filter { it > 2 } 其实是 list.filter({ it > 2 })。这条规则是 Kotlin 能写出 DSL 的地基(见 14 章);
  • lambda 的最后一个表达式就是返回值,不写 return(写了反而是从外层函数返回,见下)。

函数类型:它是个真类型

  • (Int) -> String、() -> Unit、(Int, Int) -> Int——可以当参数类型、返回类型、属性类型;
  • 可空的函数类型要加括号:((Int) -> String)?;
  • 带接收者的函数类型 StringBuilder.() -> Unit:lambda 里 this 就是那个 StringBuilder,成员可以直接写——buildString { append("hi") } 就是这么来的;
  • 函数引用 String::length、::println、Person::name 可以直接当函数值用;
  • val f: (Int) -> String 在 JVM 上的运行时接口是 kotlin.jvm.functions.Function1——参数个数编进了名字(Function0…Function22)。

SAM 转换:跟 Java 接口对接

  • 参数类型是 Java 的单抽象方法接口时,直接传 lambda:Runnable { println("hi") }、executor.submit { work() };
  • Kotlin 自己的接口默认不享受这个待遇,要显式标 fun interface(Kotlin 1.4+)才行;
  • 没标 fun 的 Kotlin 接口只能写 object : Foo { override fun ... }。

闭包捕获:Kotlin 能改,Java 不能(两边都验证过)

KotlinJava
捕获局部变量var 也能捕获,并且能在 lambda 里改只能捕获 final 或 effectively final
试图在闭包里自增正常工作:计数器输出 1 2 3编译失败:error: local variables referenced from a lambda expression must be final or effectively final
底层怎么做到的javap 显示捕获的 var 被装进 kotlin.jvm.internal.Ref$IntRef——一个堆上的可变盒子,lambda 拿到的是盒子引用直接把值拷进合成字段,所以必须不变

好处是累加器、计数器这类写法一行搞定;代价是捕获 var 会在堆上多一个 Ref 对象,而且多线程下这个盒子没有任何同步——闭包里改 var 再丢给多个线程跑,就是标准的数据竞争。

// —— 语法四级跳 ——
val a: (Int) -> Int = { n: Int -> n * 2 }   // 全写
val b: (Int) -> Int = { n -> n * 2 }        // 类型可推
val c: (Int) -> Int = { it * 2 }           // 单参数用 it
listOf(1, 2, 3).filter { it > 1 }         // 尾随 lambda,括号都省了

// —— 高阶函数:收一个函数、还一个函数 ——
fun <T> List<T>.countBy(pred: (T) -> Boolean): Int {
    var n = 0
    for (x in this) if (pred(x)) n++
    return n
}
fun multiplier(k: Int): (Int) -> Int = { it * k }   // 返回函数
multiplier(3)(5)                                     // 15

// —— 函数引用 ——
val len: (String) -> Int = String::length
listOf("a", "bb").map(String::length)                // [1, 2]
listOf(1, 2).forEach(::println)

// —— 带接收者的函数类型:lambda 里 this 就是接收者 ——
fun tag(name: String, body: StringBuilder.() -> Unit): String =
    StringBuilder().apply { append("<$name>"); body(); append("</$name>") }.toString()
tag("p") { append("hello") }        // <p>hello</p>   append 前不用写 this.

// —— SAM:Java 接口直接传 lambda ——
val r: Runnable = Runnable { println("run") }     // ✅ Java SAM

fun interface Transformer { fun apply(s: String): String }
fun useSam(t: Transformer) = t.apply("kotlin")
useSam { it.uppercase() }                          // KOTLIN  (靠 fun interface)

// —— 闭包能捕获并修改 var(Java 不行)——
fun makeCounter(): () -> Int {
    var count = 0              // 被 lambda 捕获且修改
    return { ++count }
}
val next = makeCounter()
println("${next()} ${next()} ${next()}")         // 1 2 3

var total = 0
listOf(1, 2, 3).forEach { total += it }          // total == 6
// javap: total 被装进 kotlin.jvm.internal.Ref$IntRef(堆上的可变盒子)

// 同样的 Java 代码编译不过:
//   int count = 0;
//   Supplier<Integer> s = () -> ++count;
//   error: local variables referenced from a lambda expression
//          must be final or effectively final
lambda 里的 return 是从外层函数返回,不是从 lambda 返回。list.forEach { if (x) return } 会直接结束整个外层函数(这叫非局部返回,只有 inline 的高阶函数才允许,见 09 章)——想「跳过这一个」得写 return@forEach。另一处是闭包捕获 var:它捕获的是变量本身不是当时的值,在循环里造 lambda 存进列表,最后所有 lambda 看到的都是循环结束后的那个值。要冻住当下的值,在循环体里先 val snapshot = i 再捕获 snapshot。
lambda 超过三五行就抽成具名函数。Kotlin 的省略规则(it、尾随 lambda、隐式返回)在短 lambda 上是可读性净赚,在长 lambda 上是净亏——尤其嵌套之后 it 指谁全靠数括号。抽出来还有额外好处:具名函数能写 KDoc、能单独测、能被 :: 引用复用。

五个作用域函数做的事一模一样——在一个对象上开一段临时作用域——区别只有两个维度:作用域里怎么称呼那个对象(this 还是 it),以及返回什么(对象自己还是 lambda 的结果)。把这张 2×3 表刻进脑子,选择题就没有了。

2 × 3 对照表(返回值均)

返回 lambda 的结果返回 对象自己
用 it 称呼
(可改名,能嵌套不打架)
let
sb.let { it.append("z"); 111 } → 111
also
sb.also { it.append("w"); 222 } → sb 本身
用 this 称呼
(成员可省前缀)
run(扩展式,可 ?.run)
sb.run { append("y"); 999 } → 999

with(普通函数,对象当参数)
with(sb) { length } → 长度
apply
sb.apply { append("x"); 999 } → sb 本身,999 被丢弃

怎么选:按目的对号入座

我要……用为什么
对可空对象「不为 null 才做」?.let { }唯一能跟 ?. 自然连用的一档,见 05 章
把 A 转换成 Blet / run返回的是结果;要给对象起个有意义的名字就用 let { user -> }
新建对象后连着配置一堆属性apply返回对象本身,能接着链下去
在链中间插一脚(打日志、埋点、校验)also返回对象本身,it 明确表示「我只是路过看一眼」
对同一个对象连着调好几个方法,要个结果with(obj) { }不是扩展函数,读起来像「针对 obj,做这些事」
对象可能为 null,还要配置它?.apply { }with 做不到这件事

三个真实的易错点

  • apply 里写返回值是无效的:x.apply { compute() } 的 compute() 结果被直接丢掉,返回的永远是 x。编译器只给一句轻飘飘的 warning: expression is unused.——一个警告,不是错误,很容易在一屏输出里被刷过去。想要结果请把 apply 换成 run;
  • 嵌套后 it 被遮蔽:a.let { b.let { ... } } 里面那个 it 指的是 b,外层的 a 就再也够不着了,而且不会有任何警告。嵌套时一律给外层显式命名:a.let { outer -> b.let { inner -> ... } };
  • 嵌套后 this 更危险:apply/run 套 apply/run,里层的 this 遮蔽外层,而成员访问是不写前缀的——name = "x" 到底改的是谁的 name,光看代码根本不知道,编译还能通过。this 型(run/with/apply)不要嵌套。
// —— 五个函数的返回值,—
val sb = StringBuilder()
sb.apply { append("x"); 999 }   // → sb 本身;999 被丢弃(warning: expression is unused.)
sb.run   { append("y"); 999 }   // → 999
sb.let   { it.append("z"); 111 } // → 111
sb.also  { it.append("w"); 222 } // → sb 本身;222 被丢弃
with(sb) { length }              // → 长度(Int)

// —— let:可空处理的主力 ——
val name: String? = readLine()
val upper = name?.let { it.trim().uppercase() }   // name 为 null 时整个是 null

// —— apply:新建 + 配置,返回对象本身 ——
val conf = Properties().apply {
    setProperty("host", "localhost")     // this 隐式,不用写 conf.
    setProperty("port", "8080")
}

// —— also:链中间插一脚,不改变链上流动的值 ——
val ids = loadUsers()
    .also { log("loaded ${it.size}") }      // 路过看一眼
    .filter { it.active }
    .also { log("active ${it.size}") }
    .map { it.id }

// —— run:配置 + 算一个结果 ——
val ok = conf.run {
    setProperty("retry", "3")
    getProperty("host") != null          // 最后一句就是返回值
}

// —— with:针对某对象做一串事 ——
val desc = with(StringBuilder()) {
    append("a"); append("b")
    toString()
}

// ❌ 坑一:apply 里想返回结果 —— 白写
// val n = sb.apply { length }      // n 是 sb,不是长度!只有 warning
val n = sb.run { length }        // ✅ 要结果用 run

// ❌ 坑二:嵌套 let,里层 it 遮蔽外层,无警告
// a.let { b.let { it /* 这是 b,a 够不着了 */ } }
a.let { outer -> b.let { inner -> "$outer / $inner" } }   // ✅ 显式命名
作用域函数是 Kotlin 最容易被滥用的特性,没有之一。三个具体症状:①为省一个变量名而套——val x = compute().let { it * 2 } 不如 val x = compute() * 2;②嵌套两层以上——it/this 指代靠数括号,且遮蔽不产生任何警告,是 code review 的重灾区;③用 apply 当 run 用——lambda 的结果被静默丢弃,只有一句 warning: expression is unused.。判据很硬:如果去掉作用域函数、代码没变长也没变难读,就不该用它。
口诀两句就够:要结果用 let/run/with,要对象本身用 apply/also;要起名字用 it 型(let/also),要省成员前缀用 this 型(run/apply/with)。剩下的记忆负担交给 IDE——写错了返回类型立刻标红。补一句风格建议:also 专门留给「不影响主流程的副作用」(日志、校验、埋点),团队里一看到 also 就知道这行删掉也不影响结果,这个约定非常值钱。

扩展让你给别人写的类加方法,不用继承也不用包装。但它不是真的往类里加东西——理解这句话,就理解了它全部的规则和全部的坑。

它编译成了什么(javap)

四个扩展写在 fpx.kt 里,编译后 javap -p FpxKt 显示:

  • fun Animal.speak() → public static final String speak(Animal)
  • fun Dog.speak() → public static final String speak(Dog)
  • val String.firstChar → public static final char getFirstChar(String)
  • fun String?.orDefault(d: String = "N/A") → orDefault(String, String) + 一个 orDefault$default(...) 的默认参数桥接

看清楚了:接收者只是第一个参数,两个 speak 是普通的静态方法重载。Java 侧调用它就是 FpxKt.speak(dog)(见 12 章)。三条规则由此全部推得出来:不能访问 private 成员(报错 cannot access 'val hidden: Int': it is private in 'D'.)、不能被 override、扩展属性不能有幕后字段。

静态分派:按声明类型选,不看运行时类型

同一个对象,换个变量声明就换一个实现:

代码输出
val d: Dog = Dog(); d.speak()Dog.speak
val a: Animal = d; a.speak()Animal.speak ← 同一个对象

因为重载决议在编译期就完成了:编译器看到变量的静态类型是 Animal,就选了 speak(Animal) 这个静态方法。这跟成员函数的虚分派完全相反,也是扩展函数被误用得最惨的一点——扩展函数没有多态,open/override 对它无意义。

成员函数永远优先

类里已有同名同参的成员时,扩展永远不会被调用。编译器给的是警告不是错误:

warning: this extension is shadowed by a member: 'fun show(): String' defined in 'Box'.

这个设计是故意的:否则你的扩展会在别人给类加了新成员之后悄悄改变行为。反过来说也意味着——你给第三方库写的扩展,可能在库升级后突然失效(库加了同名成员),而你只会看到一条警告。

扩展属性:只能是计算属性

  • 没有幕后字段,所以必须写 get(),不能有初始值。写了报错:extension property cannot be initialized because it has no backing field.;
  • 因此扩展属性不能缓存——每次访问都重新算。想缓存只能上 Map 或委托;
  • 可以扩展在 Companion 上:fun Int.Companion.random(n: Int) = (0..n).random(),写出来像静态工厂。
// —— 基本形态 ——
fun String.removeSpaces(): String = this.replace(" ", "")
"hello world".removeSpaces()          // "helloworld"

// 可空接收者:对 null 也能调(this 就是 null)
fun String?.orDefault(d: String = "N/A") = this ?: d
val s: String? = null
s.orDefault()                          // "N/A"   ← 注意没有 ?.

// 扩展属性:必须是计算属性
val String.firstChar: Char get() = this[0]
// val String.cached: Int = this.length   // ❌ extension property cannot be
//                                        //    initialized because it has no backing field.

// 伴生对象扩展:写出来像静态工厂
fun Int.Companion.randomUpTo(n: Int) = (0..n).random()
Int.randomUpTo(10)

// —— 静态分派:同一个对象,两种结果 ——
open class Animal
class Dog : Animal()
fun Animal.speak() = "Animal.speak"
fun Dog.speak()    = "Dog.speak"

val d: Dog    = Dog()
val a: Animal = d                       // 同一个对象,换个声明类型
println(d.speak())                      // Dog.speak
println(a.speak())                      // Animal.speak   ← 按声明类型选!

// javap -p FpxKt 揭示真相:它俩就是两个静态方法重载
//   public static final java.lang.String speak(Animal);
//   public static final java.lang.String speak(Dog);
//   public static final char getFirstChar(java.lang.String);

// —— 成员永远赢 ——
class Box { fun show() = "MEMBER" }
fun Box.show() = "EXTENSION"
// warning: this extension is shadowed by a member:
//          'fun show(): String' defined in 'Box'.
Box().show()                           // MEMBER

// —— 访问不了 private ——
class D { private val hidden = 1 }
// fun D.peek() = hidden
// error: cannot access 'val hidden: Int': it is private in 'D'.
「扩展函数是多态的」是最常见的误解。父类和子类各有一个同名扩展时,调哪个完全由变量的声明类型决定——把 Dog 装进 List<Animal> 里遍历,拿到的全是 Animal 版本,即使每个元素运行时都是 Dog。需要按运行时类型分派,就必须写成成员函数或用 when (x) { is Dog -> ... }。同样值得记一下:给可空接收者写的扩展(fun String?.foo())调用时不用 ?.,写成 s?.foo() 反而会让 s 为 null 时整个跳过——正好绕开了你专门为 null 写的那段逻辑。
扩展最好的用武之地是「给别人的类补一个你的领域概念」——String.toSlug()、LocalDate.isWeekend、Response.orThrow()。判据:如果这个行为属于你的业务、不属于那个类本身,写扩展;如果它是那个类的固有能力、且类是你自己的,就写成员函数。把扩展放进按主题命名的顶层文件(StringExt.kt)而不是散落各处,能省掉「这个方法哪来的」的困惑——顺带一提,扩展需要 import 才能用,这反而是好事:IDE 一点就知道它从哪来。

Kotlin 借了函数式的很多招,但它是一门务实的多范式语言,在几个关键处主动停下了。知道它停在哪,才不会把 Haskell/Scala 的写法硬搬过来,也才知道该在什么时候降级为命令式。

四条明确的边界

函数式语言有Kotlin 的实际情况你该怎么办
持久化不可变数据结构
(改一个元素返回新结构,内部结构共享)
没有。List 只是个只读接口,底层就是 java.util.ArrayList 那些(见 07 章)。list + item 是整份拷贝,不是结构共享小集合随便 +;热路径上大集合别拿 + 当追加用,要么可变累积、要么上 kotlinx.collections.immutable
高阶类型(HKT)
能写「对任意容器 F 都成立」的抽象
没有。写不出 interface Functor<F<_>>,泛型参数只能是具体类型不要试图搭一套 Functor/Monad 抽象。为每个具体类型各写各的扩展函数——重复一点,但读得懂
Monad 与 do-notation没有统一的 monad 抽象,也没有 for 推导式。标准库给的是具体的组合子:fold、flatMap、?.let、runCatching把 ?. / ?: 当 Maybe 用(05 章),把 Result/sealed class 当 Either 用(13 章)
无栈限制的递归
(编译器普遍做尾调用优化)
只在标记了 tailrec 时做,且只认真正的尾调用。JVM 本身不做 TCO递归深度可能很大时,写 tailrec;写不成尾递归的(比如树遍历)就改显式栈的循环

tailrec:差别

  • 同一个求和函数,写成尾递归 + tailrec:深递归正常返回结果;写成非尾递归:同样深度直接 java.lang.StackOverflowError;
  • 差别在字节码里看得一清二楚(javap -c):tailrec 版本方法体里只有一句 goto 0,根本没有自我调用;非 tailrec 版本是老老实实的 invokestatic countPlain:(I)J——一个是循环,一个是真递归;
  • tailrec 用错了编译器会明说(两条警告):
    warning: a function is marked as tail-recursive but no tail calls are found.
    warning: recursive call is not a tail call.
    ——注意这是警告不是错误,代码照常编译,然后照常在深递归时炸栈。

「函数式纯度」在 Kotlin 里是约定,不是保证

  • Kotlin 没有纯函数标记,也没有副作用类型。val 只保证引用不变,指向的对象照样能改;
  • lambda 能自由捕获并修改外部 var(见本章前面讲 Lambda 的内容),编译器不拦;
  • 所以「纯」只能靠团队自律和 code review。务实的做法:核心业务逻辑写成纯函数(好测),I/O 与状态变更集中在边缘——这是 Kotlin 能给到的最好结果,不要指望类型系统替你把关。
// —— 「不可变」只是只读视图:+ 是整份拷贝 ——
val a = listOf(1, 2, 3)
val b = a + 4              // 新建一个完整的 List,不是结构共享
// 循环里这么写就是 O(n²):
// var acc = emptyList<Int>(); repeat(n) { acc = acc + it }   ← 别
val acc = buildList { repeat(1000) { add(it) } }   // ✅ 可变累积,只读交付

// —— 没有 HKT:这行写不出来 ——
// interface Functor<F<_>> { fun <A, B> F<A>.map(f: (A) -> B): F<B> }
// Kotlin 的泛型参数只能是具体类型,不能是「一个类型构造器」

// —— 没有 monad,但有够用的组合子 ——
val n: Int? = readLine()?.trim()?.toIntOrNull()      // ?. 链 ≈ Maybe
val r: Result<Int> = runCatching { "12".toInt() }
    .map { it * 2 }
    .recover { 0 }                                // ≈ Either 的两条腿
val sum = listOf(1, 2, 3).fold(0) { s, x -> s + x }  // 折叠就是折叠,不谈 monoid

// —— tailrec:编译成循环 ——
tailrec fun sumTail(n: Int, acc: Long = 0): Long =
    if (n == 0) acc else sumTail(n - 1, acc + n)      // 递归调用是最后一步 ✅

fun sumPlain(n: Int): Long =
    if (n == 0) 0 else n + sumPlain(n - 1)          // 调用后还要做加法 ❌ 不是尾调用

sumTail(1_000_000)     // 正常返回结果
sumPlain(1_000_000)    // java.lang.StackOverflowError

// javap -c 里的差别:
//   sumTail :  23: goto 0                        ← 循环,无自我调用
//   sumPlain:  13: invokestatic sumPlain:(I)J    ← 真递归

// 标错了只有警告,不是错误 —— 照常编译,照常炸栈
// warning: a function is marked as tail-recursive but no tail calls are found.
// warning: recursive call is not a tail call.

// —— val 不等于不可变 ——
val box = mutableListOf(1)
box.add(2)                // ✅ 合法:val 冻住的是引用,不是对象
// box = mutableListOf()  // ❌ 这才是 val 拦得住的
在循环里用 + 累积集合是最常见的性能事故。acc = acc + item 读起来很函数式,但 Kotlin 的 List 没有结构共享——每一次 + 都完整拷贝一遍,循环下来就是平方级的工作量,而代码看上去人畜无害。用 buildList { } 或可变列表累积再 toList()。同理,data class 的 copy() 也是浅拷贝新对象,嵌套结构里层层 copy 更新一个深层字段,写起来长、跑起来也不便宜——深层更新频繁的场景,这正是该退回可变模型的信号。
把 Kotlin 当「默认不可变、局部可变」的语言用,而不是当纯函数式语言用。具体三条:①默认写 val,需要改再换 var;②函数签名收发只读集合,函数体内部爱怎么可变怎么可变——可变性被关在一个函数里就不会伤人;③递归深度不可控就写 tailrec,写不成尾递归就老实用循环加显式栈。追求「纯」到跟语言对着干(自己造 Monad 层、到处 copy())反而会写出团队里没人愿意维护的代码。

泛型与高级类型

本章覆盖:泛型声明与 where 约束、类型擦除的三个后果、型变 out / in 与星投影、inline / reified / noinline / crossinline、运算符重载与解构。

泛型在 JVM 上有一条硬约束:类型擦除——泛型信息只活到编译期。Kotlin 在这条约束上加了三样东西:声明处型变、reified、以及更细的 inline 控制。

声明与约束

要什么怎么写
泛型函数fun <T> first(xs: List<T>): T = xs[0]
泛型类class Box<T>(val value: T)
上界fun <T : Comparable<T>> max(a: T, b: T)
多个约束fun <T> f(x: T) where T : Closeable, T : Runnable
非空约束<T : Any>——不写的话 T 默认包含可空类型
默认上界Any?
类型推断多数情况不用写实参:listOf(1, 2) 推成 List<Int>

类型擦除的三个后果

做不到的事症状绕法
x is List<String>编译错:运行时分不清元素类型x is List<*>(星投影)再逐个检查
T::class 在普通泛型函数里编译错inline fun <reified T>
重载只差泛型参数Platform declaration clash@JvmName 改名
as List<String>只警告不报错(unchecked cast),错了要等到取元素时才 ClassCastException边界处校验,或用 filterIsInstance<String>()

型变:out 与 in

关键字T 只能出现在含义例子
协变out T返回位置(生产者)List<Dog> 可以当 List<Animal> 用List<out E> · Sequence · Flow
逆变in T参数位置(消费者)Comparator<Animal> 可以当 Comparator<Dog> 用Comparator<in T>
不变(不写)两边都出现MutableList<Dog> 不能当 MutableList<Animal>MutableList<T>
使用处型变fun copy(from: Array<out Any>)—Java 的 ? extends / ? super 对应物—
星投影List<*>—「元素类型未知」:能读成 Any?,不能写—

记法(PECS 的 Kotlin 版):只往外给用 out,只往里收用 in;两样都要就只能不变。MutableList 既读又写,所以它不变——这正是 Java 数组协变留下的那个运行时陷阱在 Kotlin 里被堵住的地方。

inline 家族

修饰符作用什么时候用
inline fun把函数体与 lambda 展开到调用处:省掉函数对象分配,允许 lambda 里非局部 return带 lambda 参数的小函数;没有 lambda 参数的函数别加,IDE 会警告
reified T让类型参数在运行时可用:能写 T::class、x is T反射、JSON 反序列化、filterIsInstance<T>();必须配 inline
noinline这个 lambda 参数不要内联要把 lambda 存起来或传给别的函数时
crossinline允许内联,但禁止里面写非局部 returnlambda 会在另一个执行上下文里被调用(如放进 Runnable)
代价字节码体积变大大函数别内联;inline 函数访问不了 private 成员

运算符重载与解构

运算符约定函数名
a + b · - · * · / · %plus · minus · times · div · rem
a[i] · a[i] = vget · set
a()invoke
a in bcontains
a..b · a..<brangeTo · rangeUntil
a < b 等compareTo(一个函数搞定四个运算符)
a == bequals(不用 operator 修饰)
+a · -a · !aunaryPlus · unaryMinus · not
a += bplusAssign(或退回 plus + 赋值)
for (x in a)iterator
解构 val (a, b) = xcomponent1() / component2()——data class 自动生成
委托属性getValue / setValue(06 章)
全都要加 operator 修饰符。别为了炫技重载:只有当运算含义与数学直觉一致时才值得(Money + Money 可以,User + Order 不行)
// ① reified:让类型参数在运行时还活着
inline fun <reified T> String.parseJson(): T = json.decodeFromString(this)
val user = raw.parseJson<User>()          // 不用再传 User::class

inline fun <reified T> List<*>.onlyOf(): List<T> = filterIsInstance<T>()

// ② 型变:out 能读不能写,in 能写不能读
interface Source<out T> { fun next(): T }          // 生产者
interface Sink<in T>    { fun put(x: T) }           // 消费者

val dogs: Source<Dog> = DogSource()
val animals: Source<Animal> = dogs             // OK:协变

val anySink: Sink<Animal> = AnimalSink()
val dogSink: Sink<Dog> = anySink                // OK:逆变

// ③ 擦除:这三行的区别要分清
// if (x is List<String>)   → 编译错
if (x is List<*>) {                          // 只能问「是不是 List」
    val strs = x.filterIsInstance<String>()      // 要元素类型就逐个筛
}

// ④ 运算符重载:含义与直觉一致时才值得
data class Money(val cents: Long) {
    operator fun plus(o: Money) = Money(cents + o.cents)
    operator fun compareTo(o: Money) = cents.compareTo(o.cents)
}
println(Money(100) + Money(50) > Money(120))   // true

// ⑤ 解构:data class 自动有 componentN
val (id, name) = user
for ((k, v) in map) { }
as List<String> 只给一个 unchecked cast 警告就放行,错了要等到取出元素赋值时才抛 ClassCastException——现场离真正的错误已经很远。从 Any / JSON / 反射拿到集合时,宁可 filterIsInstance 也别强转。
给没有 lambda 参数的函数加 inline 没有收益,只会让字节码变大(IDE 会直接警告)。inline 的意义在于消掉 lambda 的对象分配与调用开销。
型变只要记住方向:这个类型参数只从 API 里「出来」就标 out,只「进去」就标 in。写不出来说明它两边都出现,那就该不变。
reified 的用途集中在反序列化与类型过滤两处;看到「需要传 Class<T> 参数」的 API,多半可以用 inline + reified 包一层更好用的门面。

泛型让一份代码适配多种类型,代价是:在 JVM 上,类型参数只活到编译期。这条约束解释了 Kotlin 泛型几乎所有的「为什么不能」——包括为什么会有 reified。

基本形态与约束

  • 泛型函数:fun <T> firstOf(xs: List<T>): T,类型实参通常由实参推导,也可显式写 firstOf<String>(...);
  • 泛型类:class Box<T>(val v: T);
  • 单个上界:fun <T : Comparable<T>> biggest(a: T, b: T)——不写上界时默认是 Any?(允许 null,想禁止就写 <T : Any>);
  • 多个上界:必须用 where 子句——fun <T> f(v: T) where T : CharSequence, T : Comparable<T>。

擦除:javap 眼里的真相

takeStrings(xs: List<String>) 和 takeInts(xs: List<Int>) 编译后:

javap 输出
泛型签名(Signature 属性,只是元数据)int takeStrings(java.util.List<java.lang.String>)
int takeInts(java.util.List<java.lang.Integer>)
真正的方法描述符(javap -s)两个完全一样:(Ljava/util/List;)I

JVM 执行时看的是描述符,看不到尖括号里的东西。运行时验证:listOf("a").javaClass == listOf(1).javaClass 为 true。

擦除带来的三个「不能」

  • 不能 T::class——运行时根本不知道 T 是谁。报错:
    error: cannot use 'T' as reified type parameter. Use a class instead.
    解法是 inline fun <reified T>(下下卡展开);
  • 不能 x is List<String>、不能靠泛型重载——擦除后两个函数签名会撞车;
  • 非受检转型只是把爆炸推迟:any as List<Int> 这一步不会抛异常(编译器只给 unchecked cast 警告),要等到真去读一个元素时才炸。java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Number——报错位置离出错代码可能隔着好几层,是最难查的一类。

擦除不擦什么

并非一切都没了——类和字段的泛型信息保留在 Signature 元数据里,所以 Gson / Jackson 那套 TypeToken/TypeReference「造一个匿名子类,反射读它的泛型父类」的把戏才能成立。局部变量和方法调用点的类型实参才是真的消失了。

// —— 基本形态 ——
fun <T> firstOf(xs: List<T>): T = xs[0]
class Box<T>(val v: T)

// 上界:不写默认是 Any?(可空)
fun <T : Comparable<T>> biggest(a: T, b: T): T = if (a > b) a else b
fun <T : Any> notNull(v: T): T = v          // 显式禁止 null

// 多个上界必须用 where
fun <T> describe(v: T): Int where T : CharSequence, T : Comparable<T> = v.length

// —— 擦除:运行时它们是同一个类 ——
println(listOf("a").javaClass == listOf(1).javaClass)   // true

// javap -s 看真正的描述符:两个方法一模一样
//   int takeStrings(java.util.List<java.lang.String>);
//     descriptor: (Ljava/util/List;)I
//   int takeInts(java.util.List<java.lang.Integer>);
//     descriptor: (Ljava/util/List;)I      ← 尖括号只是元数据

// —— 不能拿 T 当类型来用 ——
fun <T> noReified(): String = T::class.java.name
// error: cannot use 'T' as reified type parameter. Use a class instead.

inline fun <reified T> nameOf() = T::class.java.name   // ✅ 加 inline + reified
nameOf<List<String>>()      // "java.util.List"   ← 注意:内层 String 依旧被擦掉了

// —— 非受检转型:爆炸被推迟到读取时 ——
val any: Any = listOf("a", "b")
val asInts = any as List<Int>      // ⚠️ 只是 unchecked cast 警告,这一步不抛
println(asInts.size)               // 2   ← 还是没事
val n: Int = asInts[0]            // 💥 这里才炸
// java.lang.ClassCastException: class java.lang.String cannot be cast
//   to class java.lang.Number

// —— 泛型数组是擦除最疼的地方 ——
val arr = arrayOfNulls<Any>(2) as Array<String>
// java.lang.ClassCastException: class [Ljava.lang.Object;
//   cannot be cast to class [Ljava.lang.String;
// 数组把元素类型带到了运行时(见本章后面),所以这一步就地爆炸
非受检转型的报错点和出错点隔得很远。as List<Int> 只给一条 unchecked cast 警告,转型当场成功,可能过了三个函数、进了另一个模块,才在某次读取时抛 ClassCastException——那时堆栈里已经看不到罪魁祸首了。所以不要把 @Suppress("UNCHECKED_CAST") 当成消警告的常规手段:每写一次,就等于往代码里埋一个延迟起爆的类型错误。要么在边界处逐元素校验(filterIsInstance<Int>()),要么用 reified 把类型信息真的带进来。
「类型参数只活到编译期」这一句能解释你后面遇到的所有困惑。凡是问「为什么泛型不能……」,先把它翻译成「运行时还知不知道 T 是谁」——不知道,那就不能。需要在运行时知道时,Kotlin 只给了一条路:inline fun <reified T>。想在类上拿到 T,那条路走不通(类不能 inline),标准做法是老实把 Class<T> 或 KClass<T> 当构造参数传进去。

先说问题,再说语法。问题只有一句:List<String> 能不能当 List<Any> 用?答案是「看你打算拿它干什么」——型变就是把这句话写进类型系统的机制。

为什么不能一律「能」:看 Java 数组的下场

Java 的数组是无条件协变的,String[] 直接就能当 Object[] 用。于是这段 Java 代码编译完全通过:

String[] strs = new String[2]; Object[] objs = strs; objs[0] = Integer.valueOf(42);

运行时java.lang.ArrayStoreException: java.lang.Integer。编译期放行、运行期爆炸——为了兜住这个洞,JVM 不得不给每次数组写入都加一道运行时类型检查。

Kotlin 吸取了教训:Array<T> 是不变的,同样的代码编译期就拒绝:
error: initializer type mismatch: expected 'Array<Any>', actual 'Array<String>'.

结论:能不能协变,取决于这个类型会不会「往里放」。只读(只往外拿)就安全,可写就不安全。

out / in:把「只出」「只进」写进声明

out T(协变)in T(逆变)
T 只能出现在输出位置:返回类型、val 类型输入位置:参数类型
子类型关系随 T 同向:Source<String> 是 Source<Any>随 T 反向:Sink<Any> 是 Sink<String>
直觉「生产者」——只掏东西给你,掏出 String 当 Any 用当然没问题「消费者」——能吞 Any 的,让它吞 String 当然也行
标准库例子List<out E>、Iterable<out T>、Flow<out T>Comparable<in T>、Comparator<in T>
违规时(报错原文)type parameter 'T' is declared as 'out' but occurs in 'in' position in type 'T (of interface BadSource<out T>)'.type parameter 'T' is declared as 'in' but occurs in 'out' position in type 'T (of interface BadSink<in T>)'.
都不写(不变)MutableList<E>——既能读又能写,只好一个都不给。这就是 List<String> 能赋给 List<Any>、却不能赋给 MutableList<Any> 的全部原因

记忆法 PECS:Producer Extends(=out),Consumer Super(=in)。

声明处 vs 使用处

声明处型变(Kotlin 主打)使用处型变 / 投影
写在哪类/接口定义上:interface Source<out T>每个用到的地方:fun copy(from: Array<out Any>)
写几次一次,所有使用点自动受益每处都要写(Java 的 ? extends/? super 只有这一种)
适用类型天生只读或只写类型本身可读写,但这一处只用到其中一半

星投影 *:我不关心类型实参

  • List<*> 表示「元素类型不知道,但确实是某个确定类型」。读出来的元素类型是上界(通常 Any?);
  • 它不是 List<Any?>:后者明确说「元素可以是任何东西」,前者说「是某一种,我不知道是哪种」;
  • 写入被禁止。报错:receiver type 'MutableList<*>' contains star projection which prohibits the use of 'fun add(element: E): Boolean'.;
  • 用来写「只关心 size / 是否为空 / 打印」这类跟元素类型无关的工具函数。
// ========== 问题:Java 数组协变的运行时陷阱==========
// Java:
//   String[] strs = new String[2];
//   Object[] objs = strs;                  // ✅ 编译通过:Java 数组是协变的
//   objs[0] = Integer.valueOf(42);         // 💥 运行时
//   java.lang.ArrayStoreException: java.lang.Integer

// Kotlin:Array<T> 不变,同样的代码编译期就拦下
val strs: Array<String> = arrayOf("a", "b")
// val objs: Array<Any> = strs
// error: initializer type mismatch: expected 'Array<Any>', actual 'Array<String>'.

// ========== out:只生产,可以协变 ==========
interface Source<out T> {
    fun next(): T          // ✅ 输出位置
    // fun put(t: T)        // ❌ error: type parameter 'T' is declared as 'out'
    //                      //    but occurs in 'in' position in type 'T (of ...)'.
}
val s: Source<String> = ListSource(listOf("hi"))
val o: Source<Any> = s        // ✅ 协变:拿 String 当 Any 用,安全
println(o.next())             // hi

// ========== in:只消费,可以逆变 ==========
interface Sink<in T> {
    fun put(t: T)          // ✅ 输入位置
    // fun get(): T         // ❌ ... declared as 'in' but occurs in 'out' position
}
val anySink: Sink<Any> = PrintSink()
val strSink: Sink<String> = anySink   // ✅ 逆变:能吞 Any 的当然能吞 String
strSink.put("hello")

// ========== 不变:MutableList ==========
val names: List<String> = listOf("a")
val anys: List<Any> = names           // ✅ List 声明为 List<out E>
// val m: MutableList<Any> = names    // ❌ initializer type mismatch:
//                                    //    expected 'MutableList<Any>', actual 'List<String>'
// 幸好拦住了:否则 m.add(42) 就能往一堆 String 里塞 Int

// ========== 使用处型变(投影):这一处只读 ==========
fun copyAll(from: Array<out Any>, to: Array<Any>) {   // from 只读 → out 投影
    for (i in from.indices) to[i] = from[i]
}
copyAll(arrayOf("a", "b"), arrayOfNulls<Any>(2) as Array<Any>)
// 对应 Java 的 void copyAll(Object[] from, ...) 里的 <? extends Object>

// ========== 星投影:不关心类型实参 ==========
fun describe(c: Collection<*>) = "size=${c.size}, empty=${c.isEmpty()}"
describe(listOf(1, 2, 3))       // size=3, empty=false
describe(setOf("a"))            // size=1, empty=false

// 星投影禁止写入
fun bad(xs: MutableList<*>) { xs.add("nope") }
// error: receiver type 'MutableList<*>' contains star projection
//        which prohibits the use of 'fun add(element: E): Boolean'.
把型变和「继承关系」搞混。Source<String> 是 Source<Any> 的子类型,但 Source 和 String 之间毫无关系——型变说的是泛型类之间的子类型关系怎么随类型实参变化,不是类本身的继承。第二处:协变的 List 在「查找」类方法上会撞到类型推断——listOf("a").contains(1) 在 2.4.10 编译不过,报的还是一句让人愣住的话:error: type inference failed. The value of the type parameter 'T' must be mentioned in input types ... Try to specify it explicitly.。这其实是好事(早年这类跨类型比较能编译过、然后永远返回 false),但报错文本完全没提「你在拿 Int 找 String」,第一次遇到很容易查偏。第三个:星投影不是 Any?——List<*> 说的是「某个确定但未知的类型」,所以你不能往里写任何东西,连 Any? 都不行。
设计接口时先问「这个类型参数是进还是出」,能确定就标上。out 让你的接口在调用方那边好用十倍——Repository<out T> 使得 Repository<Dog> 可以直接传给收 Repository<Animal> 的函数,调用方一行转换都不用写。而且这是个纯赚的动作:如果标错了(T 出现在了不该出现的位置),编译器当场报错,不存在「标了但有隐患」的情况。既进又出就老实不变——不变不是失败,MutableList 就是不变的。

inline 做的事只有一件:把函数体和 lambda 实参直接搬到调用点。听起来只是个性能优化,但正因为「代码被搬到了调用点」,两件本来做不到的事变得可能——非局部返回和 reified。另外三个修饰符都是在给这件事打补丁。

内联换来的三样东西

  • 省掉 lambda 对象:普通高阶函数每次调用都要造一个 Function1 实例(val f: (Int) -> String 的运行时接口就是 kotlin.jvm.functions.Function1);内联后 lambda 体直接就地展开,一个对象都不建;
  • 非局部返回:内联 lambda 里可以直接 return,返回的是外层函数。forEach { if (...) return it } 能写出来,全靠 forEach 是 inline 的;
  • reified:函数体既然被搬到调用点,编译器就能把调用点那个具体类型填进去,于是 T::class、x is T、as T 全部可用——这是绕过类型擦除(上上卡)的唯一办法。

四个修饰符一览

修饰符作用典型场景
inline函数体 + 所有 lambda 实参展开到调用点短小的高阶函数:forEach、let、use、自定义的 measure { }
reified T让 T 在函数体里当真类型用(必须配 inline)fromJson<User>()、filterIsInstance<T>()、viewModel<T>()
noinline这个 lambda 不展开,仍是个真对象需要把 lambda 存起来、当返回值、或传给别的函数时
crossinline仍然展开,但禁止非局部返回lambda 要在另一个执行上下文里跑(塞进 Runnable、回调、另一个线程)

crossinline 为什么必须存在

非局部返回的实现是「跳到外层函数的返回点」。如果这个 lambda 被塞进一个 Runnable、等到以后甚至在别的线程才执行,那时外层函数早就返回了——没有地方可跳。所以只要 lambda 会在「非直接调用」的上下文中执行,就必须标 crossinline,编译器据此禁掉非局部返回。报错原文(crossinline 与 noinline 相同):

error: 'return' is prohibited here.

不标而直接把 lambda 塞进 Runnable { block() },编译器会直接要求你标上——这不是可选的风格问题。

什么时候不该 inline

  • 没有函数类型参数时——编译器会明确警告(报错原文):
    warning: expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of function types.
    这条警告在三种情况下都会出现:完全没有 lambda 参数、唯一的 lambda 参数被标了 noinline、以及泛型扩展函数只做取值。看到这条警告就把 inline 删掉;
  • 函数体很大时——内联是把整段代码复制到每个调用点,调用点越多字节码膨胀越厉害,反而拖慢指令缓存、拉长编译时间;
  • public 的 inline 函数碰不到非 public 成员(因为函数体会被复制到调用方那边去)。报错:
    error: public-API inline function cannot access non-public-API property.;
  • inline 函数的实现是 ABI 的一部分:它被复制进了调用方的字节码,改了实现之后调用方不重新编译就不会生效——写库时尤其要当心。
// ========== reified:绕过类型擦除的唯一办法 ==========
inline fun <reified T> isType(v: Any): Boolean = v is T
isType<String>("x")      // true
isType<Int>("x")         // false

inline fun <reified T> nameOf() = T::class.java.name
nameOf<List<String>>()   // "java.util.List"  ← 内层 String 仍然被擦掉

// 不加 reified 就不行:
// fun <T> bad(): String = T::class.java.name
// error: cannot use 'T' as reified type parameter. Use a class instead.

// 实用形态:反序列化 / 类型过滤
inline fun <reified T> String.parseJson(): T = json.decodeFromString(this)
inline fun <reified T> List<*>.onlyOf(): List<T> = filterIsInstance<T>()

// ========== 非局部返回:只有 inline 才有 ==========
fun firstNeg(xs: List<Int>): Int? {
    xs.forEach { if (it < 0) return it }   // 直接从 firstNeg 返回!
    return null
}
firstNeg(listOf(1, -2, 3))    // -2
// forEach 是 inline 的,所以这个 return 编译成一次普通跳转

// ========== crossinline:lambda 要在别处跑 ==========
inline fun runLater(crossinline block: () -> Unit) {
    Runnable { block() }.run()      // block 在 Runnable 里执行 → 必须 crossinline
}
// fun f(): Int { runLater { return 1 }; return 0 }
// error: 'return' is prohibited here.       ← 外层函数可能早就返回了
runLater { println("ok") }              // 不带 return 就一切正常

// ========== noinline:要把 lambda 存起来 ==========
inline fun setup(init: () -> Unit, noinline onDone: () -> Unit): () -> Unit {
    init()          // 展开
    return onDone  // 要当返回值 → 必须是真对象 → noinline
}
val cb = setup({ println("init") }, { println("done") })
cb()                                   // done
// noinline 的 lambda 同样禁止非局部返回:error: 'return' is prohibited here.

// ========== 什么时候不该 inline ==========
inline fun addOne(x: Int): Int = x + 1
// warning: expected performance impact from inlining is insignificant.
//          Inlining works best for functions with parameters of function types.

class C {
    private val secret = 1
    inline fun leak() = secret
    // error: public-API inline function cannot access non-public-API property.
}
crossinline 常被忽略,直到编译器逼你加上。写一个 inline 函数、想把 lambda 塞进 Runnable / 回调 / 线程池,编译器会直接报错要求标 crossinline——很多人以为是编译器多事,其实它在拦一个真实的灾难:非局部返回的实现是往外层函数的返回点跳,而那个 lambda 可能在外层函数返回之后才执行,那时候根本无处可跳。第二个坑更隐蔽:inline 函数的函数体是二进制兼容性的一部分——它被复制进了每个调用方的 class 文件,你改了实现、只重新编译自己的模块,调用方仍在跑旧的那份。库作者对 public inline 要格外保守。
不要主动给函数加 inline——等到有理由再加。三个正当理由:①函数有函数类型参数且短小(这是唯一的性能理由);②需要 reified(这是功能理由,且无可替代);③需要在 lambda 里非局部返回。除此之外,JIT 自己就会内联热点小函数,你手写的 inline 只是白白膨胀字节码。判断很省事:加上去要是弹出那句 insignificant 警告,就是编译器在告诉你不该加。

Kotlin 的运算符不是魔法,而是一套按名字约定的翻译规则:写 a + b,编译器就去找 a.plus(b)。解构同理——写 val (x, y) = p,编译器就去找 p.component1() 和 p.component2()。理解「它翻译成什么」,就理解了它全部的行为,也就看得见那个坑。

约定名一览

你写的编译器调用的备注
a + b a - b a * b a / b a % bplus minus times div rem都要标 operator
+a -a !aunaryPlus unaryMinus not
a[i] a[i] = vget(i) set(i, v)可以多个下标:a[i, j]
a(x)invoke(x)让对象像函数一样调用
x in aa.contains(x)注意接收者是右边
a < b a >= b …compareTo(b)一个方法管四个运算符
a == ba?.equals(b) ?: (b === null)自带 null 处理,不用 operator;=== 不可重载
a += bplusAssign,没有就退回 a = a.plus(b)两个都定义会编译报错
a..b a..<brangeTo rangeUntil
val (x, y) = aa.component1() a.component2()data class 自动生成
for (x in a)a.iterator()可以给别人的类加个 operator fun iterator() 扩展

infix 不是运算符重载,是另一回事:单参数成员/扩展函数标上 infix 就能写成 a foo b。1 to "one" 里的 to 就是标准库的一个 infix 扩展函数。

解构按位置,不按名字

两个字段名完全相同、只是声明顺序不同的 data class:

声明val (a, b) = ... 的结果
data class UserA(val id: Int, val age: Int)
构造:UserA(id = 7, age = 30)
a = 7(id), b = 30(age)
data class UserB(val age: Int, val id: Int)
构造:UserB(age = 30, id = 7)
a = 30(age), b = 7(id)

危险就在这里:如果解构时变量名写成 val (id, age) = user,名字对不对编译器根本不管——它只按 component1()、component2() 的顺序取。所以把 data class 的两个同类型字段调换顺序,所有解构点的语义会静默反转:不报错、不警告,只是 id 和 age 从此对调。两个字段类型不同时还有类型不匹配兜底,类型相同时就是一场无声的灾难。

// ========== 运算符重载:约定名 + operator ==========
data class Point(val x: Int, val y: Int) {
    operator fun plus(o: Point) = Point(x + o.x, y + o.y)
    operator fun times(k: Int)  = Point(x * k, y * k)
    operator fun unaryMinus()  = Point(-x, -y)
    operator fun get(i: Int)    = if (i == 0) x else y
    operator fun contains(v: Int) = v == x || v == y
    operator fun compareTo(o: Point) = (x*x + y*y).compareTo(o.x*o.x + o.y*o.y)
}
val p = Point(3, 4)
Point(1, 2) + Point(3, 4)     // Point(x=4, y=6)
Point(1, 2) * 3                 // Point(x=3, y=6)
-p                              // Point(x=-3, y=-4)
p[0]                            // 3
3 in p                          // true    ← 接收者是 p,不是 3
Point(1, 1) < Point(5, 5)     // true    ← 一个 compareTo 管 < <= > >=

// invoke:让对象像函数一样被调用
class Validator(val re: Regex) {
    operator fun invoke(s: String) = re.matches(s)
}
val isDigits = Validator(Regex("\\d+"))
isDigits("123")                 // true    ← 其实是 isDigits.invoke("123")

// infix:不是运算符,是中缀调用
infix fun Int.pow(e: Int): Long = generateSequence(1L) { it * this }.elementAt(e)
2 pow 10                       // 1024
1 to "one"                     // 标准库的 to 也只是个 infix 扩展函数

// ========== 解构:componentN,按位置 ==========
val (x, y) = p                  // 等价于 p.component1(), p.component2()
val (k, v) = "name" to "Kotlin"  // Pair 解构
for ((key, value) in mapOf("a" to 1)) { }
listOf(1, 2).forEachIndexed { i, v -> }
val (_, second) = p             // 用 _ 跳过不要的位置

// 给别人的类加解构:写 componentN 扩展
operator fun java.time.LocalDate.component1() = year
operator fun java.time.LocalDate.component2() = monthValue

// ========== 💣 解构按位置,不按名字==========
data class UserA(val id: Int, val age: Int)
data class UserB(val age: Int, val id: Int)   // 同名字段,顺序调换

val (a1, b1) = UserA(id = 7, age = 30)
// a1 = 7  (id),  b1 = 30 (age)

val (a2, b2) = UserB(age = 30, id = 7)
// a2 = 30 (age), b2 = 7  (id)      ← 变量名写成 (id, age) 也照样这么取!

// 也就是说,把 data class 的字段顺序一改:
//   val (id, age) = user
// 所有解构点的含义静默反转。同类型字段时编译器一声不吭。
// ✅ 安全写法:字段多于 2~3 个就别解构,老实写 user.id / user.age
解构是按位置的,这让 data class 的字段顺序变成了公开 API。把两个同类型字段调换位置——比如 (val id: Int, val age: Int) 改成 (val age: Int, val id: Int)——所有 val (id, age) = user 的地方会静默把两个值对调,编译通过、测试可能也通过(如果测试数据不巧对称),线上才发现年龄变成了用户 ID。两个字段顺序不同的 data class 确实给出相反的解构结果。三条防线:①字段超过 2~3 个就别解构,写 user.id 清楚又安全;②data class 的字段顺序当成不可随意改的契约;③给 data class 加字段一律加在末尾并带默认值。
运算符重载只在「这个符号在该领域有公认含义」时才用。Vector + Vector、Money * 2、Duration - Duration、Matrix[i, j]——好。User + Order、Config / String——坏,读者要去翻实现才知道是什么意思,还不如一个叫 attachTo() 的普通方法。invoke 是个例外,它在「策略对象」「校验器」「工厂」上几乎总是让代码更好读。至于 compareTo:定义了它就免费获得 < <= > >=、能进 sorted()、能用 coerceIn——性价比最高的一个约定。

前面几卡讲了泛型能做什么,这里回答更实际的问题:什么时候该用泛型,什么时候用了反而是负担。泛型的收益是复用,成本是每一个类型参数都会让签名、报错信息、IDE 提示同步变复杂——它不是越多越好。

泛型 vs sealed:一条清晰的分界

用泛型 <T>用 sealed class/interface
适用于类型是开放的:调用方决定装什么,你的代码不关心装的是什么类型是封闭的:种类由你穷举,且每一种要走不同逻辑
典型Box<T>、Repository<T>、Cache<K, V>sealed interface Shape { Circle, Square }、状态机、AST
判据一句话「我对里面的东西一视同仁」「我要对每一种分别处理」
常见错误为了「以后可能扩展」提前泛型化,结果整个模块多背一个从没换过的类型参数用泛型 + when (x) { is A -> ... } 模拟——丧失了 when 的穷尽性检查

两者常常合用:sealed interface Result<out T> { data class Ok<T>(val v: T); data class Err(val m: String) : Result<Nothing> }——种类封闭(用 sealed)、成功时装什么开放(用泛型)。注意 Err 用 Result<Nothing>:Nothing 是所有类型的子类型,配上 out T 就让 Err 能当任何 Result<X> 用——这是 out 最漂亮的一个应用。

Comparable vs Comparator

Comparable<in T>Comparator<in T>
是什么类型自带的天然顺序,实现在类内部外部提供的一种排法,可以有无数个
怎么写class Money : Comparable<Money> { override fun compareTo(o) }compareBy { it.age }、compareByDescending、thenBy
用在sorted()、< >、maxOrNull()、coerceIn()sortedWith(cmp)、maxWithOrNull(cmp)、TreeMap(cmp)
怎么选「这个类型只有一种天经地义的排法」才实现它(金额、日期、版本号)业务上有多种排法(按名/按时间/按热度)——一律用 Comparator,别硬塞进 compareTo

写泛型函数时用上界 <T : Comparable<T>> 就能在函数体里直接用 < >;要更灵活就多收一个 Comparator<T> 参数。compareBy/thenBy/nullsLast 这套组合子能拼出绝大多数排序需求,几乎不需要手写 Comparator。

泛型函数还是泛型类

  • 类型参数只在一个方法里出现 → 放在函数上:fun <T> List<T>.secondOrNull(): T?。类不必被污染;
  • 类型参数要在多个成员之间保持一致(字段存了它、方法收发它) → 放在类上:class Cache<K, V>;
  • 类是泛型的、但某个方法用到的是另一个类型 → 方法上再开一个:class Box<T> { fun <R> map(f: (T) -> R): Box<R> };
  • 经验值:三个以上类型参数就该停下来重新设计了——通常说明这个类承担了太多职责,或者该引入一个中间数据类。

Any? 还是 *?

写法含义什么时候用
List<Any?>「元素可以是任何东西」——一个具体的、确定的类型你真的要往里放各种东西(异构列表)
List<*>「元素是某一种类型,但我不知道是哪种」写只关心 size/遍历/打印的工具函数;禁止写入,因此更安全
<T> 泛型函数「是哪种我不知道,但我要在函数内部保持一致」返回值类型要跟参数类型挂钩时——这是绝大多数情况的正解

顺序是固定的:先试泛型函数,不行才用 *,最后才考虑 Any?。List<String> 可以传给收 List<*> 的函数,也可以传给收 List<Any?> 的函数(因为 List<out E> 协变)——但 MutableList<String> 只能传给 MutableList<*>。

// ========== 泛型 + sealed 合用:Result 的标准写法 ==========
sealed interface Res<out T> {
    data class Ok<T>(val value: T) : Res<T>
    data class Err(val msg: String) : Res<Nothing>   // Nothing + out ⇒ Err 能当任何 Res 用
}
fun parse(s: String): Res<Int> =
    s.toIntOrNull()?.let { Res.Ok(it) } ?: Res.Err("not a number")

when (val r = parse("12")) {          // sealed ⇒ when 穷尽,不用写 else
    is Res.Ok  -> println(r.value)
    is Res.Err -> println(r.msg)
}

// ========== Comparable:只有一种天然顺序时才实现 ==========
class Money(val cents: Long) : Comparable<Money> {
    override fun compareTo(other: Money) = cents.compareTo(other.cents)
}
listOf(Money(300), Money(100)).sorted()      // 免费获得
Money(100) < Money(300)                       // 免费获得

// ========== Comparator:业务上有多种排法 ==========
data class Emp(val id: Int, val age: Int)
val byAgeThenId = compareBy<Emp> { it.age }.thenByDescending { it.id }
listOf(Emp(1, 30), Emp(2, 25), Emp(3, 30)).sortedWith(byAgeThenId)
// [Emp(2, 25), Emp(3, 30), Emp(1, 30)]

// 泛型函数用上界拿到 < 和 >
fun <T : Comparable<T>> clamp(v: T, lo: T, hi: T): T = maxOf(lo, minOf(v, hi))
// 要更灵活就把 Comparator 收成参数
fun <T> topOf(xs: List<T>, cmp: Comparator<T>): T? = xs.maxWithOrNull(cmp)

// ========== 泛型函数 vs 泛型类 ==========
// T 只出现在一个方法里 → 放函数上,类不必污染
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)

// T 要贯穿多个成员 → 放类上
class Cache<K, V>(private val max: Int) {
    private val m = LinkedHashMap<K, V>()
    fun put(k: K, v: V) { m[k] = v }
    fun get(k: K): V? = m[k]
    fun <R> mapValues(f: (V) -> R): Cache<K, R> = TODO()  // 方法再开一个 R
}

// 类上拿不到 reified(类不能 inline)→ 老实把 Class 传进来
class Codec<T>(private val type: Class<T>) {
    fun decode(s: String): T = TODO()
    companion object {
        inline fun <reified T> of() = Codec(T::class.java)   // 给个好用的入口
    }
}
val c = Codec.of<String>()          // 调用方不用写 String::class.java

// ========== Any? 还是 * ==========
fun describe(c: Collection<*>) = c.size          // ✅ 只关心 size,禁止写入更安全
fun <T> firstOrDefault(xs: List<T>, d: T): T =      // ✅ 返回值要跟参数挂钩 → 泛型
    xs.firstOrNull() ?: d
// fun bad(xs: List<Any?>): Any? = xs.firstOrNull()   // ❌ 返回类型信息全丢了
过早泛型化是 API 设计里最常见的自伤。「以后可能要支持别的类型」于是先加个 <T>,结果三年后这个 T 从头到尾只被填过一种类型,却让每一处签名、每一条报错、每一次 IDE 补全都多背一层尖括号。真要扩展的那天,把具体类型抽成泛型是个机械的重构,IDE 一键就能做——提前付的这笔成本完全不必要。另一个具体的坑:类上拿不到 reified(类不能 inline),很多人在泛型类里写 T::class 撞墙后才发现要改设计——正确做法是把 Class<T>/KClass<T> 收进构造函数,再配一个 inline fun <reified T> of() 的伴生工厂给调用方省事。
泛型是给调用方的便利,不是给作者的炫技。加一个类型参数前先问:「不加会怎样?」——如果答案是「调用方要多写几个转型或者 as」,那就加;如果答案是「没什么区别,只是显得更通用」,那就别加。同样地,out/in 是纯赚的:标对了调用方少写转换,标错了编译器当场报错,不存在中间地带——所以设计接口时该标就标。

协程与结构化并发

本章覆盖:挂起到底是什么(不是线程)、launch / async / withContext / coroutineScope 怎么选、结构化并发的规则、调度器全表、取消是协作式的与异常传播。「catch (e: Exception) 吞掉取消异常」是这一章最贵的一个坑。

一句话定位:suspend 不是开线程,而是「这里可以让出,等结果到了再从这一行继续」。协程真正值钱的是结构化并发——每个协程都属于某个作用域,作用域结束时它们一起结束。

构建器:什么时候用哪个

写法返回什么时候用
launch { }Job发起一件不需要结果的事(触发即忘,但仍受作用域管)
async { } + await()Deferred<T>要结果,而且想并发:先全部 async,再一起 await
withContext(Dispatchers.IO) { }lambda 的结果切换调度器并等它完成——写挂起函数时最常用的一个
coroutineScope { }lambda 的结果在挂起函数里开一组子协程,全部完成才返回;任一失败则全部取消
supervisorScope { }同上子协程失败不牵连兄弟
runBlocking { }lambda 的结果只在 main 与测试里用——它会阻塞当前线程
flow { }Flow<T>要一串值(11 章)
声明挂起函数suspend fun load(): Data只能从协程或另一个挂起函数里调
挂起函数的规矩挂起函数应该是「主安全」的:内部自己 withContext 切到合适的调度器,不要求调用方先切线程

作用域与结构化并发

要点说明
核心规则父协程等所有子协程完成;父被取消则子全部取消;子异常默认上抛并取消兄弟
自建作用域val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),生命周期结束时 scope.cancel()
GlobalScope基本别用:不属于任何生命周期,取消不了、泄漏没人管
Android / 框架作用域viewModelScope · lifecycleScope——自动跟随生命周期取消
SupervisorJob子协程失败不牵连兄弟;顶层作用域通常要它
Job 的操作job.cancel() · job.join() · job.cancelAndJoin() · isActive
+ 组合上下文launch(Dispatchers.IO + CoroutineName("sync")) { }
调试JVM 参数 -Dkotlinx.coroutines.debug,日志里会带协程名

调度器

调度器线程放什么
Dispatchers.DefaultCPU 核数大小的池CPU 密集计算(默认值)
Dispatchers.IO弹性扩容的大池阻塞式 IO:文件、JDBC、老式网络库
Dispatchers.MainUI 线程Android / Compose 更新界面;需要对应平台的依赖
Dispatchers.Unconfined不确定别在业务里用
限制并发Dispatchers.IO.limitedParallelism(4)给某类任务设上限
自定义Executors.newFixedThreadPool(n).asCoroutineDispatcher()用完要 close()
判据真正的挂起式 API(Ktor、Room、R2DBC)不需要切 IO;只有把阻塞调用包进协程时才需要 withContext(Dispatchers.IO)

取消与异常

要点说明
取消是协作式的只有在挂起点才会真正停下;纯计算循环不会自动响应取消
让计算可取消循环里 ensureActive() 或 yield(),或判断 isActive
CancellationException它是正常控制流,不是错误
最大的坑try { … } catch (e: Exception) 会吞掉取消异常,导致协程「取消不掉」——要么 catch (e: CancellationException) { throw e },要么只接具体类型
runCatching同样会吞;协程里慎用
清理finally { } 里如果要做挂起操作,包进 withContext(NonCancellable) { }
超时withTimeout(5.seconds) { }(超时抛)· withTimeoutOrNull(超时给 null)
异常传播launch 的异常立刻上抛给父级;async 的异常在 await() 时才抛
兜底CoroutineExceptionHandler 只对 launch 的顶层协程有效
并发原语Mutex().withLock { }(别用 synchronized,它会阻塞线程)· Semaphore(8) 限流
// ① 串行 vs 并发:只差「什么时候 await」
suspend fun slow() = coroutineScope {
    val a = fetchA(); val b = fetchB()          // 1 秒 + 1 秒
    a + b
}
suspend fun fast() = coroutineScope {
    val a = async { fetchA() }                 // 先全部发起
    val b = async { fetchB() }
    a.await() + b.await()                       // 再一起等:约 1 秒
}

// ② 挂起函数要「主安全」:自己切调度器,别要求调用方切
suspend fun readConfig(): String = withContext(Dispatchers.IO) {
    File("app.yaml").readText()                // 阻塞调用包进 IO
}

// ③ 取消:纯计算循环必须自己让出
suspend fun crunch(n: Int) = withContext(Dispatchers.Default) {
    var acc = 0L
    for (i in 0..<n) {
        ensureActive()                           // ← 没有它,cancel() 不会生效
        acc += heavy(i)
    }
    acc
}

// ④ 最常见的取消 bug:catch (e: Exception) 吞掉了 CancellationException
try {
    doWork()
} catch (e: CancellationException) {
    throw e                                      // ← 必须原样抛出去
} catch (e: Exception) {
    log.warn("失败", e)
}

// ⑤ 限流并发 + 超时:批量请求的标准形状
suspend fun fetchAll(urls: List<String>): List<String> = coroutineScope {
    val gate = Semaphore(8)
    urls.map { url ->
        async {
            gate.withPermit {
                withTimeout(10.seconds) { client.get(url) }
            }
        }
    }.awaitAll()
}
catch (e: Exception) 会吞掉 CancellationException,结果是协程取消不掉、作用域退不出、界面卡住。这是协程里最隐蔽也最常见的一个坑——要么先接住它再 throw e,要么只 catch 具体的异常类型。runCatching 有同样的问题。
取消是协作式的:一个纯 CPU 的 for 循环即使在协程里,cancel() 也拿它没办法,因为它从不到达挂起点。循环里加 ensureActive()。
async 的异常在 await() 时才抛:如果你 async 了却没 await,异常可能悄无声息地消失在作用域里。
两条能避开大半协程事故的默认动作:挂起函数自己 withContext 切调度器(调用方永远不需要关心线程),以及永远在某个有生命周期的作用域里启动协程(不要 GlobalScope)。
「并发做几件事」的固定形状就是 coroutineScope { async … ; awaitAll() },背下来就够用。

协程只有一个核心机制:挂起(suspend)。函数执行到一半让出线程、把剩下的工作存起来,等条件满足再从断点恢复——所有别的东西(构建器、调度器、Flow)都建立在这之上。

suspend 是有传染性的

suspend 函数只能在协程里或另一个 suspend 函数里调用。从普通函数调它,编译器直接拒绝(2.4.10 报错原文):

  • error: suspend function 'suspend fun fetchUser(): String' can only be called from a coroutine or another suspend function.

所以 suspend 会沿着调用链一路向上蔓延,直到遇到一个协程构建器(launch / async / runBlocking)为止。构建器就是「普通世界」和「协程世界」的边界。

挂起点在哪:不是你想挂就能挂

一个 suspend 函数不会自己挂起。真正会挂起的只有那些底层用 suspendCoroutine 实现的库函数:delay、Deferred.await、Job.join、yield、Channel.send/receive、Mutex.lock、Flow.collect 等。普通的算术、循环、IO 调用都不是挂起点——一个只做 CPU 计算的 suspend 函数从头跑到尾,一次都不会让出线程。

挂起 ≠ 阻塞(这是最关键的一条)

阻塞是「线程停在这里等」,挂起是「协程记下进度、把线程还回去」。把并行度限制到 2 的调度器上丢 20 个协程,每个等同样长的时间——

写法线程状态结果
Thread.sleep(t)线程被占死只能 2 个 2 个来,跑满 10 批
delay(t)线程被交还给调度器20 个同时在等,1 批就完

换算下来相差整整一个数量级,而这个倍数正好等于「协程数 ÷ 线程数」——不是玄学,是线程有没有被占住的直接后果。

编译后发生了什么:Continuation

挂起不是 JVM 的原生能力,是编译器做的。suspend fun fetchUser(id: Int): String 编译后的 JVM 签名是(javap -p):

  • public static final Object fetchUser(int, Continuation<? super String>)
  • 对比同签名的普通函数:public static final String plainFetch(int)

两处变化:多了一个 Continuation 参数(就是「剩下要做的事」的回调句柄),返回类型从 String 变成 Object——因为函数可能不返回值、而是返回一个特殊标记 COROUTINE_SUSPENDED 表示「我挂起了,回头再叫我」。编译器还会把函数体切成一个状态机,每个挂起点是一个状态。所谓「协程比线程轻」,轻就轻在这里:一次挂起只是存了几个字段,没有内核线程切换。

import kotlinx.coroutines.*

// ① suspend 有传染性:普通函数里调不了
suspend fun fetchUser(id: Int): String {
    delay(10)                   // ← 挂起点:让出线程,不占住它
    return "user-$id"
}

fun plainCaller() = fetchUser(1)
// error: suspend function 'suspend fun fetchUser(id: Int): String'
//        can only be called from a coroutine or another suspend function.

// ② 挂起 ≠ 阻塞:只有 2 个线程的调度器上跑 20 个协程
val pool = Dispatchers.Default.limitedParallelism(2)

withContext(pool) { repeat(20) { launch { Thread.sleep(200) } } }
// 线程被占死 → 2 个 2 个来,跑满 10 批

withContext(pool) { repeat(20) { launch { delay(200) } } }
// 挂起即交还线程 → 20 个一起等,1 批就完

// ③ 编译后:javap -p 看真实签名
// public static final Object fetchUser(int, Continuation<? super String>);
// public static final String plainFetch(int);
//   ↑ 多了 Continuation 参数;返回类型退化成 Object,
//     因为它可能返回 COROUTINE_SUSPENDED 这个「我挂起了」的标记

把 suspend 当成「自动异步」是最普遍的误解。suspend 修饰符本身不会让代码换线程、也不会让代码并发——它只是允许函数内部出现挂起点。这两个坑最常见:

  • 在 suspend 函数里写一个阻塞调用(Thread.sleep、老式 InputStream.read、JDBC 查询),线程照样被占死,协程的全部优势瞬间归零。要么用挂起版 API,要么用 withContext(Dispatchers.IO) 把它隔离到可扩张的线程池(详见「调度器与上下文」卡)。
  • 顺序调用两个 suspend 函数,它们是串行的,不会自动并发。要并发得显式用 async。
想知道某个函数会不会挂起,看它有没有 suspend 修饰只是必要条件;真正的判据是它内部有没有调用挂起点。IDE 会在挂起点的行号槽里画一个小箭头图标,那是最直观的「这里会让出线程」标记。写库时反过来用这条:如果你的 suspend 函数其实一次都不挂起,就别加 suspend,否则白白污染所有调用方。

构建器是从「普通代码」进入「协程世界」的门。三个常用的,用途完全不同,选错了不是风格问题而是 bug。

三者对照

构建器返回阻塞当前线程?异常什么时候抛用途
launchJob不立刻向父协程传播「发射后不管」,不需要返回值
asyncDeferred<T>不推迟到 await() 时要并发算出一个结果
runBlockingT(块的值)会直接抛给调用方只该出现在 main 和测试里

Deferred<T> 就是带返回值的 Job(它继承自 Job),所以 cancel() / join() / isActive 这些它都有,只是多了 await()。

async 的 await 要放在最后

async 本身立刻开始执行,await() 只是「等结果」。所以并发的关键是:先把所有 async 都起起来,再一个个 await。写成 async { a() }.await() 紧挨着,等于把并发写回了串行——这是 code review 里最常抓到的一条。

runBlocking 是一堵墙

它会阻塞当前线程直到内部协程全部结束——这正是它存在的意义(把协程世界的结果搬回阻塞世界),也正是它的危险。合法场景只有两个:fun main() = runBlocking { },以及单元测试(更推荐用 runTest)。在 suspend 函数里、在 UI 线程上、在已有协程内部调它,轻则白白占住线程,重则和当前调度器互等直接死锁。在协程里想「等一下」,用 coroutineScope 或 withContext,永远不是 runBlocking。

import kotlinx.coroutines.*

fun main() = runBlocking {      // ← 唯一推荐用 runBlocking 的地方
    // launch:拿到 Job,不要返回值
    val job = launch {
        delay(100)
        println("World")
    }
    println("Hello")
    job.join()                  // 挂起等它结束(不阻塞线程)
}

// async:要并发算结果。注意 await 全部放最后
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user  = async { fetchUser() }   // 立刻开跑
    val posts = async { fetchPosts() }  // 也立刻开跑
    Dashboard(user.await(), posts.await())  // 真并发
}

// ✗ 反面:await 紧跟 async → 退化成串行,白写了
suspend fun wrong(): Dashboard = coroutineScope {
    val u = async { fetchUser() }.await()   // 等完它…
    val p = async { fetchPosts() }.await()  // …才开始这个
    Dashboard(u, p)
}

// Deferred 就是带值的 Job
val d: Deferred<Int> = async { 42 }
d.cancel(); d.join(); d.isActive     // Job 的能力它都有
val v: Int = d.await()               // 多出来的只有 await

async 的异常是延迟的:协程里抛了异常,只要没人调 await(),你可能永远看不到它。—在 supervisorScope 里 async { throw IllegalStateException("async-boom") },创建后什么都不发生,一直等到 await() 才拿到 IllegalStateException: async-boom。

更阴的是:在普通(非 supervisor)作用域里,async 的异常同时会立刻向父协程传播导致整个作用域垮掉,即使你在 await() 外面套了 try/catch 也只能捕到它、拦不住作用域被取消。所以 async 的错误处理要么放在 supervisorScope 里,要么在 async 块内部就把异常处理成返回值。

写库的时候别自己 launch——把函数声明成 suspend,或者要求调用方传一个 CoroutineScope 进来,让调用方决定生命周期。库内部偷偷起协程,调用方既取消不掉也等不到,这是并发泄漏的经典源头(见「结构化并发」卡)。

结构化并发是 Kotlin 协程相对裸线程最大的设计胜利:每个协程都必须有父,父不结束就不算完、父被取消子全被取消、子出错父知道。没有孤儿,就没有泄漏。

CoroutineScope 是什么

它只是一个持有 CoroutineContext 的接口,而这个上下文里最要紧的元素是 Job。在某个作用域上调 launch,新协程的 Job 就成了该作用域 Job 的子——父子关系是靠 Job 串起来的,不是靠代码嵌套。这条关系带来三件事:

  • 父协程会等所有子协程结束才算完成;
  • 取消父 → 递归取消所有子;
  • 子协程失败 → 异常向上传给父(SupervisorJob 除外)。

coroutineScope vs supervisorScope:失败会不会连坐

两个都是挂起函数,都会等内部所有子协程结束才返回,区别只在失败传播:

子协程抛异常时兄弟协程作用域本身
coroutineScope异常向外抛给调用方被连坐取消异常结束
supervisorScope交给该子协程的 CoroutineExceptionHandler不受影响,继续跑完正常结束

里 coroutineScope 那组,兄弟协程的 println 一次都没打出来——它在打印前就被取消了;supervisorScope 那组兄弟正常打印,作用域也正常结束。选择判据:多个子任务是「同一件事的组成部分」(缺一不可)用 coroutineScope;是「一批互相独立的任务」(挂一个不影响别的)用 supervisorScope。

GlobalScope 是反模式

GlobalScope.launch 起的协程没有父,它的生命周期等于整个进程。外层协程被 cancelAndJoin() 之后,它的普通子协程立刻停了,而同一位置起的 GlobalScope 协程照常跑完全部循环。后果是三重的——取消不掉、没人等它、异常没人接(只会打到默认处理器)。它带 @DelicateCoroutinesApi 标注不是装饰。

正确的替代:临时并发用 coroutineScope { };需要一个和某个组件同生命周期的长期作用域,就自己建一个并在组件销毁时 cancel()——CoroutineScope(SupervisorJob() + Dispatchers.Default)。这个手动作用域 cancel() 后子协程确实立刻停止。

作用域内的共享状态:Mutex 与 Semaphore

协程并发同样有数据竞争(一万个协程无锁 counter++,结果小于一万)。Mutex.withLock { } 是协程版互斥锁——它在等锁时挂起而不是阻塞线程,这是它和 synchronized 的本质区别(在 synchronized 块里调挂起函数是编译错误)。限制并发数用 Semaphore(n).withPermit { }。

import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*

// ① coroutineScope:一个子失败 → 兄弟被取消、异常向外抛
try {
    coroutineScope {
        launch { delay(50); println("兄弟完成") }   // ← 从没打印过
        launch { delay(10); throw RuntimeException("boom") }
    }
} catch (e: Exception) {
    println(e.message)                // boom
}

// ② supervisorScope:失败不连坐,作用域正常结束
supervisorScope {
    launch(CoroutineExceptionHandler { _, e -> println("handled: " + e.message) }) {
        throw RuntimeException("boom")
    }
    launch { delay(50); println("兄弟完成") }  // ← 照常打印
}

// ③ ✗ GlobalScope:没有父,取消不掉、没人等、异常没人接
val outer = launch {
    GlobalScope.launch { repeat(5) { delay(30); println("global 还活着") } }
    launch            { repeat(5) { delay(30); println("子协程还活着") } }
}
outer.cancelAndJoin()
// 「子协程还活着」立刻停;「global 还活着」把 5 次全打完

// ④ ✓ 需要长期作用域就自己建,并负责 cancel
class Worker : AutoCloseable {
    private val scope = CoroutineScope(
        SupervisorJob() + Dispatchers.Default + CoroutineName("worker")
    )
    fun submit(task: suspend () -> Unit) { scope.launch { task() } }
    override fun close() { scope.cancel() }   // ← 生命周期终点必须有这句
}

// ⑤ 共享可变状态:Mutex 挂起而非阻塞
val mutex = Mutex()
var counter = 0
coroutineScope {
    repeat(10_000) { launch(Dispatchers.Default) { mutex.withLock { counter++ } } }
}
// 去掉 withLock,counter 会小于 10000

val sem = Semaphore(3)               // 最多 3 个并发
sem.withPermit { callApi() }

CoroutineScope(...) 这个工厂函数和 coroutineScope { } 这个挂起函数只差一个大小写,行为却相反:前者创建一个你必须自己管 cancel() 的长期作用域并立即返回,后者会挂起等到内部全部完成。写错了就是「以为在等,其实早就往下跑了」。

还有一处:手动作用域一律要用 SupervisorJob()。若用默认的 Job(),任何一个子协程失败都会让整个作用域进入取消状态,之后再 launch 什么都不会执行也不会报错——一个用完就废的作用域,排查起来极其费时。

判断一段协程代码有没有泄漏风险,只问一句:「它的 Job 挂在谁身上,谁负责 cancel」。答不上来就是泄漏。Android 上现成的答案是 viewModelScope / lifecycleScope(框架帮你 cancel);纯 JVM 服务端就是自己建的作用域 + 关停时 cancel()。

协程本身不含线程语义,「跑在哪个线程上」由 Dispatcher 决定。选错调度器不会报错,只会让程序莫名其妙地慢或卡死,所以这块要按用途记,不能按名字猜。

四个内置调度器

调度器线程池用来干什么选它的判据
Dispatchers.Default大小 ≈ CPU 核数(至少 2)CPU 密集:解析、排序、加解密、图像处理任务一直在算,多开线程也没用
Dispatchers.IO可扩张,上限远大于核数阻塞式 IO:文件、JDBC、老式 HTTP 客户端任务大部分时间在等,线程被占住是常态
Dispatchers.Main平台 UI 单线程更新 UI只在 Android / JavaFX / Swing 上存在(见下)
Dispatchers.Unconfined不限定基本只用于测试和特殊库实现业务代码里基本不该出现

Default 和 IO 共享同一个线程池,只是各自有不同的并发上限,所以两者之间 withContext 切换通常不会真的换线程、开销很小。

纯 JVM 上没有 Dispatchers.Main

本页以 JVM 为主线,这点要说清楚:Dispatchers.Main 由平台模块提供(kotlinx-coroutines-android / -javafx / -swing)。只依赖 kotlinx-coroutines-core 的普通 JVM 程序里用它,运行时直接抛 IllegalStateException(报错原文):

  • Module with the Main dispatcher is missing. Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android' and ensure it has the same version as 'kotlinx-coroutines-core'

注意这是运行时错误不是编译错误——照着 Android 教程抄代码到服务端项目里,编译一路绿灯,跑起来才炸。

CoroutineContext 是一组元素的和

CoroutineContext 像一个以类型为键的不可变 Map,用 + 拼装、用 coroutineContext[Key] 取。四类常用元素:

  • Job——生命周期与父子关系(SupervisorJob() 是它的变体);
  • CoroutineDispatcher——在哪个线程跑;
  • CoroutineName——调试用的名字,配合 -Dkotlinx.coroutines.debug 会打进线程名;
  • CoroutineExceptionHandler——未捕获异常的兜底(只对 launch、且只在根协程上生效)。

子协程继承父的上下文,但 Job 永远是新建的子 Job——这正是父子树能成立的原因。launch(Dispatchers.IO) 传的元素会覆盖继承来的同类元素。

withContext:切换而不新建协程

withContext(ctx) { } 是挂起函数:它在指定上下文里跑完这个块、把结果返回、然后回到原来的上下文,全程只有一个协程。这是「把阻塞调用隔离到 IO 池」的标准手法,也是 suspend 函数应该遵守的礼貌——一个 suspend 函数应该能在任何调度器上被安全调用,需要换线程是它自己的责任,不该甩给调用方。

import kotlinx.coroutines.*

// ① 按「在等 vs 在算」选调度器
launch(Dispatchers.Default) { parseHugeJson() }   // CPU 密集
launch(Dispatchers.IO)      { file.readText() }     // 阻塞 IO
launch(Dispatchers.Main)    { render() }            // 仅 Android/JavaFX/Swing

// 纯 JVM 上用 Main:编译通过,运行时抛
// java.lang.IllegalStateException: Module with the Main dispatcher is missing.
// Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'
// and ensure it has the same version as 'kotlinx-coroutines-core'

// ② withContext:切上下文,不新建协程;suspend 函数自己负责换线程
suspend fun loadItems(): List<Item> {
    val raw = withContext(Dispatchers.IO) { api.fetchRaw() }  // 阻塞调用隔离到 IO
    return withContext(Dispatchers.Default) { raw.map(::parse) } // 重解析回 Default
}
// ↑ 调用方在哪个调度器上调它都安全,这是 suspend 函数该有的礼貌

// ③ CoroutineContext = 一组元素的和
val ctx = SupervisorJob() + Dispatchers.IO +
          CoroutineName("sync") +
          CoroutineExceptionHandler { _, e -> log(e) }

launch(ctx) {
    println(coroutineContext[CoroutineName]?.name)   // sync
    println(coroutineContext[Job])                   // 新建的子 Job,不是上面那个
}

// ④ 需要严格单线程(取代实验性的 newSingleThreadContext,后者需 opt-in)
val single = Dispatchers.Default.limitedParallelism(1)

把阻塞调用放在 Dispatchers.Default 上是最伤的一种误用。Default 的线程数就是核数级别,几个阻塞调用就能把它全占死,而这个池是全进程共享的——你堵住的不只是自己这段代码,是整个应用里所有用 Default 的协程(包括默认调度的 Flow 操作符)。症状是「某个不相干的模块突然全线卡住」,极难定位。凡是可能阻塞的调用,一律 withContext(Dispatchers.IO)。

另一个高频误解:withContext 不制造并发。withContext(IO) { a() } 后面跟 withContext(IO) { b() } 依然是串行的,只是换了线程跑。要并发得用 async。

判断该用 Default 还是 IO,问「这个任务在等,还是在算」。等(网络、磁盘、数据库)→ IO;算 → Default。需要严格串行又不想上锁时,可以用 Dispatchers.Default.limitedParallelism(1) 造一个单线程调度器(它取代了实验性的 newSingleThreadContext——后者不是「废弃」而是需要 opt-in @ExperimentalCoroutinesApi)。

取消是协程里最容易「以为自己会了」的部分。核心只有一句:取消是协作式的——你不检查,它就取消不掉。

不检查取消的循环,取消不掉

调 cancel() 只是把 Job 标成取消中,真正的中断发生在下一个挂起点。一个纯 CPU 循环里没有挂起点,于是它会一路跑完。一个只做计算的 while 循环,cancelAndJoin() 之后 job.isCancelled 已经是 true,循环却把五轮全部打印完还打出了「循环跑完了」。

三种修法,任选其一插进循环体:

  • ensureActive()——已取消就抛 CancellationException,最简洁(加上后循环只打印了第一轮就停了);
  • yield()——既检查取消,又给同调度器上别的协程一个执行机会;
  • if (!isActive) return@launch——需要自己收尾时用。

头号坑:runCatching 会吞掉取消

协程的取消是靠抛 CancellationException(实际类型是 kotlinx.coroutines.JobCancellationException)实现的。任何捕获宽泛异常的写法——catch (e: Exception)、catch (e: Throwable)、以及最隐蔽的 runCatching { }——都会把它一起吃掉,取消机制当场失效。输出:

  • onFailure=kotlinx.coroutines.JobCancellationException / StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@…
  • 紧接着下一行照样打印:still running after cancellation!

也就是说协程已经被标记为取消,代码却继续往下跑。正确写法是捕获后重抛:catch (e: CancellationException) { throw e },或者干脆只捕获你真正关心的具体异常类型。

清理要用 NonCancellable

协程被取消后,它内部所有挂起点都会立刻抛 CancellationException——包括 finally 块里的。取消后在 finally 里调 delay(10),runCatching{...}.isSuccess 为 false,也就是根本没执行成。要在清理时做挂起操作(关连接、写日志、回滚),必须包一层 withContext(NonCancellable) { }——包上之后清理正常完成。这层只用来裹住短小的清理动作,别拿它包业务逻辑,否则又变回不可取消的代码了。

launch 与 async 的异常路径不同

异常去向怎么处理
launch立刻向上传给父 Job协程内 try/catch,或根协程上装 CoroutineExceptionHandler
async存进 Deferred,等 await() 时抛try/catch 包 await();没人 await 就永远看不到

CoroutineExceptionHandler 对 async 不生效,对非根协程的 launch 也不生效(异常先传给父,父才决定怎么办)——装在中间层是这块最常见的无效操作。

import kotlinx.coroutines.*

// ① ✗ 取消不掉:循环里没有任何挂起点
val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 5) { heavyStep(i); i++ }
    println("循环跑完了")             // 取消后照样打出来
}
job.cancelAndJoin()
println(job.isCancelled)                 // true —— 标记生效了,代码却没停

// ✓ 加一句检查就好了
launch(Dispatchers.Default) {
    var i = 0
    while (i < 5) { ensureActive(); heavyStep(i); i++ }
}                                        // 第一轮之后就停了

// ② ✗ 头号坑:runCatching 吞掉 CancellationException
launch {
    runCatching { delay(10_000) }
        .onFailure { println(it) }
    // 输出:kotlinx.coroutines.JobCancellationException:
    //   StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@...
    println("still running after cancellation!")  // ← 竟然照样执行
}

// ✓ 捕获了就重抛
launch {
    try { delay(10_000) }
    catch (e: CancellationException) { cleanupSync(); throw e }
    println("这行不会执行")
}

// ③ finally 里的挂起调用需要 NonCancellable
launch {
    try { processData() } finally {
        delay(10)                       // ✗ 已取消,立刻抛,清理没做成
        withContext(NonCancellable) {   // ✓ 只裹清理动作
            saveState(); conn.closeSuspend()
        }
    }
}

// ④ launch 与 async 的异常路径
supervisorScope {
    val d = async { throw IllegalStateException("async-boom") }
    delay(50)                            // 此刻什么都没抛
    try { d.await() }
    catch (e: Exception) { println(e.message) }   // async-boom,到这才抛
}

// ⑤ 给不受控的外部调用加保险
val r = withTimeoutOrNull(3000) { callFlakyApi() }  // 超时得到 null

CancellationException 是正常的控制流信号,不是失败。这决定了两件反直觉的事:一,子协程因为取消而抛它,父协程不会被当成失败处理;二,你的 catch (e: Exception) { log.error(...) } 会把大量取消当成错误刷进日志——线上日志里成片的 JobCancellationException 几乎都是这么来的。

另外别在 catch 里对已取消的协程继续做挂起调用(重试、上报),它们会立刻再抛一次取消异常;要么在 NonCancellable 里做,要么放到调用方去做。

withTimeout(ms) { } 超时会抛 TimeoutCancellationException(它是 CancellationException 的子类,所以只取消这个块、不影响父协程);不想处理异常就用 withTimeoutOrNull(ms) { },超时返回 null。这两个是给不受控的外部调用加保险的标准手法。

Flow 与响应式流

本章覆盖:Flow 冷流的创建与收集、操作符按用途反查(含 debounce 与 flatMapLatest 这对搜索框标配)、flowOn 只影响上游与背压三件套、StateFlow / SharedFlow / Channel 三种热流的对照。

一句话分工:suspend 函数拿一个值,Flow 拿一串值。Flow 默认是冷流——不收集就什么都不发生,每次收集都从头执行一遍。

创建与收集

要什么怎么写
从现成数据flowOf(1, 2, 3) · list.asFlow()
自己发flow { emit(x); delay(100); emit(y) }——里面可以挂起
包裹回调式 APIcallbackFlow { … awaitClose { 注销监听 } }——awaitClose 不写会抛异常
定时flow { while (true) { emit(poll()); delay(1000) } }
收集flow.collect { … }——它是挂起函数,会一直收到流结束
只要最新的collectLatest { }:新值到了就取消上一次的处理
在作用域里收flow.onEach { … }.launchIn(scope)——不阻塞当前代码
转成值toList() · first() · firstOrNull() · single() · count() · reduce · fold
冷的含义收集两次 = 执行两次;要共享一次执行的结果用 shareIn / stateIn(见下)

操作符按用途

要做的事操作符要点
变换 / 筛选map · filter · mapNotNull · transform和集合上那套同名
取几个take(n) · drop(n) · takeWhiletake 拿够就取消上游
去重distinctUntilChanged()只比相邻两个,不是全局去重
防抖 / 采样debounce(300) · sample(1000)搜索框输入的标准处理
切换到新流flatMapLatest · flatMapConcat · flatMapMerge「关键词变了就取消上一次请求」用 flatMapLatest
合并两条流combine(a, b) { x, y -> }(任一更新就出新值)· zip(一一配对)· merge表单校验常用 combine
累计scan(init) { acc, v -> }每步都发出中间结果
错误处理catch { emit(fallback) } · retry(3) · retryWhen { e, i -> }catch 只能捕获上游的异常——放在链的末尾才能覆盖前面全部
生命周期钩子onStart { } · onCompletion { cause -> } · onEmpty { }做 loading 状态很顺手
副作用onEach { }不改变值

上下文与背压

规则说明
上下文保持flow { } 里不能直接 withContext 换线程,否则运行时抛 IllegalStateException: Flow invariant is violated
flowOn(Dispatchers.IO)只影响它上游的操作——这是唯一正确的换线程方式
收集在哪在调用 collect 的那个协程上下文里
buffer(n)生产与消费并行,中间加缓冲区
conflate()只保留最新值,消费慢就丢中间的
collectLatest新值来了就取消上一次的处理体
怎么选能丢就 conflate(UI 状态),不能丢就 buffer(数据处理)

三种热流对照

StateFlowSharedFlowChannel
定位状态:总有一个当前值事件:广播给所有订阅者队列:每个元素只被一个接收者拿到
初始值必须有没有没有
新订阅者立刻收到当前值默认收不到历史(replay = n 可留)收不到
去重自动(值相等就不发)不去重不去重
典型用途UI 状态、配置、当前用户一次性事件:弹窗、导航、提示生产者-消费者、任务分发
创建MutableStateFlow(init)MutableSharedFlow(replay = 0)Channel<T>(capacity)
发_state.value = x · update { }emit(x) · tryEmit(x)send(x) · trySend(x)
对外暴露内部 MutableXxx、对外 asStateFlow() / asSharedFlow() / receiveAsFlow()
冷转热flow.stateIn(scope, SharingStarted.WhileSubscribed(5000), init) · shareIn(...)——多个订阅者共用一次上游执行

选择只要一句:「有没有『当前值』这个概念」——有就 StateFlow,没有就 SharedFlow;要点对点分发才用 Channel。

// ① 搜索框:防抖 + 切换取消,Flow 最经典的一段
val results = queryFlow
    .debounce(300)                              // 停手 300ms 再说
    .filter { it.length >= 2 }
    .distinctUntilChanged()
    .flatMapLatest { q -> flow { emit(api.search(q)) } }  // 新词来了就取消上一次
    .catch { emit(emptyList()) }                 // 放末尾才覆盖上游全部
    .flowOn(Dispatchers.IO)                     // 只影响上游

// ② 状态用 StateFlow:内部可变,对外只读
class UserViewModel {
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun refresh(scope: CoroutineScope) = scope.launch {
        _state.value = UiState.Loading
        _state.value = runCatching { repo.load() }
            .fold({ UiState.Ok(it) }, { UiState.Err(it.message ?: "") })
    }
}

// ③ 换线程只有一种正确写法
flow {
    // withContext(Dispatchers.IO) { … }   → IllegalStateException
    emit(File("a.txt").readText())
}.flowOn(Dispatchers.IO)                        // ← 这样

// ④ 包裹回调式 API:awaitClose 必须写
fun locationFlow(): Flow<Loc> = callbackFlow {
    val l = LocListener { trySend(it) }
    manager.register(l)
    awaitClose { manager.unregister(l) }          // 取消时注销,否则泄漏
}

// ⑤ 冷转热:多个订阅者共用一次上游
val shared = expensiveFlow
    .stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)
在 flow { } 里 withContext 换线程会运行时崩溃(Flow invariant is violated)。Flow 要求发射与收集在同一个上下文里,换线程唯一的办法是 flowOn,而且它只作用于上游。
callbackFlow 里忘了 awaitClose:编译能过,运行时抛异常或者监听器永远不注销——包裹任何回调式 API 时,注销逻辑必须写在 awaitClose 里。
debounce 不能替代取消:它只是延后发射,上一次已经发出去的请求仍在跑。要真正取消得配 flatMapLatest 或 collectLatest。
两条固定搭配:UI 状态用 StateFlow(内部 MutableStateFlow、对外 asStateFlow()),一次性事件用 SharedFlow——用 StateFlow 发事件会在旋转屏幕/重新订阅时把旧事件再放一遍。
catch 与 flowOn 都有方向性,写在链的末尾覆盖面最大。

把 Flow 理解成「可以挂起的 Sequence」,八成的困惑就没了:它是惰性的、按需产生的、一次一个值,只是每一步都允许挂起。

冷流:声明 ≠ 执行

flow { } 只是保存了一段代码,什么都不会跑;每调用一次终端操作(collect / toList / first),这段代码就从头完整执行一遍。一个 body 里带 println 的 flow,创建时没有任何输出,连续 collect 两次,body 的打印出现了两次、每次都从第一个元素开始。

这意味着 Flow 天然是「可重放的配方」而不是「正在流动的数据」。同一个 Flow 对象给十个人 collect,就是十次独立的执行、互不干扰——每个订阅者都拿到完整的一份。什么时候需要相反的语义(一份数据广播给多个订阅者),见「热流」卡。

和 Sequence 的区别:能不能挂起

结构几乎一样(sequence { yield(x) } 对 flow { emit(x) }),区别是致命的一条:Sequence 的 lambda 里不能调挂起函数。它用的是「受限挂起」,只允许调 yield 一族。在 sequence { } 里写 delay(10),编译直接失败:

  • error: restricted suspending functions can invoke member or extension suspending functions only on their restricted coroutine scope.

所以:数据在内存里、同步可得 → Sequence(见 07 章);每个元素需要等 IO / 等时间 / 等别的协程 → Flow。

和 List 的区别:要不要一次算完

suspend fun loadAll(): List<Item> 必须把全部结果攒齐才返回;fun loadAll(): Flow<Item> 可以边产边消费。前者适合结果不多、要整体处理;后者适合结果很多(分页拉取)、无限(传感器读数、日志尾随)、或者你想尽早拿到第一个元素。Flow 的返回类型上不带 suspend——因为构造它不需要等待,等待发生在 collect 时,而 collect 才是挂起函数。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

// ① 冷流:创建时什么都不跑,每次 collect 从头执行一遍
val f: Flow<Int> = flow {
    println("flow body starts")
    emit(1); emit(2)
}
println("created")
f.collect { println("first: $it") }
f.collect { println("second: $it") }
// 输出:
//   created            ← body 还没跑
//   flow body starts   ← 第一次 collect 才跑
//   first: 1 / first: 2
//   flow body starts   ← 又跑了一遍
//   second: 1 / second: 2

// ② Flow 的 lambda 可以挂起;Sequence 的不行
val g = flow { repeat(2) { delay(10); emit(it) } }   // ✓

fun seq() = sequence { repeat(2) { delay(10); yield(it) } }   // ✗
// error: restricted suspending functions can invoke member or extension
//        suspending functions only on their restricted coroutine scope.

// ③ 返回 Flow 的函数不需要 suspend;挂起发生在 collect
fun pagedItems(): Flow<Item> = flow {
    var page = 0
    while (true) {
        val batch = api.load(page++)      // 挂起:等 IO
        if (batch.isEmpty()) break
        batch.forEach { emit(it) }        // 边拉边给,不用等全部
    }
}
pagedItems().take(10).collect(::render)   // 拿够 10 个就停止拉取

// ④ 把回调式 API 包成 Flow
fun ticks(): Flow<Long> = callbackFlow {
    val l = Listener { trySend(it) }
    source.register(l)
    awaitClose { source.unregister(l) }   // ← 漏写就是泄漏
}

「冷流每次 collect 都重跑一遍」这条经常反过来咬人:如果 flow { } 里是一次网络请求或数据库查询,那么每多一个 collector 就多打一次后端。在 UI 场景(一个数据源、多处订阅)这几乎一定是 bug,解法是 shareIn / stateIn 转成热流(见「热流」卡)。

另一处容易踩的:flow { } 里不能从别的协程 emit。emit 必须在 flow builder 自己的协程里调用,在里面 launch { emit(x) } 会在运行时抛 IllegalStateException: Flow invariant is violated。需要从别处推送数据请用 channelFlow { } / callbackFlow { },它们的 send 是线程安全的。

flowOf(1, 2, 3) 和 listOf(1,2,3).asFlow() 是造测试用 Flow 的最短路径。反过来,把回调式 API(监听器、老式异步客户端)包成 Flow 用 callbackFlow { },它提供 trySend 和 awaitClose { }——后者是注销监听器的地方,忘了写就是资源泄漏。

Flow 的操作符分两类:中间操作返回新 Flow、不触发任何执行;终端操作是挂起函数、才真正开跑。这条分界和集合上的 Sequence 完全一致。

常用操作符

类别操作符说明
转换map · filter · transformtransform 最通用:一个输入可 emit 零到多个输出
限流/截断take · drop · distinctUntilChanged · debouncetake(n) 拿够就取消上游
副作用onEach · onStart · onCompletion都是中间操作,不会触发流
组合zip · combine · flatMapLatestzip 严格配对;combine 任一有新值就出
异常catch · retrycatch 只捕上游异常
终端collect · toList · first · fold · launchIn挂起函数,到这里才开始执行

flowOf(1..5).filter{奇数}.map{×10}.toList() 得到 [10, 30, 50];transform 里 emit 两次可以把 [1,2] 变成 [<1, 1>, <2, 2>];distinctUntilChanged() 把 [1,1,2,2,2,3,1] 变成 [1,2,3,1]——它只去相邻重复,末尾那个 1 保留了。

上下文保持:flow { } 里不许换线程

Flow 有一条硬性规则叫上下文保持(context preservation):emit 必须发生在 collect 所在的那个协程上下文里。所以在 flow { } 内部写 withContext(Dispatchers.Default) { emit(x) },运行时会抛 IllegalStateException,报错原文:

  • Flow invariant is violated:
  • Flow was collected in [BlockingCoroutine{Active}@…, BlockingEventLoop@…],
  • but emission happened in [DispatchedCoroutine{Active}@…, Dispatchers.Default].
  • Please refer to 'flow' documentation or use 'flowOn' instead

这条规则存在的意义是:collector 永远知道自己的回调跑在哪个线程上。否则一个 collect { updateUI(it) } 就可能被上游偷偷丢到别的线程执行,UI 框架当场炸。

flowOn 才是换线程的正确姿势

flowOn(dispatcher) 只影响它上游的操作符,下游(包括 collect)不受影响。一条 flow { 打印线程 }.flowOn(Dispatchers.Default).collect { 打印线程 }:上游打出 DefaultDispatcher-worker-1,collector 打出 main——一条链上两个线程,各管各的。位置很重要:flowOn 写在链尾就影响整条链,写在中间就只影响它前面那一段。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

// ① 中间操作 vs 终端操作
val f = flowOf(1, 2, 3, 4, 5)

f.filter { it % 2 == 1 }.map { it * 10 }.toList()   // [10, 30, 50]
f.take(2).transform<Int, String> { emit("<$it"); emit("$it>") }.toList()
// [<1, 1>, <2, 2>]  —— transform 可以一进多出
flowOf(1,1,2,2,2,3,1).distinctUntilChanged().toList()
// [1, 2, 3, 1]  —— 只去「相邻」重复

// ✗ 忘了终端操作:一行代码,零个副作用,不报错
f.onEach { save(it) }
// ✓
f.onEach { save(it) }.launchIn(scope)      // 返回 Job,可取消

// ② ✗ flow{} 里 withContext 换线程 → 运行时抛
val bad = flow { withContext(Dispatchers.Default) { emit(1) } }
bad.collect { }
// java.lang.IllegalStateException: Flow invariant is violated:
//   Flow was collected in [BlockingCoroutine{Active}@..., BlockingEventLoop@...],
//   but emission happened in [DispatchedCoroutine{Active}@..., Dispatchers.Default].
//   Please refer to 'flow' documentation or use 'flowOn' instead

// ✓ flowOn:只影响上游,下游不动
flow {
    println("upstream: " + Thread.currentThread().name)  // DefaultDispatcher-worker-1
    emit(heavyCompute())
}.flowOn(Dispatchers.Default)
 .collect {
    println("collector: " + Thread.currentThread().name) // main
 }

// ③ catch 只捕上游
pagedItems()
    .map { parse(it) }
    .retry(2) { it is java.io.IOException }
    .catch { e -> emit(Item.EMPTY) }   // 捕获上游异常,还能补发兜底值
    .collect { render(it) }             // ← 这里抛的异常 catch 接不住

只写中间操作、忘了终端操作,是 Flow 最安静的 bug:flow.onEach { save(it) } 单独一行,什么都不会发生,不报错、不警告(Kotlin 只会给出一个「返回值未使用」提示,很容易被忽略)。必须以 collect() / launchIn(scope) 结尾。

catch 的作用域也常被误解:它捕不到下游的异常。flow.catch{}.collect { 这里抛异常 } 里 collector 抛的异常不会被 catch 接住,会直接扔给调用方。要保护 collector 自身,用普通的 try/catch 包住整个 collect 调用。

onEach { }.launchIn(scope) 是「在某个作用域里后台消费一条流」的惯用写法,等价于 scope.launch { flow.onEach{}.collect() } 但更短,而且返回 Job 方便取消。catch 要放在链的末端附近——它只能捕获自己上游的异常,放在最前面等于什么都没保护。

当生产者比消费者快,怎么办?Flow 的默认答案是反压到生产者——emit 会挂起、等消费者处理完。想改变这个默认,只有三个开关,语义各不相同。

默认:串行,一个都不丢

不加任何操作符时,emit 和 collect 在同一个协程里交替执行——生产一个、消费一个、再生产下一个。所以总耗时是两者之和,但零丢失。这是最安全的默认,绝大多数场景不需要改。

三种策略的对比

同一条流(依次产出 1..5,生产快、消费慢),四种写法收到的元素序列:

写法收到机制什么时候用
(默认)[1, 2, 3, 4, 5]串行,生产者被反压等待每个元素都必须处理
.buffer()[1, 2, 3, 4, 5]生产与消费并行,中间加队列不能丢数据,但想让两端同时跑
.conflate()[1, 3, 5]消费者忙时新值覆盖旧值只关心最新状态(进度条、行情)
.collectLatest { }[5]来新值就取消正在处理的旧值处理本身可丢弃(搜索建议、预览渲染)

三者的关键差别一句话说清:buffer 一个不丢,conflate 丢掉没来得及消费的值,collectLatest 连正在消费的都打断。conflate 收到的是 [1,3,5] 而不是 [5]——因为它只覆盖缓冲区里还没被取走的值,已经开始处理的那个会处理完。collectLatest 只剩 [5],因为前四个的处理逻辑都被中途取消了。

buffer 的容量与溢出策略

buffer(capacity, onBufferOverflow) 可细调:BufferOverflow.SUSPEND(默认,满了就反压)、DROP_OLDEST(丢最旧的,等价于 conflate() 在容量 1 时的行为)、DROP_LATEST(丢新来的)。flowOn 内部也自带一个 buffer,所以 .flowOn(IO) 已经让上下游并行了,后面再加 .buffer() 往往是多余的。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.BufferOverflow

// 生产快、消费慢的同一条流
fun src(): Flow<Int> = flow {
    for (i in 1..5) { delay(50); emit(i) }
}

// ① 默认:串行,一个不丢
src().collect            { delay(150); use(it) }  // [1, 2, 3, 4, 5]

// ② buffer:并行跑,仍然一个不丢
src().buffer().collect   { delay(150); use(it) }  // [1, 2, 3, 4, 5]

// ③ conflate:缓冲区里的旧值被新值覆盖
src().conflate().collect { delay(150); use(it) }  // [1, 3, 5]

// ④ collectLatest:新值直接取消正在处理的旧值
src().collectLatest      { delay(150); use(it) }  // [5]

// buffer 的容量与溢出策略
src().buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
// ✗ 无界缓冲 = 关掉背压 = 慢消费者下必 OOM
src().buffer(Channel.UNLIMITED)

// flatMapLatest:搜索框的标准写法——输入一变就取消上一次请求
queryFlow
    .debounce(300)
    .flatMapLatest { q -> searchApi(q) }   // 旧请求自动取消
    .collect { showResults(it) }

collectLatest 的 lambda 会被随时取消,所以里面绝对不能做「做了一半就出问题」的事——写文件、更新数据库、扣款。取消发生在 lambda 内部的任意挂起点,你会得到一个执行了一半的副作用。它只适合纯展示 / 可重算的工作。

buffer() 用无界容量(Channel.UNLIMITED)时会把背压彻底关掉:生产者永远不等,队列无限涨,快的生产者 + 慢的消费者 = OOM。背压不是麻烦,是保护机制,关掉它之前先想清楚谁来限制内存。

选择顺序:先默认什么都不加;确认瓶颈在「上下游串行等待」再加 buffer();确认「旧值没有价值」再换 conflate();确认「处理过程本身可以被打断且无副作用」才用 collectLatest。collectLatest 还有对应的中间操作 mapLatest / flatMapLatest,后者是搜索框「输入变化就取消上一次请求」的标准写法。

冷流是「配方」,每个订阅者各跑一份;热流是「广播」,数据独立于订阅者存在、所有人共享同一份。UI 状态、事件总线、传感器读数天然是热的。

StateFlow:永远有一个当前值,且会去重

MutableStateFlow(初始值) 必须有初始值,随时可以读 .value,新订阅者立刻收到当前值(先 value = "latest" 再订阅,第一个收到的就是 "latest")。

它有两个内建行为,都会「少发」值,必须知道:

  • 去重(相当于自带 distinctUntilChanged,用 equals 比较)——慢速依次设置 1,1,1,2,2,3,订阅者收到的是 [0, 1, 2, 3],重复值一次都没重复发;
  • 合并(conflated)——消费者慢时,中间值会被跳过。快速依次设置 1,2,3,4,5 而 collector 处理很慢,收到的是 [0, 5],中间的 2、3、4 整个消失。

所以 StateFlow 表达的是「最新状态是什么」,绝不能拿它传「发生了什么事件」——事件会丢。

SharedFlow:可配置的广播

MutableSharedFlow(replay, extraBufferCapacity, onBufferOverflow) 没有初始值、不去重、默认 replay = 0。三条:

  • 没有订阅者时 emit 的值直接丢弃(先 emit 99 再订阅,订阅者只收到之后的 1, 2);
  • replay = 2 时,先 emit 1,2,3 再订阅,新订阅者立刻收到 [2, 3](最后两个);
  • 连续 emit 三个相同的 7,replayCache 是 [7, 7, 7]——确实不去重。

StateFlow 本质就是 SharedFlow(replay=1) + 去重 + 强制初始值的特例。

把冷流变热:stateIn / shareIn

这是解决「多个订阅者导致上游重复执行」的标准手法。一个会计数自增的冷流,直接 collect 两次上游跑了两遍;经 stateIn(scope, SharingStarted.Eagerly, 初值) 之后,无论读多少次 .value,上游只跑了 1 次。

SharingStarted 决定上游何时启动与停止:Eagerly(立刻启动、永不停)、Lazily(首个订阅者到了才启动、之后不停)、WhileSubscribed(stopTimeoutMillis)(有订阅者才跑、最后一个走后延时停)。UI 场景几乎总是 WhileSubscribed(5000)——退到后台一会儿就停掉上游,返回时不用重新加载。

Channel 和 Flow 怎么选

Channel 是协程间的队列,不是流:它是热的、有背压、而且元素被消费掉就没了。两个协程同时 for (v in channel) 消费同一个 Channel,发送 1,2,3,4,两个接收者拿到的是互不重叠的两份(每个元素只进一个接收者),而不是各拿全部——这和 SharedFlow 的广播语义正好相反。

冷/热多订阅者初始值典型用途
Flow冷各跑一份无一次性数据流、分页拉取
StateFlow热广播,全都收到必须有UI 状态、当前配置
SharedFlow热广播,全都收到无(可 replay)一次性事件、消息总线
Channel热竞争,各拿一部分无任务分发、生产者-消费者

判据:「每个订阅者都要看到全部」→ SharedFlow;「一个任务只能被处理一次」→ Channel。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.channels.Channel

// ① StateFlow:有当前值、会去重、会合并
val state = MutableStateFlow(0)
launch { state.collect { record(it) } }
delay(30)                                   // 让订阅先建立起来,否则收不到初始值 0
for (v in listOf(1, 1, 1, 2, 2, 3)) { state.value = v; delay(30) }
// 收到 [0, 1, 2, 3] —— 相同值被去重

// 消费者慢时,中间值被合并掉(这就是它不能传事件的原因)
launch { state.collect { delay(60); record(it) } }
delay(30)                                   // 同上:先让订阅建立
for (v in 1..5) state.value = v
// 收到 [0, 5] —— 2、3、4 整个消失

// ② SharedFlow:不去重,replay 可配
val ev = MutableSharedFlow<Int>()              // replay = 0
ev.emit(99)                                    // 没订阅者 → 直接丢弃
launch { ev.collect { record(it) } }
ev.emit(1); ev.emit(2)                     // 收到 [1, 2]

val rep = MutableSharedFlow<Int>(replay = 2)
rep.emit(1); rep.emit(2); rep.emit(3)
launch { rep.collect { record(it) } }        // 立刻收到 [2, 3]

// ③ 对外只暴露只读类型(平台无关的状态持有者)
class CounterStore(scope: CoroutineScope) {
    private val _state = MutableStateFlow(UiState(count = 0))
    val state: StateFlow<UiState> = _state.asStateFlow()

    private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
    val events: SharedFlow<String> = _events.asSharedFlow()

    fun inc() { _state.update { it.copy(count = it.count + 1) } }
}
// Android 上通常把 scope 换成 ViewModel 的 viewModelScope,其余完全一样

// ④ stateIn:把冷流变热,上游只跑一次
val hot: StateFlow<Int> = coldSource()
    .stateIn(scope, SharingStarted.WhileSubscribed(5000), initialValue = 0)
// 直接 collect 两次 → 上游跑 2 次;stateIn 后读多少次 .value → 上游只跑 1 次

// ⑤ Channel:元素被「取走」,多个接收者是竞争关系
val ch = Channel<Int>()
launch { for (v in ch) worker1(v) }
launch { for (v in ch) worker2(v) }
for (i in 1..4) ch.send(i)
ch.close()
// 两个 worker 拿到的是互不重叠的两部分,合起来才是 1,2,3,4
// —— 想让每个订阅者都收到全部,用 SharedFlow,不是 Channel

// select:同时等多个,取最先完成的
val first = select<String> {
    slowCh.onReceive { "from slow: $it" }
    fastCh.onReceive { "from fast: $it" }
}

用 StateFlow 传事件是最常见的架构错误。它去重 + 合并的双重行为意味着:连续发两次相同的事件(比如两次「显示同一条错误提示」)订阅者只会收到一次;而在订阅者忙时快速发的多个不同事件会被合并到只剩最后一个。上面的数据 [0, 5] 就是证据。事件请用 SharedFlow(并按需设 extraBufferCapacity)或 Channel。

MutableStateFlow 的去重用的是 equals。如果你的状态是 data class 之外的普通类,equals 是引用比较,等价的新对象会被当成不同值——反而会重复发。而如果你持有一个 data class 却原地修改了它内部的可变字段再赋回 .value,equals 判定相等,更新会被整个丢弃、UI 一动不动。状态类一律用不可变的 data class + copy()。

对外暴露热流时永远暴露只读类型:private val _state = MutableStateFlow(...) + val state: StateFlow<T> = _state.asStateFlow()。这样外部只能读不能改,是状态管理的基本纪律。select { } 表达式可以同时等多个 Channel.onReceive / Deferred.onAwait,取最先完成的那个——慢/快两个 Channel,拿到的确实是快的那个。

Java 互操作

本章覆盖:Kotlin 调 Java(平台类型、集合映射、SAM 转换、反引号转义)、Java 调 Kotlin(顶层函数、默认参数、伴生对象、suspend 的真实签名)、@Jvm* 注解全表,以及什么时候值得混编。

Kotlin 能在 JVM 上站住脚,靠的是它与 Java 之间那条几乎无损的双向通道。但「几乎」的那部分正是要查的:可空性、默认参数、静态成员、受检异常。

Kotlin 调 Java

Java 那边Kotlin 这边看到什么要注意
任何返回值平台类型 String!可空性未知,编译器不拦——边界处显式标成 String?(05 章)
getName() / setName()属性 obj.name符合 JavaBean 命名才会变属性
isEnabled()obj.isEnabledis 开头的保留原名
单方法接口SAM 转换:可以直接传 lambdaexecutor.execute { … }
List<String>(Mutable)List<String!>!Java 的 List 在 Kotlin 侧既能当只读也能当可变——它真的能被改
数组Array<String!>! · IntArray基本类型数组是专门的类型
静态成员JavaClass.staticMethod()直接点
名字撞上 Kotlin 关键字obj.`in`用反引号包起来(in / object / is / when…)
受检异常不强制处理Kotlin 没有受检异常,别忘了该接的还是要接
@Nullable / @NotNullKotlin 会当真,变成 String? / String让 Java 侧加注解,是消灭平台类型的正道

Java 调 Kotlin

Kotlin 这边Java 那边看到什么
顶层函数 fun foo()(Util.kt)UtilKt.foo()——用 @file:JvmName("Utils") 换个名字
属性 var namegetName() / setName()
val 属性只有 getter
默认参数只看得到全参数那一个——加 @JvmOverloads 生成全套重载
companion object 里的成员Foo.Companion.bar()——加 @JvmStatic 才是 Foo.bar()
const val真正的静态常量字段
属性想暴露成字段@JvmField(去掉 getter/setter)
object SingletonSingleton.INSTANCE
data classgetX() / equals / hashCode / toString / component1() / copy(...)(copy 带默认参数,Java 侧不好用)
internalJava 侧其实能看到(名字被改写过),只是不该用
suspend fun多一个 Continuation 参数,Java 侧基本没法直接用——给 Java 的 API 别用挂起函数,改成返回 CompletableFuture
扩展函数静态方法,第一个参数是接收者
不想被 Java 看到@JvmSynthetic
签名撞车Platform declaration clash → @JvmName("other")

@Jvm* 注解全表

注解解决什么
@JvmStatic伴生对象成员在 Java 侧变成真正的静态方法
@JvmOverloads为默认参数生成全套重载
@JvmField属性直接暴露成字段
@JvmName("x")改生成的方法名/文件类名(加在 @file: 上)
@Throws(IOException::class)生成 throws 声明,Java 调用方才能 catch
@JvmSynthetic对 Java 隐藏
@JvmInlinevalue class(13 章)

什么时候值得混编

情况建议
老项目逐步迁移值得:从叶子模块、工具类、测试开始,一个文件一个文件换
要用某个只有 Java 的库直接用,成本几乎为零
对外发布的 SDK 要同时服务两边值得,但要认真加 @Jvm* 注解并写 Java 侧的用例
新项目别混:一个模块一种语言,边界清楚
协程 API 对 Java 暴露别直接暴露 suspend;包一层 CompletableFuture 或回调
看生成了什么IntelliJ:Tools → Kotlin → Show Kotlin Bytecode → Decompile——互操作的一切疑问都能在这里当场确认
// ① 边界上把平台类型收干净
val raw = legacy.getName()                 // String!(编译器不拦)
val name: String? = legacy.getName()      // ← 显式表态,之后全程有保护
println(name?.uppercase() ?: "(无)")

// ② 名字撞上关键字:反引号
javaObj.`in`()
javaObj.`object`

// ③ 给 Java 用的 Kotlin API:四个注解一起上
@file:JvmName("Texts")                    // Java 侧 Texts.slug(...),不是 TextsKt
package com.example

@JvmOverloads                              // Java 才看得到 slug(s)
fun slug(s: String, max: Int = 80): String = TODO()

class Config private constructor(val host: String) {
    companion object {
        @JvmStatic                       // Java 侧 Config.of("x"),不是 Config.Companion.of
        fun of(host: String) = Config(host)

        const val DEFAULT_PORT = 443       // 真正的静态常量
    }
}

@Throws(IOException::class)                 // Java 侧才能 catch
fun read(p: String): String = File(p).readText()

// ④ 别把 suspend 直接暴露给 Java
suspend fun load(): Data = TODO()          // Java 侧要自己造 Continuation,没法用
fun loadFuture(scope: CoroutineScope): CompletableFuture<Data> =
    scope.future { load() }                 // kotlinx-coroutines-jdk8
平台类型是 Kotlin 空安全唯一的缺口:val s: String = javaObj.getName() 编译完全通过,运行时 Java 返回 null 就直接 NPE,而且异常指向赋值那一行而不是真正的源头。跟 Java 打交道时,非空类型要自己负责验证。
Java 调用方看不到默认参数,也调不了 suspend 函数。库要服务 Java 用户,这两条必须在设计 API 时就考虑,事后补 @JvmOverloads 会改变二进制签名。
混编项目的第一条规矩:在边界上表态。每个从 Java 进来的值,在第一行就写清楚是 T 还是 T?;每个给 Java 用的 API,加上对应的 @Jvm* 注解并真的写一个 Java 调用的测试。
对互操作行为有疑问时反编译看一眼比查文档快:Show Kotlin Bytecode → Decompile。

Kotlin 调 Java「几乎无感」,不是因为它退让了,而是因为编译器在读 Java 字节码时做了一整套翻译:getter/setter 变属性、int... 变 vararg、java.util.List 变 List。唯一翻译不了的是可空性——Java 那边根本没这个信息,于是就有了平台类型。

平台类型 T!:编译器选择不表态

Java 方法返回的 String,Kotlin 既不当它非空、也不当它可空,而是标成 String!。这不是一种你能写出来的类型(你没法声明 val x: String!),只是编译器在提示里表达「我不知道,你自己看着办」。代价是空检查从编译期挪到了运行期,而且炸的位置取决于你怎么用它——两条路径:

写法炸在哪异常
val ok: String = J.maybeNull()
(立刻收敛成非空类型)
赋值那一行java.lang.NullPointerException: maybeNull(...) must not be null
val p = J.maybeNull()
(留在平台类型,之后再用)
真正用它的地方java.lang.NullPointerException: Cannot invoke "String.length()" because "p" is null

第一种是 Kotlin 编译器插的断言,报错里带着那个 Java 方法名,一眼知道谁给了 null;第二种就是一个普通的 JVM 空指针,可能发生在离边界很远的地方。这就是「边界上立刻收敛」这条惯用法的全部理由,第四卡细讲。

Java 侧标了注解,编译器就表态

Java 代码里的 @Nullable / @NotNull(org.jetbrains.annotations,以及 JSR-305、androidx、jakarta 等一批常见注解包)会被 Kotlin 直接采信:标了 @Nullable 的方法,返回类型在 Kotlin 侧就是货真价实的 String?,不写安全调用编译不过;标了 @NotNull 的就是 String。中 annotatedNullable() 必须用 String? 接,annotatedNotNull().length 可以直接点出来。所以给要被 Kotlin 调用的 Java 代码补注解,是投入产出比最高的互操作改造——一行注解换掉一整类运行期 NPE。

集合是「映射」,不是「包装」

Kotlin 没有自己的集合实现,List / Map 这些名字在 JVM 上映射到的就是 java.util 里那套。所以跨语言传集合不复制、不包装、零成本。Java 侧 new ArrayList<>() 返回给 Kotlin,l.javaClass.name 打出来就是 java.util.ArrayList。

Java 类型Kotlin 看到的
java.util.List<E>(Mutable)List<E>!——可读可写都行
java.util.Map<K,V>(Mutable)Map<K,V>!
ObjectAny!
int / int[]Int / IntArray
getX() / setX() 成对出现属性 x(只有 getter 就是只读属性)
static 成员用类名点出来,JavaSide.TAG
int... xsvararg xs: Int,传数组要 *arr 展开

(Mutable)List! 这个奇怪的写法意思是:可空性和可变性都不表态。把它声明成 List<String> 之后依然能 add——因为底下就是那个 ArrayList。「只读 ≠ 不可变」这件事在 07 章讲过,跨语言边界是它最容易咬人的地方。

SAM 转换只对 Java 接口自动生效

「单抽象方法接口可以传 lambda」是 Java 互操作的专属福利,Kotlin 自己的接口默认不享受。三种情况下来是这样:

被调方能传 lambda 吗
Java 的 interface Handler { String handle(String s); }能,JavaSide.run { s -> s + "!" } 直接过
Kotlin 的 fun interface KFunHandler { ... }能,fun 修饰符就是显式申请 SAM 转换
Kotlin 的普通 interface KHandler { ... }不能,只能写 object : KHandler { ... }

第三种的报错原文:error: argument type mismatch: actual type is '(??? (Unknown lambda parameter type)) -> ??? (Unknown lambda return type)', but 'KHandler' was expected.

为什么 Kotlin 要这么设计?因为它本来就有函数类型——想要一个「接收 String 返回 String 的东西」,直接写 (String) -> String 就够了,不需要先造一个接口。fun interface 是为了「这个接口要给 Java 用 / 要有名字和文档」时的补充。所以纯 Kotlin 的新 API 优先用函数类型,别下意识地照搬 Java 的 Listener 接口习惯。

// ── Java 侧(JavaSide.java)────────────────────────────
// public class JavaSide {
//     private String name = "bob";
//     public String getName()          { return name; }
//     public void   setName(String n)  { this.name = n; }
//     public static final String TAG = "javaside";
//     public static String maybeNull(boolean b) { return b ? "x" : null; }
//     @Nullable public static String annotatedNullable() { return null; }
//     public static List<String> makeList()  { ... }   // ["a","b"]
//     public static int sum(int... xs)       { ... }
//     public interface Handler { String handle(String s); }
//     public static String run(Handler h) { return h.handle("in"); }
// }

// ── Kotlin 侧 ─────────────────────────────────────────
val j = JavaSide()
j.name = "alice"                   // getName/setName 自动变成属性 name
println(JavaSide.TAG)                // static 成员:类名直接点出来

// 平台类型:两种用法,两种炸法
val ok: String = JavaSide.maybeNull(true)     // 立刻收敛成非空
// 传 false 时炸在这一行:
// java.lang.NullPointerException: maybeNull(...) must not be null

val plat = JavaSide.maybeNull(false)         // 类型是 String!,不收敛
plat.length                                    // 炸在这里,且只是个普通 NPE:
// java.lang.NullPointerException: Cannot invoke "String.length()" because "plat" is null

val n: String? = JavaSide.annotatedNullable()  // 有 @Nullable:编译器知道它可空

// 集合:映射而非包装
val l = JavaSide.makeList()         // 类型 (Mutable)List<String>!
println(l.javaClass.name)          // java.util.ArrayList —— 没复制也没包装
l.add("c")                        // 平台类型不表态可变性,能 add

JavaSide.sum(1, 2, 3)                // int... 变成 vararg
val arr = intArrayOf(4, 5)
JavaSide.sum(*arr)                   // 数组要用 * 展开

// SAM:Java 接口可以直接收 lambda
JavaSide.run { s -> s + "!" }        // "in!"

// Kotlin 自己的普通接口不行 ——
interface KHandler { fun handle(input: String): String }
runK { s -> s + "#" }
// error: argument type mismatch: actual type is '(???) -> ???', but 'KHandler' was expected.

fun interface KFunHandler { fun handle(input: String): String }
runKFun { s -> s + "?" }             // fun interface 就可以
把平台类型原样往下传,是互操作里最难查的一类 bug。里那个 Cannot invoke "String.length()" because "plat" is null,栈顶是你的 Kotlin 代码,而真正的肇事者是几层之外那个返回 null 的 Java 方法——异常里完全没有它。更糟的是平台类型会顺着数据结构扩散:把 String! 放进 data class 的非空字段里,构造那一刻不一定报错,等到几个模块之后再炸。规则很简单:从 Java 拿到的值,别让它活过当前这个函数。
平台类型是一种你写不出来的类型。IDE 的悬浮提示里能看到 String!,但你没法把它写进源码——这是故意的:语言不想给你一个「合法地不做空检查」的声明方式,只想让你在边界上做一次选择。所以看到 ! 就当成一句提问:「这里到底会不会是 null?」答案要么来自 Java 那边的文档/注解,要么来自你自己的断言。

反方向要麻烦得多:Kotlin 有一堆 Java 没有的概念——顶层函数、伴生对象、属性、默认参数。它们在字节码里都得找个 Java 表达得出来的形状落地,而默认落地方式往往对 Java 调用方很不友好。一组 @Jvm* 注解就是用来调整这个形状的。想知道你的 API 在 Java 眼里长什么样,别猜,javap -p 一下就全看见了。

顶层函数 → 一个以文件名命名的类

KotlinApi.kt 里的顶层函数会被塞进一个自动合成的类 KotlinApiKt,全部是 public static final。Java 侧就得写 KotlinApiKt.greet("java")——这个多出来的 Kt 后缀相当难看。文件头加一行 @file:JvmName("KotlinApi") 就能改掉它。如果你在写要给 Java 用的库,这一行几乎是必备的。

伴生对象:默认要绕一层 Companion

companion object 在字节码里是一个真实的内部类实例,挂在外层类的 public static final ... Companion 字段上。所以不加注解时 Java 只能写 Config.Companion.plain()。加了 @JvmStatic,编译器会额外在外层类上生成一份静态方法——注意是「额外」,javap 里 viaJvmStatic 在 Config 和 Config$Companion 上各有一份,Kotlin 侧调的还是伴生对象那份。const val 则本来就是 public static final 字段,Java 写 Config.VERSION 就行,不需要任何注解。

默认参数:Java 完全看不见,除非 @JvmOverloads

这是最容易被漏掉的一条。Kotlin 的默认参数不是靠重载实现的,而是靠一个合成的 xxx$default 方法 + 一个位掩码参数——Java 理论上叫得动它,但要自己算 bitmask 还要传 null 占位,实际没人这么干。加上 @JvmOverloads,编译器才会从后往前逐个省略参数,把真正的重载生成出来。javap 对比:

不加 @JvmOverloads加 @JvmOverloads
三参构造器
(title, message = "", cancelable = true)
(String, String, boolean)
(String, String, boolean, int, DefaultConstructorMarker)
共 2 个
以上两个,再加
(String, String)
(String)
共 4 个

注意生成规则是严格从尾部裁:不会有 (String, boolean) 这种「跳着省略」的重载。所以设计给 Java 用的签名时,参数顺序要按「越可选越靠后」排。

属性、@JvmField 与 data class 的 copy

  • 属性默认是 private 字段 + getX() / setX()(val 只有 getter)。Java 侧写 c.getHost(),中规中矩。
  • @JvmField 去掉访问器,直接暴露成 public final 字段:javap 里 public final java.lang.String region;,Java 写 c.region。适合纯数据载体,但从此就没法再改成计算属性了——这是拿 API 演进能力换调用便利。
  • data class 的 copy 在 Java 侧很难用:它的参数也是带默认值的,javap 里能看到 copy(long, String, String) 和 copy$default(...)。Java 只能调全参版,把不想改的字段一个个原样传回去——u.copy(2L, u.getName(), u.getEmail())。而 @JvmOverloads 对 copy 无效(它是编译器生成的)。要给 Java 用的可变体,老实提供 Builder 或者具名工厂方法。
// ── Kotlin 侧 ─────────────────────────────────────────
@file:JvmName("KotlinApi")      // 否则这个类叫 KotlinApiKt
package demo

fun greet(who: String): String = "hi " + who       // 顶层函数

class Config(val host: String, var port: Int) {
    @JvmField val region: String = "cn"            // 直接暴露成字段
    companion object {
        const val VERSION = "1.0"                   // 天然就是 static final
        fun plain(): Config = Config("p", 1)
        @JvmStatic fun viaJvmStatic(): Config = Config("s", 2)
    }
}

class Dialog(val title: String, val message: String = "",
             val cancelable: Boolean = true)
class DialogO @JvmOverloads constructor(
             val title: String, val message: String = "",
             val cancelable: Boolean = true)

// ── javap -p demo/Config.class(真实输出)──────────────
// public final class demo.Config {
//   public static final demo.Config$Companion Companion;
//   private final java.lang.String host;
//   private int port;
//   public final java.lang.String region;             ← @JvmField:public 字段
//   public static final java.lang.String VERSION;     ← const val
//   public final java.lang.String getHost();
//   public final int getPort();
//   public final void setPort(int);
//   public static final demo.Config viaJvmStatic();   ← @JvmStatic 额外生成的一份
// }

// ── javap 对比默认参数 ────────────────────────────────
// Dialog(不加):  (String, String, boolean)
//                   (String, String, boolean, int, DefaultConstructorMarker)
// DialogO(加了): 以上两个,外加
//                   (String, String)
//                   (String)

// ── Java 侧 ───────────────────────────────────────────
// KotlinApi.greet("java");        // 顶层函数 → 静态方法
// c.getHost();  c.setPort(9090);  // 属性 → getter/setter
// c.region;                       // @JvmField → 直接字段
// Config.VERSION;                 // const val
// Config.Companion.plain();       // 没加 @JvmStatic,要绕 Companion
// Config.viaJvmStatic();          // 加了 @JvmStatic
// new DialogO("only-title");      // 加了 @JvmOverloads 才有这个构造器
// u.copy(2L, u.getName(), u.getEmail());   // copy 只能全参调
Kotlin 侧的泛型擦除会制造 Java 看不见的签名冲突。fun sum(xs: List<Int>) 和 fun sum(xs: List<String>) 在 Kotlin 看来是两个不同的重载,但擦除后 JVM 签名一模一样。报错:
error: platform declaration clash: The following declarations have the same JVM signature (sum(Ljava/util/List;)I):
    fun sum(xs: List<Int>): Int
    fun sum(xs: List<String>): Int
解法就是给其中一个加 @JvmName("sumStrings")——这是 @JvmName 除了改文件类名之外最常见的用途。注意加了 @JvmName 之后 Java 侧看到的是新名字,Kotlin 侧还是原名,两边不一致,记得在文档里写清楚。
把 javap -p 当成互操作的验收工具。写完一个准备给 Java 用的 Kotlin 模块,编译出来 javap -p 扫一眼,看到的就是 Java 调用方看到的全部——哪个方法名带了后缀、哪个构造器少了重载、哪个字段被 getter 包了起来,全在上面。这比在 Java 侧写测试代码试探快得多,也比查文档可靠。

Java 有受检异常(checked exception):方法签名里写了 throws IOException,调用方就必须catch 或继续声明,否则编译不过。Kotlin 把这套机制整个删掉了——它的所有异常都是不受检的。这个决定在跨语言边界上会直接表现为一个编译错误,而且报错信息乍看毫无道理。

Kotlin 为什么不要受检异常

不是偷懒,是二十年经验的结论:受检异常在大型代码库里的实际形态,往往是满屏的 catch (Exception e) { } 空吞和层层向上的 throws Exception 声明污染——它强制你「处理」,但没法强制你「处理得对」。更硬的技术原因是它和高阶函数合不来:list.map { it.read() } 里那个 lambda 抛出的受检异常,得由 map 的签名声明出来,而 map 是通用的,它没法知道。Java 8 引入 lambda 之后也撞上了同一堵墙(这正是 Stream API 里处理受检异常特别别扭的原因)。

后果:Java 侧 catch 不了你声明的异常

Kotlin 函数就算实实在在抛 IOException,字节码签名里也没有 throws 子句。javac 于是认定「这个 try 块不可能抛出 IOException」,直接报错。

javap 里的签名Java 侧 catch (IOException e)
不加 @ThrowsString readNoThrows(String);编译报错
error: exception IOException is never thrown in body of corresponding try statement
加 @Throws(IOException::class)String readWithThrows(String) throws java.io.IOException;正常编译、正常捕获

关键在于理解这只是编译期的信息缺失,不是运行期行为差异:把 catch 换成 catch (Exception e),不加 @Throws 的那个函数照样抓得到 java.io.IOException : boom: x。异常一直都在,只是编译器不知道它会来。

@Throws 该加在哪

  • 只对会被 Java 调用的公开 API 有意义。纯 Kotlin 项目里加了也不会有任何效果——Kotlin 侧照样不强制 catch。
  • 覆写 Java 接口方法时要注意:Java 接口里声明了 throws 的方法,你的 Kotlin 实现如果要抛,也得自己加 @Throws,否则子类签名比父类窄,Java 调用方拿到接口引用时的 catch 又落空。
  • 它不改变 Kotlin 侧的任何行为——纯粹是往字节码里补一条 throws 子句,给 javac 看的。
import java.io.IOException

fun readNoThrows(path: String): String = throw IOException("boom: " + path)

@Throws(IOException::class)
fun readWithThrows(path: String): String = throw IOException("boom: " + path)

// ── javap -p demo/ApiKt.class(真实输出)──────────────
// public static final java.lang.String readNoThrows(java.lang.String);
// public static final java.lang.String readWithThrows(java.lang.String)
//                                              throws java.io.IOException;
//                                              ↑ 只有 @Throws 的那个带 throws 子句

// ── Java 侧(真实编译/运行结果)───────────────────────
// try { ApiKt.readNoThrows("x"); }
// catch (IOException e) { ... }
// error: exception IOException is never thrown in body of
//        corresponding try statement

// try { ApiKt.readNoThrows("x"); }
// catch (Exception e) { ... }        // 这样能编过,而且运行时确实抓到:
// C1 java.io.IOException : boom: x   // ← 异常一直都在,只是编译器不知道

// try { ApiKt.readWithThrows("y"); }
// catch (IOException e) { ... }      // 有 @Throws:可以精确 catch
// C2 java.io.IOException : boom: y
那句报错的字面意思会把人带沟里。exception IOException is never thrown in body of corresponding try statement 读起来像是「你的代码不会抛这个异常」,于是很多人第一反应是去检查 Kotlin 那边的逻辑,甚至怀疑异常被吞了。实际上它抛得好好的——javac 只是在照着方法签名做静态判断,而签名里没有 throws。看到这句报错,先去看 Kotlin 侧有没有 @Throws,别去看业务逻辑。
别把 @Throws 当成「文档注解」到处撒。它有实打实的约束力:一旦某个公开方法标了 @Throws(IOException::class),Java 调用方就会写上 catch;将来你想把这个异常换掉,就是破坏性 API 变更。所以只在「这个异常确实是契约的一部分、调用方本来就该处理」时才标——和你在 Java 里决定要不要用受检异常的判据是一样的。

平台类型的机制第一卡讲完了,这里讲工程纪律:从 Java 拿到的值,在哪一行、用什么方式变成一个 Kotlin 类型系统认识的类型。原则只有一句——让它在边界上就炸,别让它活着往下走。因为 null 引发的故障,唯一昂贵的部分是「从崩溃点回溯到源头」。

四种收敛姿势,按信息量排序

写法失败时你拿到什么用在哪
val x: String = j.getName()NullPointerException: getName() must not be null——带方法名默认选择。零成本、零噪音,报错已经指名道姓
requireNotNull(v) { "配置 db.url 缺失" }IllegalArgumentException: 配置 db.url 缺失需要业务语义的地方——配置加载、外部输入校验
checkNotNull(v) { "..." }IllegalStateException: ...「不该是 null,因为这个对象的状态本该保证它不是」
v!!NullPointerException,没有任何消息几乎总有更好的选择
v ?: default不失败确实有合理兜底值时——这才是最好的结局

requireNotNull / checkNotNull 相对 !! 的额外好处是它们有返回值:val v = requireNotNull(s) 之后 v 的类型已经是非空的 String,后面不需要再做任何智能转换的铺垫。这两个函数的完整语义见 13 章。

!! 什么时候还算合理

只有一种:你刚刚才亲手证明过它非空,而编译器由于智能转换的限制看不出来。典型是可变属性(var 可能被别的线程改)、跨 lambda 边界的局部变量。除此之外,写 !! 通常意味着你把一个「本该在类型里表达的事实」降级成了运行期赌博。更严重的是 a!!.b!!.c!! 这种连环写法——抛出来的 NPE 只有一个行号,三个 !! 到底是哪个炸的,得看字节码或者猜。真要这么写,拆成多行。

lateinit:给依赖注入准备的后门

Spring、Dagger、Koin 这类框架都是「先构造对象、再往里塞依赖」,而 Kotlin 的非空属性要求在构造函数结束前就有值——两边直接冲突。lateinit var 就是为此开的口子:跳过初始化检查,未赋值就访问时抛一个专门的异常,而不是含糊的 NPE。

kotlin.UninitializedPropertyAccessException: lateinit property repo has not been initialized
  • 只能用于 var,且类型必须非空、不能是原始类型(Int/Long 这些不行,因为它们没有「未初始化」这个状态可用)。
  • ::repo.isInitialized 可以查是否已赋值,但只能在声明它的类内部写。在外部调用报错:error: backing field of 'var repo: String' is not accessible at this point.
  • 什么时候该用 by lazy 而不是 lateinit?值能自己算出来就用 lazy(还能保持 val),要等外部塞进来才用 lateinit。DI 属于后者。
// ① 默认做法:立刻收敛。炸在这一行,报错自带 Java 方法名
val name: String = javaObj.getName()

// ② 需要业务语义时:requireNotNull
val url = requireNotNull(loader.load("db.url")) { "配置 db.url 缺失" }
// java.lang.IllegalArgumentException: 配置 db.url 缺失
// url 的类型已经是 String,后面直接用

// ③ !! —— 只留一个没消息的 NPE
val bad = loader.load("db.url")!!
// java.lang.NullPointerException      ← 什么都不告诉你

// ④ 有合理兜底就别抛
val port: Int = props.getPort() ?: 8080

// ⑤ lateinit:框架先造对象、再注入依赖
class Service {
    @Inject lateinit var repo: Repository     // @Inject 来自 jakarta.inject

    fun ready() = ::repo.isInitialized          // 只能在类内部写
    fun use() = repo.find(1)
}
// 没注入就用:
// kotlin.UninitializedPropertyAccessException:
//     lateinit property repo has not been initialized

// 在类外部查 isInitialized(报错):
// error: backing field of 'var repo: String' is not accessible at this point.

// ⑥ 值能自己算 → 用 lazy,还能保持 val
class Report {
    val heavy: Index by lazy { buildIndex() }
}
lateinit 会悄悄传染。它一旦出现在某个属性上,这个类就多了一个「构造完成但还不能用」的中间状态,而这个状态在类型上是看不见的——任何拿到实例的人都可能撞上 UninitializedPropertyAccessException。它在 DI 框架下是合理的(框架保证了注入先于使用),但很多人拿它当「我懒得想初始化顺序」的万能贴,最后一个类里五个 lateinit,谁先谁后全靠默契。判据:这个属性是不是由一个你信得过的框架/生命周期保证会被赋值?不是的话,改成构造函数参数或 by lazy。
把「收敛」写成一个薄适配层,比在业务代码里到处判空划算得多。做法是给要用的 Java 类型写一批扩展函数或一个小 wrapper,在那里一次性把 String! 变成 String 或 String?、把 Optional<T> 变成 T?、把「空字符串代表没有」变成真正的 null。之后整个 Kotlin 侧就都活在一个诚实的类型系统里,空安全的价值才真正兑现——05 章讲的那套机制,只有在边界被守住之后才成立。

前面几卡讲的是语言层面的翻译规则。真正的时间消耗其实在另一处:Java 生态里大量框架依赖运行期反射,而它们的假设是「Java Bean」——无参构造器、可变属性、非 final 字段。而 Kotlin 最推荐的写法(data class + 全 val)在这三条上全不满足。下面讲这类摩擦怎么解。

data class + val 为什么会出错

看 javap 就清楚了:data class User(val id: Long, val name: String, val email: String? = null) 编出来是——

private final long id;
private final java.lang.String name;
public demo.User(long, java.lang.String, java.lang.String);
public demo.User(long, String, String, int, DefaultConstructorMarker);
public final long getId();
public final demo.User copy(long, java.lang.String, java.lang.String);

没有无参构造器,字段全是 final,没有 setter。老式的反射框架先 newInstance() 再逐个 setXxx(),第一步就失败。而且 Kotlin 的默认参数、可空性、value class 这些信息都存在 @Metadata 注解里,纯 Java 的反射根本读不到——所以「能不能反序列化」和「反序列化对不对」是两个问题:一个不认识 Kotlin 的 JSON 库能通过 Unsafe 把 null 硬塞进非空字段,等你读它的时候才 NPE。

三类解法,按框架对号入座

框架它要什么Kotlin 侧怎么办
Jackson无参构造器 + setter,或构造器参数名加 jackson-module-kotlin 并注册它。这个模块读 @Metadata,认得主构造器、默认参数和可空性,非空字段收到 null 会直接报错而不是硬塞
JPA / Hibernate无参构造器 + 非 final 的实体类Gradle 上 kotlin("plugin.jpa")(底层是 no-arg 插件,给 @Entity 合成一个无参构造器)+ kotlin("plugin.allopen") 把实体类改成 open 供代理继承
Spring能被 CGLIB 代理的类(不能 final)kotlin("plugin.spring"),它是 all-open 的预设,自动给 @Component / @Configuration 等开 open
kotlinx.serialization什么都不要——编译期生成序列化器纯 Kotlin 项目的首选:没有反射、跨平台、data class + val 原样能用

这几个插件的共同点是:它们都是编译器插件,改的是字节码,不是你的源码。你的 Kotlin 代码看起来还是不可变的 data class,只有框架通过反射看得到那个多出来的无参构造器。

两个日常选型

  • Optional<T> vs T?:Kotlin 侧一律用 T?。Optional 是 Java 在没有可空类型时的补丁——它是一个真实对象(要分配、要拆箱、不能进原始类型),而 T? 是编译期信息,运行时零成本。只在跨越 Java API 边界时才让 Optional 出现,进门就 .orElse(null) 转成 T?;出门(返回给 Java 调用方)时再包回去。
  • 静态工具类 vs 顶层函数:Java 里 StringUtils.isEmail(s) 这种「一个类当命名空间」的写法,在 Kotlin 里没有必要——顶层函数本来就在一个自动合成的类里。更进一步,能写成扩展函数就写成扩展函数:s.isEmail() 的调用点在 IDE 里可以自动补全出来,StringUtils.isEmail(s) 得先想起有这个类。但要给 Java 用的话,记得配 @file:JvmName,否则那边看到的是 StringExtKt。
// Kotlin 侧照常写不可变的 data class
data class User(val id: Long, val name: String, val email: String? = null)

// javap -p demo/User.class(真实输出,节选)
// private final long id;                     ← final 字段
// public demo.User(long, java.lang.String, java.lang.String);
// public final long getId();                 ← 只有 getter
// public final demo.User copy(long, String, String);
// —— 没有无参构造器,没有 setter

// ── build.gradle.kts:让 Java 生态用得上它 ────────────
// plugins {
//     kotlin("jvm")            version "2.4.10"
//     kotlin("plugin.spring")  version "2.4.10"   // all-open 预设
//     kotlin("plugin.jpa")     version "2.4.10"   // no-arg:合成无参构造器
// }
// dependencies {
//     implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// }

// Jackson:注册模块之后,data class + val 直接可用
val mapper = jacksonObjectMapper()
val u: User = mapper.readValue(json)

// 纯 Kotlin 项目更省事:kotlinx.serialization 编译期生成,零反射
@Serializable
data class User2(val id: Long, val name: String)
val u2 = Json.decodeFromString<User2>(json)

// Optional 只活在边界上,进门立刻转成 T?
fun find(id: Long): User? = repo.findById(id).orElse(null)

// 工具类 → 顶层扩展函数
// Java 思维: class StringUtils { static boolean isEmail(String s) }
fun String.isEmail(): Boolean = contains("@")      // 调用处:s.isEmail()
no-arg / all-open 插件是「给框架开的后门」,不是「给你开的」。no-arg 合成的无参构造器是 synthetic 的,Kotlin 侧调不到,但它确实存在——意味着框架可以造出一个所有字段都是默认值(引用类型全是 null)的实例,哪怕你声明的是非空 val。JPA 的懒加载代理、Hibernate 的部分初始化实体,都可能让你在 val name: String 上读到 null。另外 data class 当 JPA 实体本身就是个坏主意:equals/hashCode 由全部字段生成,而实体的身份应该只由主键决定,放进 Set 或做懒加载时行为会很奇怪。实体老实写普通 class,data class 留给 DTO。
选型顺序:先问「这个框架认不认 Kotlin」。认(jackson-module-kotlin、kotlinx.serialization、Exposed、Ktor)就什么都不用改,data class + val 原样上;不认(老式 JPA、老式 BeanUtils、部分 XML 绑定库)才上编译器插件。最坏的做法是为了迁就框架,把整个领域模型改成 var + 全可空 + 无参构造器——那等于把 Kotlin 的核心价值退掉,换来的只是少配一个插件。

前面五卡讲了怎么混编,这里讲要不要混。互操作能力强不代表混编没有成本——它有一个具体的、可以量化的成本:构建复杂度和认知负担。什么时候这笔账算得过来,值得先想清楚。

一个模块里两种语言,编译分两趟

这不是构建工具的实现细节,是必然的:Kotlin 要引用 Java 类,Java 也要引用 Kotlin 类,循环依赖只能靠分阶段打破。的顺序是——

  1. kotlinc 先跑,参数里同时给它 .kt 和 .java。它会解析 .java 的源码来做符号解析,但不为它们产出字节码:跑完之后输出目录里只有 MainKt.class / KHandler.class 这些 Kotlin 的产物,没有 JavaSide.class。
  2. javac 再跑,把上一步的输出目录放进 -cp,此时 Java 代码就能看到 Kotlin 编出来的类了。

Gradle 的 kotlin("jvm") 插件默认就是这个顺序,正常情况下你什么都不用配。但它有一个真实的限制:Kotlin 看得见你手写的 .java,却看不见 javac 阶段由注解处理器生成的 Java 源码——因为那时 Kotlin 已经编完了。这也是 KAPT(把注解处理接到 Kotlin 编译里)和后来 KSP 存在的原因之一。

渐进迁移:唯一靠谱的策略

  • 新代码写 Kotlin,旧代码原地不动。这是绝大多数团队实际走的路,也是 Kotlin 互操作设计的目标场景。旧 Java 代码不重写、不「顺手改改」,除非你本来就要动它。
  • 改动某个 Java 文件时才顺带转换它——因为你反正要读懂它、要重新测它,转换的边际成本最低。IDE 的「Convert Java File to Kotlin」能做机械翻译,但产出的是「能跑的 Kotlin」不是「好的 Kotlin」:满屏 !! 和平台类型残留,转完必须人工过一遍可空性。
  • 从叶子往上转,别从核心接口开始。工具类、DTO、纯函数最好转;被几十处引用的公开接口最后转——它一改,两边的调用点都得跟着动。
  • 先给要留着的 Java 代码补 @Nullable / @NotNull。这是投入最小、收益最直接的一步:不用改一行逻辑,Kotlin 侧的平台类型立刻变成真类型。

什么时候该保持 Java

情况建议
代码稳定、没人动、测试覆盖好别碰。重写的唯一确定收益是引入新 bug
重度依赖注解处理器(老式 APT)先评估 KSP 有没有对应实现,没有就先留着
要给纯 Java 项目当依赖发布的库可以用 Kotlin 写,但每个公开 API 都得考虑 @Jvm*,成本不低;API 表面很大时 Java 更省事
团队里没人熟 Kotlin混编会让「读懂一个模块」需要两套知识——先培训,或者整模块切换而不是逐文件混
字节码/性能极敏感的热点代码差别通常可以忽略,但 inline、value class、协程的字节码形态和 Java 不同,真要抠就,别假设
# 一个模块里两种语言,编译必须分两趟(下面是验证过程)

# ① kotlinc 先跑,参数里同时给它 .kt 和 .java
$ kotlinc -d out src/Main.kt src/JavaSide.java
$ find out -name '*.class'
out/KHandler.class
out/KFunHandler.class
out/MainKt.class
# ← 没有 JavaSide.class:.java 只是被「读」来做符号解析,不产出字节码

# ② javac 再跑,把 Kotlin 的产物放进 classpath
$ javac -cp out -d out src/JavaSide.java

# Gradle 的 kotlin("jvm") 插件默认就是这个顺序,通常无需配置:
# plugins { kotlin("jvm") version "2.4.10" }
# src/main/java 和 src/main/kotlin 下的文件会被一起处理

# ── 迁移时最划算的第一步:给要留着的 Java 补注解 ──────
# // 改之前:Kotlin 侧看到 String!
# public String findName(long id) { ... }
#
# // 改之后:Kotlin 侧看到 String?,编译器开始帮你干活
# @Nullable public String findName(long id) { ... }
IDE 的「Convert Java File to Kotlin」转出来的代码不能直接提交。它做的是保守的机械翻译:Java 里所有可能为 null 的地方,它都翻译成可空类型然后到处补 ?. 和 !!;Java 的 static 字段变成 companion object 里的属性,Java 调用方那边全部编译失败;for 循环、三元表达式会被逐字翻译成笨拙的 Kotlin。转换只是第一步,之后必须做三件事:① 逐个 !! 判断该收敛成什么;② 检查有没有 Java 调用方被这次转换打断(@JvmStatic / @JvmField / @JvmName 补上);③ 跑一遍测试。跳过这三步的「一键转换」,基本等于把编译期错误换成了运行期错误。
用「这段代码接下来六个月会不会被改」当迁移优先级的判据,比用「这段代码有多难看」靠谱得多。会被反复修改的代码,转成 Kotlin 的收益(空安全、更少样板、更好的表达力)会持续兑现;一个五年没动过的工具类转成 Kotlin,收益是零,风险却是实打实的。15 章讲的那套测试基线,是决定「敢不敢转」的前提——没有测试的模块,先补测试再谈迁移。

惯用法与 API 设计

本章覆盖:require / check / error / assert 各管一段、失败表达的四种谱系(异常 / 可空 / Result / sealed)、value class 给原始类型套语义、可见性与 API 稳定性、命名惯例。同样的功能,地道与不地道的差距集中在这一章。

语法写对不等于写得地道。这一章是「Kotlin 里公认的做法」的集中地——前置条件怎么写、失败怎么表达、类型怎么起名、API 怎么不坑将来的自己。

前置条件:四个函数各管一段

函数失败时抛用在哪
require(cond) { "msg" }IllegalArgumentException校验参数——「调用方传错了」
requireNotNull(x) { }同上参数不能为 null,返回值是非空的 x
check(cond) { }IllegalStateException校验状态——「现在不该调这个方法」
checkNotNull(x) { }同上同理
error("msg")IllegalStateException「不可能走到这里」;返回 Nothing,可以放在 ?: 右边
assert(cond)AssertionError默认被关闭(要 -ea),别用它做真正的校验
TODO()NotImplementedError占位,编译得过、跑到就抛
写法要点消息写成 lambda({ "…" })——不失败时不会构造字符串

失败怎么表达:四种谱系

方式什么时候用代价
抛异常调用方无法预期、也无法处理的错误:参数非法、状态不对、IO 崩了Kotlin 没有受检异常,签名上看不出来会抛什么——靠文档与命名补
返回 T?「没找到」是正常结果,而且原因只有一种丢失了失败原因
Result<T> / runCatching要把成功与失败当值传递runCatching 会连 CancellationException 一起吞(协程里危险,10 章);Result 作为返回类型有使用限制
sealed 结果类型失败有多种、每种带不同数据(网络错 / 校验错 / 权限错)要多写几个类;换来 when 的穷尽检查
约定同一个操作提供两个版本是标准库的做法:first() / firstOrNull()、toInt() / toIntOrNull()——自己的 API 也该这么配对

value class:给原始类型套语义

要点说明
写法@JvmInline value class UserId(val value: Long)
解决什么fun transfer(from: Long, to: Long, amount: Long) 这种签名,参数顺序传错编译器一声不吭;换成 UserId / Money 就编译不过
代价运行时通常没有包装对象(编译后就是那个 Long)——零开销
什么时候会真装箱放进集合、当泛型实参、当可空类型时
限制只能有一个属性、不能有幕后字段、不能继承
还能带方法value class Email(val v: String) { init { require("@" in v) } }——构造时就校验

可见性与 API 稳定性

要点说明
internal模块内可见——库作者最该用的修饰符,把实现细节挡在外面
默认 publicKotlin 不写修饰符就是公开的,和 Java 的包私有默认相反——容易不小心暴露
显式 API 模式kotlin { explicitApi() }:强制公开声明写出可见性与返回类型,写库时建议开
返回类型显式化公开 API 别用表达式体推断返回类型——实现改一行就可能改变签名
加参数给公开函数加带默认值的参数不是二进制兼容的:调用方需要重新编译
data class 作为公开 API加字段会改变 copy 与 componentN 的签名——公开数据模型慎用 data
标注实验@RequiresOptIn 定义自己的 opt-in 注解
废弃@Deprecated("原因", ReplaceWith("newApi()"))——ReplaceWith 能让 IDE 一键迁移

命名与写法惯例

东西惯例
类 / 接口PascalCase;接口不加 I 前缀,实现类也不叫 XxxImpl(想不出名字说明抽象有问题)
函数 / 变量camelCase
常量UPPER_SNAKE(const val 与顶层 val)
返回 Boolean 的函数isX / hasX / canX
会抛 vs 不会抛x() / xOrNull()
扩展文件StringExt.kt · CollectionExt.kt
包名全小写,不用下划线
幕后属性private val _items + val items: List<T> get() = _items
格式官方 Kotlin style guide;用 ktlint / detekt 自动执行(15 章)
// ① 前置条件:参数错用 require,状态错用 check
class Account(private var balance: Long) {
    private var closed = false

    fun withdraw(amount: Long) {
        require(amount > 0) { "金额必须为正,收到 $amount" }     // 调用方传错
        check(!closed) { "账户已关闭" }                          // 状态不对
        require(amount <= balance) { "余额不足" }
        balance -= amount
    }
}

// ② 失败的四种表达,按场景选
fun findUser(id: Long): User? = repo[id]                      // 没找到很正常 → 可空

sealed interface LoadResult {                                 // 失败有多种 → sealed
    data class Ok(val user: User)        : LoadResult
    data class NetworkError(val e: Throwable) : LoadResult
    data object Unauthorized          : LoadResult
}

// ③ value class:让类型系统拦住参数顺序错误
@JvmInline value class UserId(val value: Long)
@JvmInline value class Cents(val value: Long)

fun transfer(from: UserId, to: UserId, amount: Cents) { }
// transfer(amount, from, to)   → 编译错(换成三个 Long 就编得过)

// ④ 构造时校验:非法状态根本造不出来
@JvmInline value class Email(val value: String) {
    init { require("@" in value) { "不是邮箱:$value" } }
}

// ⑤ 幕后属性:内部可变,对外只读
class Cart {
    private val _items = mutableListOf<Item>()
    val items: List<Item> get() = _items
}
assert() 默认是关闭的(JVM 要加 -ea 才生效),拿它当参数校验等于什么都没做。要校验就用 require / check。
Kotlin 不写可见性修饰符就是 public,这和 Java 的包私有默认相反。写库时不注意,实现细节会大片地暴露成公开 API,之后就改不动了——写库一律开 explicitApi()。
公开的 data class 是兼容性陷阱:加一个字段就改变了 copy() 与 componentN() 的签名,所有调用方都得重新编译。
两条能立刻提升代码质量的习惯:公开函数的第一行写 require / check(失败信息里带上实际值),以及给容易搞混的原始类型套 value class——后者几乎零运行时成本,换来的是编译器帮你挡住参数顺序错误。
失败表达的选择只要一问:「调用方需要区分几种失败」——一种用 T?,多种用 sealed,不需要区分就抛异常。

下面这个签名有什么问题:fun transfer(from: String, to: String, memo: String)?问题是三个参数任意调换顺序都能编译通过。这类 bug 编译器帮不上忙、code review 看不出来、测试还常常刚好用了相同的测试数据。value class 就是用来关掉这扇门的。

它解决什么,以及和 typealias 的区别

@JvmInline value class UserId(val raw: String) 声明了一个真正的新类型:它只包一个值,但在类型系统里和 String、和别的 value class 都不通用。把参数写反:

fun link(user: UserId, order: OrderId) = ...
link(order, user)
error: argument type mismatch: actual type is 'OrderId', but 'UserId' was expected.

而换成 typealias TUserId = String,同样写反,编译器一声不吭,程序照跑,结果是错的(输出 OU 而不是 UO)。typealias 只是给已有类型起个别名,不创造新类型——02 章讲过这一点,这里是它最重要的实际后果。别用 typealias 来「表达领域概念」,它只适合缩短又长又难念的泛型签名。

擦除:运行时通常一个对象都不多

value class 的卖点是「类型安全不要钱」——编译器会在字节码里把它擦回底层类型。javap:

@JvmInline value class Cents(val v: Int)
fun plain(c: Cents): Int
→ public static final int plain-dtET9Oc(int);

参数是裸 int,没有对象分配。方法名后面那串哈希是编译器加的:因为擦除后 plain(Cents) 和 plain(Int) 的 JVM 签名会撞车,加个后缀区分开。SPEC 里那个 takesId-Bu7z9Ig(java.lang.String) 是同一回事。副作用是:这样的函数从 Java 侧基本没法调(要写 plain-dtET9Oc 这种名字),要给 Java 用得加 @JvmName 另开一个包装。

什么时候会装箱——四种情况

用法javap 里的实际签名装箱?
fun plain(c: Cents)int plain-dtET9Oc(int)否
fun nullable(c: Cents?)int nullable-jjoDfS8(Cents)是——int 表示不了 null
fun inList(l: List<Cents>)int inList(java.util.List<Cents>)是——泛型实参必须是引用类型
fun asAny(a: Any),传 Cents 进去String asAny(java.lang.Object)是——当成 Any 就得有个对象

有个容易误判的细节:可空是不是一定装箱,取决于底层类型。Cents(Int) 的可空版会装箱,但UserId(String) 的可空版签名是 takesNullable-EbZ85Aw(java.lang.String)——没有装箱,因为底层就是引用类型,用 null 表示「没有」是免费的。另外接口方法也不一定装箱:interface Payable { fun price(): Cents } 编出来是 public abstract int price-oFSpMh4();,依然是裸 int。

结论不是「要小心装箱」,而是「就算装箱了通常也不比手写包装类差」——你原本就得为类型安全付一个对象的代价,value class 只是在大部分场景下把这个代价省掉了。

// 问题:这三个参数写反了编译器也不知道
fun transfer(from: String, to: String, memo: String) { /* ... */ }

// 解法:给每个概念一个真正的类型
@JvmInline value class UserId(val raw: String)
@JvmInline value class OrderId(val raw: String)

fun link(user: UserId, order: OrderId): String = user.raw + order.raw

link(order, user)      // 报错:
// error: argument type mismatch: actual type is 'OrderId', but 'UserId' was expected.

// ── 对照组:typealias 不是新类型 ──────────────────────
typealias TUserId = String
typealias TOrderId = String
fun linkAlias(u: TUserId, o: TOrderId) = u + o

linkAlias(orderId, userId)   // 编过、跑通、结果是错的(输出 OU)

// ── javap:擦除长什么样 ───────────────────────────────
@JvmInline value class Cents(val v: Int)

fun plain(c: Cents): Int = c.v
fun nullable(c: Cents?): Int = c?.v ?: -1
fun inList(l: List<Cents>): Int = l.size
fun asAny(a: Any): String = a.toString()

// javap -p Vc2Kt(真实输出)
// public static final int plain-dtET9Oc(int);                    ← 擦成 int,不装箱
// public static final int nullable-jjoDfS8(Cents);               ← 可空 ⇒ 装箱
// public static final int inList(java.util.List<Cents>);          ← 泛型实参 ⇒ 装箱
// public static final java.lang.String asAny(java.lang.Object);  ← 当 Any 用 ⇒ 装箱

// 但接口方法不一定装箱:
interface Payable { fun price(): Cents }
// public abstract int price-oFSpMh4();      ← 依然是裸 int

// value class 里可以有方法和计算属性,正好放校验和转换
@JvmInline
value class Email(val raw: String) {
    init { require("@" in raw) { "不是合法邮箱: " + raw } }
    val domain: String get() = raw.substringAfter("@")
}
擦除会让 value class 在两个地方出人意料。① Java 完全用不了——方法名带哈希后缀,Java 侧写不出来;给 Java 用的 API 得另开一个 @JvmName 包装函数,参数用底层类型。② 重载会撞车:fun f(id: UserId) 和 fun f(id: OrderId) 底层都是 String,但因为哈希后缀不同,这两个反而能共存;真正会撞的是 fun f(id: UserId) 和 fun f(raw: String)——前者带后缀、后者不带,也能共存,但从 Java 侧看名字对不上号。所以判据是:value class 是给 Kotlin 内部用的类型安全工具,一旦这个 API 要跨语言暴露,就得额外设计一层。
value class 的 init 块是放校验的最佳位置。把「合法的邮箱」这个约束写进类型里之后,整个系统里只要拿到一个 Email 实例,就不需要再校验第二次——这就是所谓「让非法状态无法表示」。对比一下到处 if (!s.contains("@")) throw ... 的写法:那种做法里,你永远不知道手上这个 String 有没有被校验过。注意 init 块会在每次构造时执行,构造本身仍然是免费的(没有对象分配),但校验逻辑该多快还是得多快。

「这个参数不能是负数」「这时候连接必须已经建立」——这类前置条件人人都在写,但写成 if (x < 0) throw IllegalArgumentException("...") 又长又容易挑错异常类型。Kotlin 标准库把它们做成了四个内联函数,语义分工很清晰:谁的错,就抛谁的异常。

四个函数,四种「谁错了」

函数语义抛出默认消息
require(cond) { msg }调用方给的参数不对java.lang.IllegalArgumentExceptionFailed requirement.
requireNotNull(v) { msg }同上,专管 null,返回非空值java.lang.IllegalArgumentExceptionRequired value was null.
check(cond) { msg }对象自己的状态不对java.lang.IllegalStateExceptionCheck failed.
checkNotNull(v) { msg }同上,专管 null,返回非空值java.lang.IllegalStateExceptionRequired value was null.
error(msg)无条件终止,返回类型是 Nothingjava.lang.IllegalStateException—
assert(cond) { msg }开发期自检,默认不生效java.lang.AssertionError(仅 -ea)—

require 和 check 的分界线就是「责任在谁」:require(amount > 0)——你传了个负数,是你的错,IllegalArgumentException;check(account.isOpen)——账户被冻结了,是这个对象当前的状态不允许,IllegalStateException。这不只是命名洁癖:调用方 catch 到 IllegalArgumentException 知道该检查自己传的参数,catch 到 IllegalStateException 知道该检查调用时机。

assert 默认什么都不做

这是最容易踩的一个:assert(false) 直接跑,什么也不会发生。它编译成 JVM 的断言机制,必须启动时加 -ea(enable assertions)才生效。同一份代码两种跑法:

$ java -cp ... PreKt
F7 assert OK (no throw)

$ java -ea -cp ... PreKt
F7 assert java.lang.AssertionError : 断言失败

所以永远不要把生产环境需要的检查写成 assert——线上默认没开 -ea,那行代码等于注释。assert 的正确定位是「我认为这里不可能发生,测试时帮我盯着」,测试环境开 -ea,生产不开也不影响正确性。真正必须成立的东西用 require / check。

两个用起来很顺手的性质

  • requireNotNull / checkNotNull 有返回值,可以直接接住:val v = requireNotNull(s) { "s 不能为空" },之后 v 的类型就是 String(v.length 直接能点出来)。比先判空再用 !! 干净得多,而且报错自带业务信息。
  • error() 的返回类型是 Nothing,所以它可以出现在任何需要「值」的位置:val x = map[key] ?: error("缺少 key: " + key)、when 的分支里、Elvis 的右侧。编译器知道这一支永远不会返回,后面的代码照样能做穷尽性检查——03 章讲 Nothing 时是同一个机制。
  • 四个函数都是 inline 的,消息用 lambda 传({ "..." })而不是直接传字符串。区别是:条件成立时那个字符串根本不会被拼出来。所以 require(x > 0) { "非法值: " + expensiveToString() } 在正常路径上零开销——能用 lambda 版就别用字符串版。
fun withdraw(account: Account, amount: Int): Int {
    // 参数不对 → 调用方的错
    require(amount > 0) { "取款额必须为正,收到 " + amount }

    // 状态不对 → 这个对象现在不能干这事
    check(account.isOpen) { "账户已冻结" }

    // 有返回值,接住之后类型已经是非空的
    val owner = requireNotNull(account.owner) { "账户没有归属人" }

    // error() 返回 Nothing,可以放在需要值的位置
    val limit = limits[owner.level] ?: error("未配置等级额度: " + owner.level)

    // 开发期自检:默认不生效,只有 -ea 才抛
    assert(account.balance >= 0) { "余额不该为负" }

    return account.balance - amount
}

// ── 输出(同一份代码,两种跑法)─────────────────
// $ java ...
// F1 require        java.lang.IllegalArgumentException : age 必须为正
// F2 require 无消息  java.lang.IllegalArgumentException : Failed requirement.
// F3 check          java.lang.IllegalStateException    : 还没初始化
// F4 error          java.lang.IllegalStateException    : 不该走到这里
// F5 requireNotNull java.lang.IllegalArgumentException : 缺少配置
// F6 checkNotNull   java.lang.IllegalStateException    : Required value was null.
// F7 assert         OK (no throw)          ← 没加 -ea,这行什么也没发生
//
// $ java -ea ...
// F7 assert         java.lang.AssertionError : 断言失败

// requireNotNull 的返回值可以直接用,不需要再 !! 或再判空
fun length(s: String?): Int {
    val v = requireNotNull(s) { "s 不能为空" }
    return v.length          // v 的类型已经是 String
}
require 不是错误处理,是契约检查。两者的区别在于「这种情况是否属于程序的正常运行范围」:用户在表单里填了个负数——那是预期内的输入,应该走校验流程返回给用户看的错误信息,不该抛 IllegalArgumentException;而一个内部函数收到负数,说明上游代码有 bug,那才是 require 的场合。把 require 当输入校验用,会导致两个后果:一是把 bug 和正常业务失败混在同一种异常里,监控上分不开;二是调用方为了处理正常失败被迫 catch IllegalArgumentException,而这个异常本该是「不该被 catch」的。本章后面讲这条线怎么划。
消息要写「发生了什么」,不要写「应该怎样」。require(amount > 0) { "amount 必须为正" } 只是把代码念了一遍;require(amount > 0) { "取款额必须为正,收到 " + amount } 把实际值带出来了——排查时这一个数字往往就是全部线索。因为消息是 lambda,正常路径上不会付这个字符串拼接的代价,所以没有理由省。

Kotlin 没有受检异常,也没有强推某一种错误处理方式,于是「一个函数失败了怎么告诉调用方」有四种主流答案。它们不是替代关系,选错了会一路把复杂度传染到整个调用链。

四种方案的判据

方案什么时候用代价
抛异常失败是异常情况:bug、环境崩了、契约被违反。调用方通常不该处理,让它冒到顶层统一记录调用方看签名不知道会抛什么;控制流不可见
返回 T?失败只有一种原因,而且不言自明:map[key] 没找到、toIntOrNull() 格式不对丢掉了「为什么失败」;两层可空会很麻烦
返回 Result<T>失败要带着原因往上传,但调用方不需要按失败种类分别处理——记个日志、给个兜底就行失败类型是 Throwable,没有穷尽性;runCatching 有下面两个陷阱
自定义 sealed 结果类型失败有几种,调用方要分别处理:登录失败是密码错、账号锁定还是网络超时,UI 上完全不同要多写一个类型;不能直接用标准库的组合子

一句话判据:调用方会不会对不同的失败做不同的事?会 → sealed;不会但要知道原因 → Result;连原因都不关心 → T?;根本不该发生 → 异常。

runCatching 的两个陷阱

runCatching { ... } 看起来是 try/catch 的优雅替代,但它有两个必须知道的坑,两个都验证过。

① 它捕获的是 Throwable,不是 Exception。这意味着 OutOfMemoryError、StackOverflowError 这些「JVM 已经不健康了」的信号也会被它悄悄变成一个 Result.failure,程序带着一个坏掉的运行时继续往下跑。

val r = runCatching { throw OutOfMemoryError("simulated OOM") }
r.exceptionOrNull()  →  java.lang.OutOfMemoryError : simulated OOM

② 在协程里它会吞掉 CancellationException。协程的取消机制是靠抛 CancellationException 实现的(10 章讲过),而 runCatching 一视同仁地把它接住了。后果是:协程已经被取消,但你的代码毫不知情,继续往下执行。输出:

caught=kotlinx.coroutines.JobCancellationException
取消之后这行照样执行            ← job.isCancelled 已经是 true 了

解法:协程里要么老实用 try/catch (e: Exception),要么在 onFailure 里把 CancellationException 重新抛出去。这是协程代码里最常见的一类「取消不生效」bug 的根因。

Result 作为返回类型:现在没有限制了

历史上 Result 刚引入时(Kotlin 1.3)不允许当返回类型,要加 -Xallow-result-return-type 才行,网上大量老资料还停在这个说法。这个限制早已解除:在 2.4.10 上,fun parse(s: String): Result<Int>、Result 当参数、当属性、放进 List、写成 Result<Int>?,全部无需任何 opt-in,编译零警告。

真正还剩的性质是:Result 本身是个 value class,所以它遵守前面那套擦除规则——fun takes(r: Result<Int>) 的 JVM 签名是 takes(java.lang.Object),而放进 List<Result<Int>> 或写成可空时会装箱成真正的 kotlin.Result 对象。这在绝大多数场景下都无所谓。

// ① 异常:失败 = bug 或环境问题,让它冒上去
fun loadConfig(path: String): Config {
    val text = File(path).readText()      // 文件不存在就该炸
    return parse(text)
}

// ② T?:失败只有一种原因,一望而知
fun findUser(id: Long): User? = cache[id]

// ③ Result:要带原因,但调用方不分种类处理
fun parsePort(s: String): Result<Int> = runCatching { s.toInt() }
parsePort("x").getOrElse { 8080 }
// Failure(java.lang.NumberFormatException: For input string: "x")

// ④ sealed:失败分几种,调用方要分别处理
sealed interface LoginResult {
    data class Ok(val token: String) : LoginResult
    data object WrongPassword       : LoginResult
    data class Locked(val until: Instant) : LoginResult
    data class Failed(val cause: Throwable) : LoginResult
}
when (result) {                      // 编译器逼你处理每一种
    is LoginResult.Ok            -> goHome(result.token)
    LoginResult.WrongPassword    -> showError("密码错误")
    is LoginResult.Locked        -> showLocked(result.until)
    is LoginResult.Failed        -> showRetry()
}

// ── runCatching 陷阱 ①:它 catch 的是 Throwable ────────
val r = runCatching { throw OutOfMemoryError("simulated OOM") }
println(r.exceptionOrNull())
// java.lang.OutOfMemoryError : simulated OOM   ← 连 Error 都吞

// ── runCatching 陷阱 ②:在协程里吞掉取消 ───────────────
launch {
    val out = runCatching { delay(10_000) }
    println("caught=" + out.exceptionOrNull()?.javaClass?.name)
    println("取消之后这行照样执行")
}
// 输出:
// caught=kotlinx.coroutines.JobCancellationException
// 取消之后这行照样执行            ← 此时 job.isCancelled 已经是 true

// 协程里的安全写法:把取消放回去
val safe = runCatching { risky() }
    .onFailure { if (it is CancellationException) throw it }
runCatching { }.getOrNull() 是最容易写出的、也最糟的一种错误处理。它把三件事一起做了:捕获所有 Throwable(含 Error 和协程取消)、丢掉全部失败信息、返回一个和「正常的 null」无法区分的 null。排查线上问题时,这行代码的位置会是一片彻底的空白——没有日志、没有栈、没有痕迹。如果你只想要「失败就给个默认值」,至少写成 runCatching { ... }.onFailure { log.warn("...", it) }.getOrElse { default },让失败留下痕迹。在协程里还要额外把 CancellationException 抛回去。
公开 API 用 sealed,内部实现用 Result,这条分界很好使。公开 API 的失败种类是契约的一部分——写进 sealed interface 之后,调用方的 when 会在你新增一种失败时编译报错,逼他去处理,这正是你想要的;而内部实现里失败往往就是「出错了,记个日志重试一下」,Result 的组合子(map / recover / getOrElse)能省掉大量样板。别在公开 API 上返回 Result:调用方拿到一个 Throwable,只能靠 is SomeException 去猜你可能抛什么,等于把契约藏进了实现。

下面是一组「知道了就再也不想用别的写法」的标准库函数,外加一条同样重要的反向建议:作用域函数用过头,代码会变得比它替代掉的那版更难读。

buildList / buildMap / buildString:命令式地攒,函数式地交

有些集合天生是「一条条攒出来的」——有条件地加、循环里加、从几个来源合并。传统写法是 val list = mutableListOf<String>() ... 一堆 add ... 最后 return list,问题是那个 MutableList 变量泄漏了出去,返回类型写 List 也只是障眼法(07 章讲过,强转回 MutableList 照样能改)。

buildList { } 把构建期和使用期切开:块里是 MutableList,块外拿到的是一个真正不可变的 List。这不是说说而已:

val l = buildList { add(1); add(2) }
l.javaClass.name  →  kotlin.collections.builders.ListBuilder
(l as MutableList<Int>).add(99)
→  java.lang.UnsupportedOperationException

它和「先 mutableListOf 再声明成 List」不同——那一种强转回去会改成功;而 listOf 和 buildList 一样会抛(底层是 Arrays$ArrayList,见 07 章)。buildList 是目前构造只读集合最安全的方式。buildMap / buildSet / buildString 同理(buildString 底下是 StringBuilder,是拼字符串的默认选择)。

四个高频小函数

函数做什么
listOfNotNull(a, b, c)过滤掉 null,结果类型是 List<T> 而不是 List<T?>listOfNotNull("a", null, "b") → [a, b]
associateWith { }元素当 key,lambda 算 valuelistOf("aa","bbb").associateWith { it.length } → {aa=2, bbb=3}
associateBy { }元素当 value,lambda 算 keylistOf("aa","bbb").associateBy { it.length } → {2=aa, 3=bbb}
takeIf { } / takeUnless { }满足条件返回自己,否则返回 null5.takeIf { it > 3 } → 5;5.takeUnless { it > 3 } → null

associateWith 和 associateBy 谁是 key 谁是 value 特别容易记反,记法是看后缀:associateBy「按……归类」——lambda 给的是 key;associateWith「配上……」——lambda 给的是配上去的 value。

takeIf 的真正价值在于把一个 if 变成可空链的一环:name?.takeIf { it.isNotBlank() } ?: "(空)" 一行搞定「非空且非空白,否则给默认值」,输出 (空)。写成 if-else 要三行加一个临时变量。

什么时候不该用作用域函数

let / run / with / apply / also 五个函数上手很快,然后就容易失控。几条实际的界线:

  • 一条链里超过两个作用域函数,通常就该拆了。a.let { ... }.also { ... }.run { ... } 读的人要在脑子里同时维护三个 it/this 指向什么,而拆成三行带名字的局部变量,每一行都自解释。
  • 嵌套的 let 里 it 会打架。外层的 it 被内层遮蔽,写出来的代码能编过但意思和你想的不一样。真要嵌套,给参数起名:user.let { u -> ... }。
  • ?.let { } 不是万能的判空写法。只在「非空时才做一件事」时用;如果两边都有分支(?.let { } ?: run { }),老实写 if (x != null) 更清楚——而且 if 之后有智能转换,不需要 it。
  • apply 只用于配置对象(连续设置属性然后返回它自己),also 只用于旁路(打日志、加校验,不改变链上的值)。用 apply 去做计算、用 also 去改值,都是在制造只有作者读得懂的代码。
// buildList:块里可变,块外拿到真正只读的 List
val routes = buildList {
    add("/health")
    if (devMode) add("/debug")
    addAll(plugins.map { it.path })
}
println(routes.javaClass.name)
// kotlin.collections.builders.ListBuilder

(routes as MutableList<String>).add("/x")
// java.lang.UnsupportedOperationException   ← 真的改不了

val headers = buildMap {
    put("Accept", "application/json")
    if (token != null) put("Authorization", "Bearer " + token)
}

val report = buildString {          // 底下就是 StringBuilder
    appendLine("== 报告 ==")
    items.forEach { appendLine("- " + it) }
}

// 四个高频小函数(输出在注释里)
listOfNotNull("a", null, "b")                        // [a, b],类型是 List<String>
listOf("aa", "bbb").associateWith { it.length }   // {aa=2, bbb=3}
listOf("aa", "bbb").associateBy   { it.length }   // {2=aa, 3=bbb}

val n = 5
n.takeIf { it > 3 }        // 5
n.takeUnless { it > 3 }    // null

// takeIf 最好用的地方:把一个 if 塞进可空链
val shown = name?.takeIf { it.isNotBlank() } ?: "(空)"

// ── 作用域函数:好的用法与失控的用法 ──────────────────
// 好:apply 配置对象,also 旁路日志
val conn = Connection().apply {
    timeout = 5_000
    retries = 3
}.also { logger.info("建连: " + it) }

// 坏:一条链三个作用域函数,三个 it 各指一处
// val x = a.let { ... }.also { ... }.run { ... }

// 坏:两边都有分支时硬用 let
// user?.let { save(it) } ?: run { logMissing() }
// 好:老实写 if,之后还有智能转换
if (user != null) save(user) else logMissing()
buildList 的那个 receiver 千万别泄漏出去。块里那个 this 是个 MutableList,如果你在块里把它存到外面(赋给外部变量、传给别的对象保存),构建结束之后再通过那个引用去 add,行为是未定义的——标准库明确说了 builder 在 build 之后不该再被使用。这个坑不常见,但一旦踩到会表现为「集合内容莫名其妙变了」或者 UnsupportedOperationException,非常难查。规则:buildList 的块里只做添加,不做保存。
需要「攒一个集合」时,先问能不能用 map / filter / flatMap 直接表达。buildList 很好用,但它终究是命令式的;如果逻辑本质上是「对每个元素做个变换」,那 items.map { ... } 更短也更难写错。buildList 的主场是真正需要条件分支和多来源合并的构建——比如上面那个「基础路由 + 开发模式路由 + 插件路由」,用函数式写法反而要拼三段。

「哪些东西是给外面用的」这个问题,在写库时决定了你以后能改什么、不能改什么;在写应用时决定了模块之间会不会长出意料之外的依赖。Kotlin 比 Java 多了一个 internal,而它在 JVM 上的实现方式有个必须知道的漏洞。

四个可见性修饰符

修饰符范围注意
public所有人Kotlin 的默认值(Java 的默认是包私有,这是个常见误判)
internal同一个模块内「模块」= 一次编译的单元(一个 Gradle 子项目 / 一个 Maven 模块),不是包
protected本类 + 子类不包括同包,比 Java 的 protected 窄
private本类内;顶层声明则是本文件内顶层 private 是「文件私有」,这是 Kotlin 特有的好东西

internal 的真相:Java 侧其实看得见

JVM 字节码里根本没有「模块」这个访问级别,所以 internal 只能靠改名来实现:编译器把成员名后面加上「$ + 模块名」,让别的模块的 Kotlin 代码找不到它。javap

class Service {
    internal fun internalMember(): String = "im"
    internal val internalProp: Int = 1
}
→ public final java.lang.String internalMember$main();
  public final int getInternalProp$main();

注意那个 public——它对 JVM 而言是完全公开的。Java 代码只要照着改编后的名字写,就能直接调用,s.internalMember$main() 返回 "im",一点障碍都没有。

而且改名不是全都改,下来是这样:

声明javap 里的样子
internal fun(类成员)internalMember$main()——改名
internal val(类成员)访问器 getInternalProp$main()——改名
internal fun(顶层)topLevelInternal()——不改名,Java 直接能调
internal classpublic final class demo.InternalClass——不改名,Java 直接能 new

所以 internal 是一个「对 Kotlin 编译器有效、对 JVM 无效」的约束。它足以防止团队内部误用,但不能当安全边界——真要防止被外部访问,靠的是不把这个 artifact 发出去,或者用 JPMS 的模块系统。

@PublishedApi 与显式 API 模式

  • @PublishedApi:public inline 函数的函数体会被复制到调用方,所以它里面用到的东西必须在调用方那里也可见。直接用 internal 的东西,报错:error: public-API inline function cannot access non-public-API function. 给那个 internal 声明标上 @PublishedApi 就能编过——含义是「它在字节码层面是公开的,但请把它当内部实现,我随时可能改」。
  • explicitApi():写库时在 build.gradle.kts 的 kotlin { } 块里加这一行(等价编译器参数 -Xexplicit-api=strict,还有个 warning 档),编译器就会强制你给每个公开声明写出可见性和返回类型。报错原文:
    error: visibility must be specified in explicit API mode.
    error: return type must be specified in explicit API mode.

为什么 public API 必须显式写返回类型?因为类型推断是根据实现推出来的——fun parse(s: String) = s.split(",") 推出 List<String>,哪天你把实现改成 s.split(",").toSet(),返回类型悄悄变成 Set<String>,所有调用方的二进制兼容性就断了,而你的源码 diff 里看不出任何 API 变化。显式写出返回类型,等于把这件事变成一个必须主动修改的声明——review 时一眼可见。

// ── internal 在 JVM 上靠改名实现 ──────────────────────
class Service {
    internal fun internalMember(): String = "im"
    internal val internalProp: Int = 1
}
internal class InternalClass { fun member() = "ic" }
internal fun topLevelInternal() = "tli"

// javap -p demo/Service.class(真实输出)
// public final java.lang.String internalMember$main();   ← 改名了,但还是 public
// public final int getInternalProp$main();
//
// javap demo/InternalClass.class
// public final class demo.InternalClass { ... }          ← 完全没改
//
// javap -p demo/VisKt.class
// public static final java.lang.String topLevelInternal();  ← 顶层的也没改

// Java 侧照着改编后的名字,确实叫得动(跑通):
// s.internalMember$main()        →  "im"
// new InternalClass().member()   →  "ic"
// VisKt.topLevelInternal()       →  "tli"

// ── @PublishedApi:public inline 要用 internal 就得标 ──
internal fun helper() = "h"
inline fun useIt() = helper()
// error: public-API inline function cannot access non-public-API function.

@PublishedApi internal fun helper2() = "h"
inline fun useIt2() = helper2()        // 这样就能编过

// ── 显式 API 模式(写库必开)─────────────────────────
// build.gradle.kts
// kotlin { explicitApi() }        // 等价 -Xexplicit-api=strict
// kotlin { explicitApiWarning() } // 迁移期先用 warning 档

class Widget(val name: String) { fun render() = "<" + name + ">" }
// error: visibility must be specified in explicit API mode.
// error: return type must be specified in explicit API mode.

// 改成:
public class Widget2(public val name: String) {
    public fun render(): String = "<" + name + ">"
}
别把 internal 当安全或隔离手段。三个具体后果:① Java 代码照着 $模块名 后缀就能调进来(跑通);② 反射完全不受影响,任何基于反射的框架都看得见 internal 成员;③ 改名本身还会制造兼容性问题——internal 成员的 JVM 名字里带着模块名,改一下 Gradle 子项目名,字节码签名就变了,已编译的下游会 NoSuchMethodError。所以 internal 的正确定位是「给同事的信号」而不是「给编译器的墙」:它表达「这是实现细节,别依赖」,不表达「你碰不到」。
写库的默认姿势:explicitApi() 打开,然后把「不确定要不要公开的」一律先写成 internal。把一个 internal 改成 public 永远是安全的(没人依赖它),反过来把 public 收回去就是破坏性变更。API 表面越小,你以后能改动的自由度越大——这是设计库时最重要的一条纪律,比任何具体的命名或结构选择都重要。配套工具是 binary-compatibility-validator:它把公开 API 的签名 dump 成一个文本文件提交进仓库,任何 API 变动都会体现为这个文件的 diff,review 时藏不住。

这里不是风格指南的罗列,只挑官方编码约定里真正影响可读性、而且有明确理由的几条讲——每条都回答「为什么」,而不是「规定如此」。

工厂函数可以用类名大写开头

Kotlin 的函数命名规则是小驼峰,但有一个官方认可的例外:返回某个类型的工厂函数,可以和那个类型同名。fun Money(amount: Long, currency: String): Money = MoneyImpl(...) 在调用点读起来和构造器一模一样。

为什么值得这么做?因为它把「怎么造出来」和「是什么」解耦了:调用方写的是 Money(100, "CNY"),你可以在背后返回缓存的实例、返回子类、做参数归一化,甚至哪天把 Money 从 class 改成 interface——调用方一行都不用改。真构造器做不到这些(constructor 必须返回这个类的新实例)。标准库自己大量这么干:listOf / mutableListOf / Regex(...) / Comparator { ... },你从来没有 new ArrayList() 过。

扩展函数放在哪

判据是「谁拥有这个语义」,而不是「被扩展的是谁」:

  • 领域相关的扩展(fun Order.totalPrice(): Money)放在领域类型旁边——它属于订单这个概念,只是恰好写成了扩展。
  • 对第三方/标准库类型的通用扩展(fun String.isEmail())单独开文件,按被扩展的类型命名(StringExt.kt)。别搞一个 Extensions.kt 收容一切——半年后那个文件会有八百行,谁也不敢删里面任何一个函数。
  • 扩展函数是静态分发的(08 章讲过):它不参与多态,父类型引用上调扩展函数,调到的是父类型那个版本。所以需要多态的行为不能写成扩展,这是个硬约束不是风格问题。

表达式体的边界在哪

fun area(r: Double) = PI * r * r 这种写法很省事,但它有个隐性代价:返回类型被推断出来,读代码的人得自己在脑子里算。几条实用界线:

  • 一眼能读完就用表达式体,需要中间变量、需要早退、超过两三行,就用块体。
  • 公开 API 一律显式写返回类型,理由本章前面讲过(推断出来的类型会随实现悄悄改变)。
  • when / if 作为表达式体是合适的——它们本身就是表达式,形状和意图一致。
  • 返回 Unit 的函数别写成表达式体:fun log(s: String) = println(s) 能编过,但它把「这是个副作用」伪装成了「这是个求值」。

什么时候该用命名参数

命名参数的价值全在调用点的可读性,所以判据也在调用点:

  • 布尔字面量必须命名。createUser("bob", true, false) ——这两个值是什么?读的人得跳到定义去看。createUser("bob", isAdmin = true, sendEmail = false) 自解释。这是最有价值的一条。
  • 连续多个同类型参数要命名。rect(0, 0, 100, 200) 到底是 (x, y, w, h) 还是 (left, top, right, bottom)?——顺便说,这种情况更好的解法是前面的 value class,让编译器来抓。
  • 跳过默认参数时必须命名(这是语法要求,不是风格)。
  • 反过来:参数少、类型各异、名字已经很清楚时,硬加命名参数只是噪音。max(a, b) 不需要写成 max(a = a, b = b)。

还有一条 API 设计上的连带影响:参数名是公开契约的一部分。只要有人用命名参数调你的函数,你改参数名就是破坏性变更——和改函数名一样严重,但很多人意识不到。

// ① 工厂函数用类名大写开头 —— 调用点读起来像构造器
interface Money { val amount: Long; val currency: String }

fun Money(amount: Long, currency: String): Money =
    if (amount == 0L) ZeroMoney else MoneyImpl(amount, currency)
// 调用点:Money(100, "CNY")  —— 实现随时可以换,调用方无感
// 标准库同款:listOf / mutableListOf / Regex(...) / Comparator { ... }

// ② 扩展函数:领域相关的放领域旁边,通用的单开文件
// Order.kt      → fun Order.totalPrice(): Money
// StringExt.kt  → fun String.isEmail(): Boolean
// 不要: Extensions.kt 收容一切

// ③ apply 用于配置(返回接收者本身)
val conn = Connection().apply {
    timeout = 5_000
    retries = 3
}

// ④ 表达式体的边界
fun area(r: Double) = PI * r * r                  // 好:一眼读完

fun grade(score: Int): String = when {              // 好:本来就是表达式
    score >= 90 -> "A"
    score >= 60 -> "B"
    else        -> "F"
}

fun log(s: String) = println(s)                   // 坏:Unit 函数伪装成求值
fun log2(s: String) { println(s) }                 // 好

// 公开 API 显式写返回类型 —— 否则改实现会悄悄改签名
public fun parse(s: String): List<String> = s.split(",")

// ⑤ 命名参数:布尔字面量和同类型连排
createUser("bob", true, false)                     // 这两个是什么?
createUser("bob", isAdmin = true, sendEmail = false)  // 自解释

rect(0, 0, 100, 200)                              // (x,y,w,h)?(l,t,r,b)?
rect(x = 0, y = 0, width = 100, height = 200)      // 或者干脆用 value class
参数名是公开契约,改名 = 破坏性变更。这条特别容易被忽略:函数签名的类型没变、行为没变、单元测试全绿,但只要有任何调用方写了 f(oldName = x),你把参数改成 newName 他就编译不过。data class 尤其危险——它的 copy() 几乎总是用命名参数调的(u.copy(name = "new")),改一个属性名会同时打断构造器、copy 和解构(component1() 的顺序)三处。所以给 data class 的属性起名要一次到位,和给函数起名一样慎重。
「有几个参数才该考虑 Builder / 参数对象」——Kotlin 的答案比 Java 大得多。因为有默认参数 + 命名参数,一个八参数的构造器在 Kotlin 里调起来完全可读(Config(host = "x", port = 8080)),根本不需要 Builder 模式。Java 里那套 new Builder().setA().setB().build() 在 Kotlin 里几乎总是多余的——除非这个 API 要给 Java 用,那时 12 章讲的默认参数不可见问题就来了,Builder 反而成了最省事的方案。

DSL 构建

本章覆盖:带接收者的 lambda T.() -> Unit(所有 Kotlin DSL 的发动机)、搭一个 DSL 需要的六个部件、@DslMarker 防止作用域串味,以及什么时候不该写 DSL。

Kotlin 的 DSL 全部建立在一个语法之上:带接收者的函数类型 T.() -> Unit——在 lambda 里 this 就是 T,于是能直接写 T 的成员,看起来就像另一门语言。

发动机:带接收者的 lambda

写法含义
(T) -> Unit普通 lambda,参数叫 it
T.() -> Unit带接收者:lambda 里 this 是 T,成员可以直接写
标准库的例子apply 的签名就是 T.(T.() -> Unit): T;buildString 是 StringBuilder.() -> Unit
怎么调fun html(block: Html.() -> Unit): Html = Html().apply(block)
嵌套函数里再接一个带接收者的 lambda,就形成层级
与扩展函数的关系同一个机制:扩展函数就是第一个参数是接收者的函数

搭一个 DSL 要哪几样

部件作用例子
① 入口函数创建根对象并执行 blockfun html(block: HtmlTag.() -> Unit) = HtmlTag().apply(block)
② 节点类每层一个类,持有子节点class Body { val children = mutableListOf<Node>() }
③ 子节点函数在节点类里定义,创建子节点、执行它的 block、挂到 childrenfun p(block: P.() -> Unit) { children += P().apply(block) }
④ 属性设置普通属性赋值即可class A { var href: String = "" }
⑤ 输出递归 toString / 渲染—
⑥ @DslMarker防止作用域串味:不加的话内层 lambda 里能直接调外层的函数,写错不报错见下
可选operator fun String.unaryPlus() 让 +"文本" 能加纯文本—

@DslMarker

要点说明
问题嵌套 lambda 里,外层接收者的成员依然可见——在 tr { } 里能直接写 table { },结构错了却编译通过
解法定义 @DslMarker annotation class HtmlDsl,给所有节点类标上 @HtmlDsl
效果同一个标记下,只有最近的那个接收者可见,写错当场编译错
什么时候必须加只要 DSL 有两层以上嵌套就该加

什么时候不该写 DSL

情况说明
只用一两次DSL 的成本(一堆节点类 + 标记注解)要靠反复使用摊薄
使用者是外部用户他们要先学你的 DSL;普通函数 + 数据类的学习成本是零
结构会频繁变DSL 改一次要动入口、节点类、渲染三处
只是想少打几个字具名参数 + 默认值 + apply 通常就够了
该用的时候结构天然是树、要写很多遍、结构相对稳定:构建 HTML / 配置 / 测试数据 / Gradle 脚本本身
现成的例子buildString · buildList · Gradle 的 .kts · Compose 的 @Composable 树 · kotlinx.html · Ktor 路由
// ① 一个能跑的最小 DSL:三个部件
@DslMarker
annotation class HtmlDsl                     // ⑥ 防串味

@HtmlDsl
class Tag(private val name: String) {
    private val children = mutableListOf<Any>()
    var cls: String? = null                      // ④ 属性

    fun tag(n: String, block: Tag.() -> Unit) {    // ③ 子节点
        children += Tag(n).apply(block)
    }
    operator fun String.unaryPlus() { children += this }   // +"文本"

    override fun toString(): String {
        val attr = cls?.let { " class=\"$it\"" } ?: ""
        return "<$name$attr>${children.joinToString("")}</$name>"
    }
}

fun html(block: Tag.() -> Unit) = Tag("html").apply(block)   // ① 入口

// 用起来就像另一门语言
val page = html {
    tag("body") {
        cls = "main"
        tag("p") { +"你好" }
    }
}

// ② @DslMarker 挡住的错误写法
html {
    tag("body") {
        // cls = "x"      ← 这是 body 的(正确)
        // 不加 @DslMarker 时,这里还能直接调外层 html 的成员,结构错了也编译通过
    }
}

// ③ 标准库里你早就在用的 DSL
val s = buildString { append("a"); appendLine("b") }   // StringBuilder.() -> Unit
val l = buildList { add(1); addAll(other) }
不加 @DslMarker 的多层 DSL 会静默接受错误结构:内层 lambda 里外层接收者的成员依然可见,把 tr 写进了 tbody 之外也编译通过,问题要到渲染出来才发现。
DSL 的调试体验比普通代码差:栈里全是 lambda,报错位置往往指向入口函数。写复杂逻辑时,把它拆成普通函数再在 DSL 里调用。
写 DSL 之前先问一句:「这个结构我会写很多遍吗」。答案是否,就用具名参数 + apply——同样清楚,成本为零。
真要写,@DslMarker 从第一天就加上:等到别人已经在用了再加,会让一批原本能编译的代码报错。

Kotlin 的 DSL 没有任何魔法,全部来自一个类型:T.() -> Unit。它和普通的 (T) -> Unit 只差一件事——lambda 体内的 this 是谁。

两种函数类型的唯一区别

类型lambda 里怎么写怎么调用它
(Menu) -> Unit{ m -> m.item("茶") },必须带前缀block(m)
Menu.() -> Unit{ item("茶") },this 就是 Menum.block(),像调成员函数

正因为第二种把接收者变成了隐式的 this,嵌套一层就得到 head { title("…") } 这种读起来像标记语言的写法。DSL 的全部秘密就在这里,剩下的都是收尾工作。

标准库里早就在用了

  • apply 的签名就是 fun <T> T.apply(block: T.() -> Unit): T——你每次写 obj.apply { … },用的就是这个机制(08 章讲过作用域函数的选择)。
  • buildString { append("x="); append(1) } 里的接收者是 StringBuilder;buildList/buildMap 同理。它们就是标准库自带的小 DSL。

输出

右侧代码原样编译运行(Kotlin 2.4.10),输出为:

  • A 咖啡, 茶 B 咖啡, 茶——两种写法结果完全一样,差别只在书写;
  • C 可乐——apply 走的是同一条路;D x=1——buildString 同理;
  • E this is Menu——在 lambda 里打印 this::class.java.name,确实是 Menu。
// 一个十几行、真能跑的最小 DSL
class Menu {
    private val items = mutableListOf<String>()
    fun item(name: String) { items.add(name) }
    override fun toString() = items.joinToString(", ")
}

fun menu(block: Menu.() -> Unit): Menu {
    val m = Menu()
    m.block()          // 把 m 当接收者调用
    return m
}

fun menu2(block: (Menu) -> Unit): Menu {   // 对照:普通参数版
    val m = Menu()
    block(m)
    return m
}

fun main() {
    val a = menu {
        item("咖啡")    // this 就是 Menu,直接写
        item("茶")
    }
    println("A $a")                // A 咖啡, 茶

    val b = menu2 { m ->
        m.item("咖啡")  // 必须写 m.
        m.item("茶")
    }
    println("B $b")                // B 咖啡, 茶

    // apply / buildString 是标准库里的同一机制
    println("C " + Menu().apply { item("可乐") })   // C 可乐
    println("D " + buildString { append("x="); append(1) })  // D x=1

    menu { println("E this is " + this::class.java.name) } // E this is Menu
}
接收者 lambda 里 this 被换掉了,外层的 this 就被遮住。在类的成员函数里写 menu { … },块内的 this 是 Menu 而不是那个类——要访问外层实例得写限定形式 this@MyClass。这也是下一步 @DslMarker 要解决的问题的另一面。
接收者 lambda 常配 inline:DSL 的 builder 函数标 inline 后 lambda 会被内联进调用处,既省掉每次构建的函数对象分配,也允许 lambda 里写 return。标准库的 apply/buildString 都是 inline 的(代价见 16 章)。

把本章前面那套机制叠两层,就是「类型安全构建器」:每种标签一个类,每个类只暴露它合法的子标签,于是写错结构在编译期就被拦住。

三个零件

  • 基类 Tag 持有子节点与属性,并提供受保护的 add(child, block):先用 child 当接收者跑 block,再把 child 挂到自己名下——所有嵌套都走这一个函数。
  • 子类限定词汇表:Html 只有 head/body,Head 只有 title,Body 才有 h1/p/div。head { h1("…") } 直接编译不过——这就是「类型安全」三个字的含义。
  • 入口函数 fun html(block: Html.() -> Unit): Html = Html().apply(block),一行。

输出(Kotlin 2.4.10 真跑)

右侧程序原样运行,标准输出是:

行输出
1–2<html> /   <head>
3–5    <title> /       我的页面 /     </title>
7–  <body> 下依次是 <h1>、<p>,然后是带属性的 <div class="box">,其内再嵌一层 <p>

缩进由 render(sb, indent) 递归时把 indent 加两个空格产生,不需要任何额外状态。

为什么 add 要泛型

protected fun <T : Tag> add(child: T, block: T.() -> Unit): T 里的 T 让 block 的接收者精确到具体子类:传 Head() 进去,block 里就只能调 Head 的方法。写成 Tag 就退化成「什么标签都能往哪儿塞」,类型安全立刻丢失(09 章讲过这类型参数传播)。

open class Tag(private val name: String) {
    private val children = mutableListOf<Any>()
    val attrs = mutableMapOf<String, String>()

    protected fun <T : Tag> add(child: T, block: T.() -> Unit): T {
        child.block(); children.add(child); return child
    }
    fun text(s: String) { children.add(s) }

    fun render(sb: StringBuilder, indent: String) {
        val q = '"'
        val a = attrs.entries.joinToString("") { " " + it.key + "=" + q + it.value + q }
        sb.appendLine("$indent<$name$a>")
        for (c in children) when (c) {
            is Tag -> c.render(sb, "$indent  ")
            else   -> sb.appendLine("$indent  $c")
        }
        sb.appendLine("$indent</$name>")
    }
    override fun toString() = StringBuilder().also { render(it, "") }.toString()
}

// 每个类只暴露它合法的子标签 —— 这就是「类型安全」
class Html : Tag("html") {
    fun head(block: Head.() -> Unit) = add(Head(), block)
    fun body(block: Body.() -> Unit) = add(Body("body"), block)
}
class Head : Tag("head") {
    fun title(s: String) = add(Tag("title")) { text(s) }
}
open class Body(name: String) : Tag(name) {
    fun h1(s: String) = add(Tag("h1")) { text(s) }
    fun p(s: String)  = add(Tag("p"))  { text(s) }
    fun div(block: Div.() -> Unit) = add(Div(), block)
}
class Div : Body("div")

fun html(block: Html.() -> Unit): Html = Html().apply(block)

fun main() {
    print(html {
        head { title("我的页面") }
        body {
            h1("Hello DSL")
            p("类型安全构建器")
            div {
                attrs["class"] = "box"
                p("嵌套内容")
            }
        }
    })
}
/* 输出:
<html>
  <head>
    <title>
      我的页面
    </title>
  </head>
  <body>
    <h1>
      Hello DSL
    </h1>
    <p>
      类型安全构建器
    </p>
    <div class="box">
      <p>
        嵌套内容
      </p>
    </div>
  </body>
</html>                                    */
add 里的顺序不能反:必须先跑 child.block() 再把 child 加进 children?其实两种顺序都能出正确结果,真正会出错的是忘了 children.add(child)——DSL 照常编译、照常运行、一个错都不报,只是渲染出来少了一整棵子树。构建器类的 bug 大多是这种「静默丢内容」,写完第一件事是跑一遍看输出,别只看编译过没过。
真实项目别自己造:kotlinx.html 就是这套结构的完整实现(含全部 HTML 标签与转义),Ktor 服务端可以直接 call.respondHtml { … }。自己写一遍的价值在于——之后看任何 Kotlin DSL 的源码,你都知道它在干什么。

嵌套 DSL 有个隐蔽 bug:内层 lambda 里,外层接收者的方法照样能调。因为两个接收者都在作用域里,编译器按最近的找,找不到就往外找——于是手滑写错一层,代码照常编译、照常运行,只是内容跑到了错误的地方。

不加注解时的 bug(真跑出来的)

下面 Outer 有 outerOnly,Inner 有 innerOnly。在 inner { … } 内部误写了 outerOnly("oops"):

  • 编译通过,运行也不报错;
  • 输出是 LOG [OUTER:a, INNER:b, OUTER:oops]——那条本该属于内层的内容,被隐式接收者悄悄记到了外层账上。

放到 HTML DSL 里就是:在 body { p { … } } 的 p 里写了 h1("…"),标题跑到了段落外面,页面结构错了但没人报错。

加上 @DslMarker 之后

自定义一个注解并标上 @DslMarker,再把它打在所有 DSL 类上。规则是:同一个 marker 标记的多个隐式接收者,只有最内层的那个可用。同样的代码在 Kotlin 2.4.10 下编译直接失败,原文是:

  • error: 'fun outerOnly(s: String): Unit' cannot be called in this context with an implicit receiver. Use an explicit receiver if necessary.

报错还提示了逃生门:确实要访问外层,就写显式接收者——this@build.outerOnly("…")(用标签限定 this)。DslMarker 挡的是「隐式」,不是「不许访问」。

用法要点

  • marker 注解自身要能标在类上,惯例写 @DslMarker annotation class MyDsl;
  • 打在每一个构建器类上(漏一个,那个类就还会串味);也可以打在它们共同的基类上由子类继承;
  • marker 是按注解身份区分的:两套不相干的 DSL 各用各的 marker,混用时互不干扰。
// ① 不加 marker:内层能调到外层的方法,编译运行都不报错
class Outer {
    val log = mutableListOf<String>()
    fun outerOnly(s: String) { log.add("OUTER:$s") }
    fun inner(block: Inner.() -> Unit) { Inner(this).block() }
}
class Inner(val parent: Outer) {
    fun innerOnly(s: String) { parent.log.add("INNER:$s") }
}
fun build(block: Outer.() -> Unit): Outer = Outer().apply(block)

build {
    outerOnly("a")
    inner {
        innerOnly("b")
        outerOnly("oops")   // 手滑写错层 —— 没人拦你
    }
}
// 输出: LOG [OUTER:a, INNER:b, OUTER:oops]

// ② 加 marker:同样的代码,编译期就被拦下
@DslMarker
annotation class MyDsl

@MyDsl class Outer { /* 同上 */ }
@MyDsl class Inner(val parent: Outer) { /* 同上 */ }

/* kotlinc 2.4.10 报错原文:
   error: 'fun outerOnly(s: String): Unit' cannot be called in this
   context with an implicit receiver. Use an explicit receiver if necessary.
           outerOnly("oops")
           ^^^^^^^^^                                                  */

// ③ 确实要访问外层:用带标签的 this 显式限定
build {
    inner { this@build.outerOnly("故意的") }   // OK
}
@DslMarker 只管隐式接收者,不管作用域里的普通变量和顶层函数:内层 lambda 里照样能调到外层的局部变量和同名的顶层函数,它拦不住。另外注解漏标是最常见的失效原因——加了注解却发现还能串味时,先检查是不是有个构建器类忘了标。
这是 DSL 里最重要也最少被讲的一环——写构建器时把 @DslMarker 当成必做项,成本是三行代码,换来的是一整类「静默写错结构」的 bug 由编译器代劳。kotlinx.html、Ktor、Gradle Kotlin DSL 全都这么做。

DSL 让调用方好看,代价全落在别处。写之前先问一句:这个 API 会被写多少次?如果答案是「几次」,那就别写。

三笔要算清的账

  • IDE 补全变差:光标在 { } 里时,补全列表是当前接收者的全部成员——层数一多,用户很难从列表看出「这一层到底能写什么」,而普通函数的参数列表是直接显示的。
  • 报错难懂:DSL 出错时编译器说的是构建器类型和 lambda 推断的话,不是领域的话。本章前面那句 cannot be called in this context with an implicit receiver 已经算友好的了。
  • 新人上手成本:DSL 是一门你自己发明的小语言,没有文档就没人会用,而且它的规则不能靠读函数签名推出来。

判据

情形建议理由
配置类(大量可选项、有默认值)值得命名参数一多就难读,DSL 分组更清楚
树状/嵌套结构(HTML、UI、路由表)值得嵌套是 DSL 唯一无可替代的强项
领域概念稳定、会被写很多次值得一次投入摊到成千上万个调用点
一次性逻辑、只在一两处用别写普通函数就够,DSL 的学习成本收不回
参数少(三五个)且都必填别写命名参数 + data class 更清晰、补全更好
只是想「看起来高级」别写这是最常见的真实动机,也是最差的理由

先试试更便宜的方案

Kotlin 里 DSL 的最大竞争对手是命名参数 + 默认值 + data class:Config(host = "localhost", port = 8080, retries = 3) 一样自解释,还免费获得 IDE 参数提示、copy()、解构和相等性。只有当嵌套层级出现、或可选项多到一屏放不下时,DSL 才真正开始占优。

真实案例:都符合上面的判据

  • Gradle Kotlin DSL(build.gradle.kts):配置 + 嵌套 + 领域极稳定,还借 DSL 拿到了 Groovy 版没有的类型检查与补全(15 章细讲)。
  • kotlinx.html:HTML 本身就是树,语言结构与领域结构一一对应。
  • Ktor 的路由与安装块:routing { get("/") { … } },同样是稳定领域上的嵌套配置。
  • Jetpack Compose 的 UI 描述也建立在接收者 lambda 之上(本页以纯语言与 JVM 为主线,Compose 只在此作为例子提及,不展开)。
// 场景:一个有 3 个参数的配置 —— 别写 DSL
data class Retry(
    val times: Int = 3,
    val delayMs: Long = 500,
    val backoff: Double = 2.0,
)
val r = Retry(times = 5, delayMs = 200)
// 自解释、IDE 有参数提示、白送 copy()/解构/相等性 —— DSL 一样都没有

// 同样的东西写成 DSL:多了 30 行实现、少了参数提示、还得写文档
class RetryBuilder {
    var times = 3
    var delayMs = 500L
    var backoff = 2.0
    fun build() = Retry(times, delayMs, backoff)
}
fun retry(block: RetryBuilder.() -> Unit) = RetryBuilder().apply(block).build()
val r2 = retry { times = 5; delayMs = 200 }   // 收益≈0

// 值得写 DSL 的形状:嵌套 + 可选项多 + 领域稳定
server {
    port(8080)
    routing {
        get("/health") { ok() }
        route("/api") {
            get("/users") { listUsers() }   // 嵌套才是 DSL 无可替代的地方
        }
    }
}

// 折中:核心是普通函数和 data class,只在最外层包一层薄 DSL
// —— 内部逻辑好测好复用,出问题也容易分清是 DSL 层还是逻辑层
别把校验写进 DSL 的调用顺序里。构建器天然允许用户以任意顺序、任意次数调用那些方法(title("a"); title("b") 会怎样?漏调 body 又会怎样?)——这些在类型系统里表达不出来,必须在 build() 那一步集中检查并抛出可读的异常(require 抛 IllegalArgumentException,见 13 章)。很多自制 DSL 的坑就是「少写一句,静默出空结果」。
折中方案常常最好:核心用普通函数和 data class,只在最外层包一层薄薄的 DSL。这样内部逻辑好测试、好复用,DSL 只负责组装——出问题时也容易判断是 DSL 层还是逻辑层的锅。

测试与工具链

本章覆盖:kotlin.test 与断言、协程的虚拟时间测试(runTest 让 delay(10_000) 瞬间完成)、build.gradle.kts 常用配置块与版本目录、序列化与网络库选型、ktlint / detekt / Kover 把关。

语言写得对不等于工程立得住。这一章四张表:怎么写测试 → 协程怎么测 → Gradle 怎么配 → 用什么自动把关。

测试框架与断言

要什么怎么写
依赖testImplementation(kotlin("test"))——kotlin.test 会自动桥接到 JUnit
一个测试@Test fun \`余额不足时抛异常\`() { … }——函数名可以用反引号写中文
断言assertEquals(expected, actual)(期望值在前)· assertTrue · assertNull · assertContains · assertFailsWith<T> { }
前后置@BeforeTest / @AfterTest
跳过@Ignore("原因")
参数化JUnit 5 的 @ParameterizedTest + @ValueSource;或者自己 listOf(...).forEach { }
更好读的断言库Kotest assertions(x shouldBe 2)· AssertJ · Strikt
MockMockK(Kotlin 原生,能 mock final 类与挂起函数)· Mockito-Kotlin
更推荐手写假实现:接口小的时候几行就够,行为明确、重构时编译器会提醒
跑./gradlew test · --tests "*OrderTest";报告在 build/reports/tests/test/index.html

协程测试:虚拟时间

要什么怎么写
依赖testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:…")
测挂起函数@Test fun x() = runTest { … }——不要用 runBlocking
虚拟时间runTest 里的 delay(10_000) 瞬间完成——测超时/重试/防抖不用真等
手动推进advanceTimeBy(1000) · advanceUntilIdle() · runCurrent()
控制调度器val d = StandardTestDispatcher(testScheduler);被测代码要能注入调度器,别硬编码 Dispatchers.IO
替换 MainDispatchers.setMain(dispatcher) / resetMain()
测 Flowflow.toList() 收完;复杂场景用 Turbine(flow.test { assertEquals(1, awaitItem()) })
常见失败「测试挂住不返回」——多半是收了一个永不结束的热流,或者用了真实调度器

Gradle:build.gradle.kts 常用块

要做什么怎么写
插件plugins { kotlin("jvm") version "2.4.10"; application }
序列化插件kotlin("plugin.serialization") version "2.4.10"——版本必须与 Kotlin 一致
依赖implementation(…)(自己用)· api(…)(会传递给下游,只给真正对外暴露的类型用)· testImplementation · compileOnly · runtimeOnly
JVM 版本kotlin { jvmToolchain(21) }——比 sourceCompatibility 可靠
编译器参数kotlin { compilerOptions { freeCompilerArgs.add("-Xjsr305=strict") } }
测试框架tasks.test { useJUnitPlatform() }
版本目录gradle/libs.versions.toml → implementation(libs.ktor.client)——多模块必备
多模块settings.gradle.kts 里 include(":core", ":app");模块间 implementation(project(":core"))
加速gradle.properties:org.gradle.caching=true · org.gradle.parallel=true · org.gradle.jvmargs=-Xmx4g
常用库序列化 kotlinx-serialization · HTTP Ktor client / OkHttp+Retrofit · 日志 kotlin-logging · 时间 kotlinx-datetime · DI Koin / Dagger

静态检查与代码质量

工具管什么
编译器警告最便宜的一层;allWarningsAsErrors = true 让它们不被无视
ktlint格式:缩进、导入顺序、空行——格式不该靠人争论
detekt代码味道:函数太长、复杂度太高、!! 的使用、空 catch
Android Lint平台相关问题
覆盖率Kover(Kotlin 官方)或 JaCoCo
CI 卡门./gradlew ktlintCheck detekt test——三条一起跑
显式 API写库时 kotlin { explicitApi() }(13 章)
// ① 一个普通测试:函数名可以用反引号写中文
class AccountTest {
    @Test
    fun `余额不足时抛 IllegalArgumentException`() {
        val acc = Account(100)
        val e = assertFailsWith<IllegalArgumentException> { acc.withdraw(200) }
        assertContains(e.message.orEmpty(), "余额不足")
    }
}

// ② 协程测试:虚拟时间,10 秒瞬间过去
@Test
fun `超时会重试三次`() = runTest {
    val api = FlakyApi(failTimes = 2)
    val r = retryWithBackoff(times = 3) { api.load() }   // 里面有 delay(1000) 之类
    assertEquals("ok", r)
    assertEquals(3, api.calls)
}                                             // 实际耗时几毫秒

// ③ 让代码可测:调度器要能注入,别硬编码
class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun load() = withContext(io) { /* … */ }
}
// 测试里:Repo(StandardTestDispatcher(testScheduler))

// ④ 测 Flow:Turbine 让断言按事件走
@Test
fun `状态先 Loading 后 Ok`() = runTest {
    vm.state.test {
        assertEquals(UiState.Loading, awaitItem())
        assertIs<UiState.Ok>(awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}

# ⑤ CI 一条命令把三件事都卡住
$ ./gradlew ktlintCheck detekt test
被测代码里硬编码 Dispatchers.IO,测试就没法控制时间——runTest 的虚拟时间只对注入进来的测试调度器生效。写业务代码时把调度器作为构造参数传进去(默认值仍写 Dispatchers.IO),是让协程可测的关键一步。
Gradle 的 api 与 implementation 别混用:api 会把依赖传递给下游模块,一旦滥用,整个项目的编译依赖图会膨胀,构建变慢而且升级牵一发动全身。默认用 implementation。
Kotlin 的测试函数名用反引号写成一句中文,报告里直接能读——这是这门语言在测试上最实惠的一个小特性。
协程测试一律 runTest 而不是 runBlocking:前者给你虚拟时间,重试与超时逻辑可以在毫秒内测完。

Kotlin 的测试栈有两层:kotlin.test 提供一套平台无关的断言与注解,JUnit 5 在 JVM 上真正执行它们。搞清这层关系,你就知道该 import 哪个。

kotlin.test 到底是什么

它是标准库配套的门面:你写 kotlin.test.Test 和 assertEquals,构建时选一个引擎实现(JVM 上通常是 JUnit 5),注解和断言被映射到该引擎上。好处是同一份测试代码在 JVM/JS/Native 都能跑(16 章的 KMP 就靠这个)。纯 JVM 项目直接用 JUnit 5 的注解也完全可以——只是跨平台时要改。

需求写法
相等断言assertEquals(expected, actual)(期望值在前,写反了报错信息会误导)
真/假/空assertTrue / assertFalse / assertNull / assertNotNull
断言抛异常assertFailsWith<IllegalArgumentException> { … },返回异常实例可继续断言 message
生命周期@BeforeTest / @AfterTest(JUnit 5 对应 @BeforeEach / @AfterEach)

参数化测试

同一逻辑喂多组数据,别复制粘贴多个测试函数。JUnit 5 用 @ParameterizedTest + @ValueSource / @CsvSource / @MethodSource;纯 Kotlin 的轻量替代是在一个测试里遍历一张表——listOf(1 to 1, 2 to 4).forEach { (input, expected) -> … },配合反引号函数名已经足够可读。

MockK 的定位

  • MockK 是为 Kotlin 写的 mock 框架:every { … } returns … 打桩、verify { … } 校验、coEvery / coVerify 直接支持 suspend 函数——这是它相对 Mockito 最实在的优势。
  • Kotlin 的类默认 final,mock 框架需要额外手段才能代理;MockK 内建处理,Mockito 则要 inline mock maker 或 all-open 插件配合。
  • 但先问要不要 mock:能用手写的假实现(fake)就别上 mock 框架。接口一小,class FakeRepo : Repo { … } 比一串打桩更好读、重构时也不会静默失效。
import kotlin.test.*

class CalculatorTest {
    @BeforeTest
    fun setup() { /* 每个测试前跑一次 */ }

    @Test
    fun `两数相加返回和`() {
        assertEquals(4, add(2, 2))     // (期望, 实际) —— 别写反
    }

    @Test
    fun `负数参数抛 IllegalArgumentException`() {
        val e = assertFailsWith<IllegalArgumentException> {
            sqrtOf(-1)                    // 内部用 require(x >= 0) { ... }
        }
        assertTrue(e.message!!.contains("负"))
    }

    @Test
    fun `多组数据走同一条逻辑`() {         // 轻量参数化:遍历一张表
        listOf(0 to 0, 2 to 4, -3 to 9).forEach { (input, expected) ->
            assertEquals(expected, square(input), "输入 $input")
        }
    }
}

// JUnit 5 原生参数化(需要 junit-jupiter-params)
@ParameterizedTest
@ValueSource(ints = [1, 2, 3])
fun `正数都合法`(n: Int) = assertTrue(isValid(n))

// MockK:suspend 函数用 coEvery / coVerify
val repo = mockk<UserRepository>()
every   { repo.findById(1) } returns User("Tom")
coEvery { repo.fetch(1) }    returns User("Tom")   // suspend
verify  { repo.findById(1) }

// 常常更好的选择:手写 fake,没有打桩、重构时编译器会提醒你
class FakeUserRepository(private val data: Map<Int, User>) : UserRepository {
    override fun findById(id: Int) = data[id]
}
两个高频坑:① assertEquals 的参数顺序是(期望,实际),写反了不影响通过与否,但失败信息会把「期望」和「实际」说反,能让人查错方向。② 用 try { … ; fail() } catch (e: Exception) { } 手写异常断言,会顺带吞掉 fail() 自己抛的断言异常,测试永远通过——一律改用 assertFailsWith。
测试函数名用反引号包裹的自然语言——fun `空列表求和返回 0`() { … }。测试是给人读的报告,报错列表里一眼能看懂失败的是哪条业务规则。(这一招只在测试源集里用,生产代码里的反引号名字会给 Java 调用方添麻烦,见 12 章。)

测协程最反直觉的一点:delay(1000) 在测试里瞬间就过去了。因为 runTest 用的是虚拟时间——它不真睡,只把时钟往前拨。

虚拟时间是怎么回事

runTest { … } 内部跑在 TestDispatcher 上,它带一个 TestCoroutineScheduler:所有 delay 不去真的挂起线程,而是把「在虚拟时刻 T 恢复」登记到调度器里。当没有可立即执行的任务时,调度器直接把虚拟时钟跳到下一个登记时刻。于是「等 1 秒后重试三次」的逻辑,测起来不用真等 3 秒,而且时序完全确定——不会因为机器忙就随机失败。

API作用什么时候用
runTest { }建立带虚拟时间的测试作用域,并等待其中协程完成所有协程测试的入口
advanceUntilIdle()一直推进到没有待执行任务「跑完再断言」,最常用
advanceTimeBy(n)只推进 n 毫秒虚拟时间要断言「中途某一刻的状态」,如超时/防抖
runCurrent()执行当前已就绪的任务,不推进时钟区分「已排队」和「已到时间」
currentTime读当前虚拟时间断言「确实等了 1000ms」而不真等

被测代码里写死调度器,测试就废了

如果生产代码里直接写 withContext(Dispatchers.IO) { … },那段逻辑跑的是真线程池、真时间,runTest 的虚拟时钟管不到它,测试要么真的等、要么时序随机。解法是把调度器变成依赖注进去(构造参数默认 Dispatchers.Default,测试传 StandardTestDispatcher(testScheduler))。这是协程可测性的第一原则,比任何测试 API 都重要。

两种 TestDispatcher

  • StandardTestDispatcher(runTest 默认):新协程排队,不抢占当前协程——需要你显式 advanceUntilIdle() 才推进。时序清楚,推荐。
  • UnconfinedTestDispatcher:新协程立刻开始执行直到第一个挂起点,省掉手动推进,写起来省事但时序不直观。老代码里常见。

测 Flow:Turbine

Flow 是流,不是一个值,断言要按「第几项」来。Turbine 提供 flow.test { awaitItem(); awaitComplete() }:awaitItem() 取下一项、awaitComplete() 断言正常结束、awaitError() 断言异常,块结束时还会检查有没有没被消费的项——这一条能抓出「多发了一次」的 bug,手写 toList() 是抓不到的。对无限流(StateFlow)尤其必要,因为 toList() 根本不会返回(11 章讲过冷热流的区别)。

// 时间推进 API 目前仍是实验性的,不加这行、又开了 allWarningsAsErrors 就会构建失败
@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)

import kotlinx.coroutines.test.*
import kotlin.test.*

// 生产代码:把调度器作为依赖注入,否则没法测
class Loader(
    private val repo: Repo,
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
    suspend fun loadWithRetry(): List<Item> = withContext(dispatcher) {
        repeat(2) {
            try { return@withContext repo.fetch() } catch (e: Exception) {}
            // 协程里别用 runCatching 包挂起调用,它会吞掉取消,理由见 10 章
            delay(1000)              // 测试里不会真的等 1 秒
        }
        repo.fetch()
    }
}

@Test
fun `失败后重试并最终成功`() = runTest {
    val loader = Loader(flakyRepo, StandardTestDispatcher(testScheduler))

    val job = async { loader.loadWithRetry() }
    advanceUntilIdle()                 // 推进虚拟时钟直到没活可干

    assertEquals(3, job.await().size)
    assertEquals(1000, currentTime)      // 断言「确实等了 1s」,零耗时
}

@Test
fun `超时前不该出结果`() = runTest {
    val vm = Counter(StandardTestDispatcher(testScheduler))
    vm.startDebounced()
    advanceTimeBy(499); runCurrent()
    assertNull(vm.result.value)         // 还没到 500ms
    advanceTimeBy(1); runCurrent()
    assertNotNull(vm.result.value)
}

// Turbine:按项断言,块结束时还会检查有没有多余的项
@Test
fun `计数流按序发出`() = runTest {
    counter.state.test {
        assertEquals(0, awaitItem())
        counter.increment()
        assertEquals(1, awaitItem())
        cancelAndIgnoreRemainingEvents()   // StateFlow 是无限流
    }
}
在 runTest 里用 GlobalScope.launch 或另建一个 CoroutineScope 起协程,它不在测试作用域里:虚拟时间推不动它,runTest 也不会等它,测试常常在协程还没跑完时就结束并「通过」。协程必须起在 runTest 给你的作用域内(或用注入的 TestDispatcher),这也是 10 章结构化并发那条规矩在测试里的直接体现。
断言「确实等够了时间」不用真等:在 runTest 里读 currentTime——跑完带 delay(1000) 的重试逻辑后 assertEquals(1000, currentTime),既验证了行为又零耗时。这是虚拟时间最漂亮的用法。

这是本页的隐藏门槛:本页后面凡是 import kotlinx.* 的代码,用 kotlinc hello.kt 一律编译不过,报的就是 01 章那条 unresolved reference 'kotlinx'.。协程、序列化这些「Kotlin 招牌特性」都不在标准库里,是需要单独声明依赖的独立库。所以在读到 10 章之前,先把项目搭起来。

什么时候该从单文件毕业

  • 标准库里有什么:listOf、String、println、集合与序列(07 章)、作用域函数——这些 kotlinc 直接就能编;
  • 标准库里没有什么:kotlinx.coroutines(协程,10 与 11 章)、kotlinx.serialization(@Serializable)、kotlinx.datetime、以及所有测试框架(15 章)。名字里带 kotlinx 的一律是需要加依赖的独立库——kotlin 是语言,kotlinx 是官方扩展库,这个命名区分很好用;
  • 另外三个信号:代码超过一个文件、需要跑测试、需要打包发布。任一出现就该上 Gradle。

三步:建项目、加依赖、跑起来

  • ① 建:IDE 里 New Project → Kotlin,构建系统选 Gradle + Kotlin DSL,最省事;命令行则是空目录里 gradle init,按提示选 application / Kotlin / Kotlin DSL。产物结构是 src/main/kotlin/ 放源码、build.gradle.kts 放配置、gradlew 是自带的构建入口;
  • ② 加依赖:往 build.gradle.kts 的 dependencies { } 里加一行 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"),IDE 会提示「Load Gradle Changes」,点它(或跑一次构建)就会自动下载;
  • ③ 跑:./gradlew run(Windows 上 gradlew.bat run)。注意用项目自带的 gradlew 而不是全局的 gradle——前者会自动下载并锁定项目声明的那个 Gradle 版本,保证换台机器结果一致,也省掉单独安装 Gradle;
  • 版本基线:Kotlin 插件 kotlin("jvm") version "2.4.10"、coroutines 1.10.2、serialization-json 1.9.0。

本页后续的约定

本页后面凡是 import 了 kotlinx.* 的代码,都默认你已经加好了对应依赖。看到 runBlocking / launch / Flow 就是 coroutines,看到 @Serializable / Json.encodeToString 就是 serialization(后者还需要额外的 kotlin("plugin.serialization") 编译器插件,光加依赖不够)。这些片段大多省略了 fun main 外壳,想动手跑就塞进 fun main() { ... };用到 suspend 的则塞进 fun main() = runBlocking { ... }。

// build.gradle.kts —— 一个够用的最小 JVM 项目
plugins {
    kotlin("jvm") version "2.4.10"
    application              // 没有它就没有 ./gradlew run 这个任务
}

repositories { mavenCentral() }   // 去哪里下依赖

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    // 需要 @Serializable 时再加(另需 plugins 里的 plugin.serialization):
    // implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
    testImplementation(kotlin("test"))
}

application {
    mainClass.set("MainKt")   // src/main/kotlin/Main.kt 里的 fun main
}

// ---- 目录结构 ----
// my-app/
//   build.gradle.kts
//   gradlew / gradlew.bat      ← 用它,别用全局的 gradle
//   src/main/kotlin/Main.kt
//   src/test/kotlin/MainTest.kt

// ---- 常用命令 ----
//   ./gradlew run     编译并运行(Windows: gradlew.bat run)
//   ./gradlew build   编译 + 测试 + 打包
//   ./gradlew test    只跑测试

// ---- 加完依赖后,这段就能编译运行了 ----
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    println("hi")
}
// 同一份源码,classpath 上没有 coroutines 时报
// error: unresolved reference 'kotlinx'.
三个高频卡点。其一,plugins 里只写 kotlin("jvm") 是没有 run 任务的——必须再加 application 并设好 mainClass,否则 ./gradlew run 报「找不到 task 'run'」。其二,mainClass 要写编译后的类名(Main.kt → "MainKt",带包名则是 "com.example.MainKt"),写成 "Main" 会在运行时才失败。其三,kotlinx.serialization 光加依赖不够,它靠编译器插件生成序列化代码,plugins 里漏了 kotlin("plugin.serialization") 时最坑的地方在于:编译期完全沉默,一直到运行到序列化那一行才炸。所幸消息写得很清楚——报错原文是 kotlinx.serialization.SerializationException: Serializer for class 'User' is not found. Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.,末句点名了插件。看到「compiler plugin is applied」就去查 plugins 块,别在注解上找原因。
不想为了验证一段协程代码专门建项目时,还有个折中办法:手动把 jar 塞进 classpath——从 Maven Central 下 kotlinx-coroutines-core-jvm-1.10.2.jar(它还依赖 atomicfu-jvm),编译和运行时都用 -cp 带上即可,源码一个字都不用改。这也直观说明了 Gradle 到底在替你做什么:它就是个自动下 jar、自动拼 classpath 的机器。

01 章解决的是「怎么让第一个项目跑起来」;这一张讲的是项目长大以后的事:依赖版本怎么集中管、用哪个 JDK 编译、多模块怎么不重复配置。

版本目录:把版本号从构建脚本里赶出去

gradle/libs.versions.toml 是 Gradle 内建的版本目录,四个表:[versions] 定版本号、[libraries] 定坐标、[plugins] 定插件、[bundles] 打包一组常一起用的依赖。构建脚本里改写成 implementation(libs.coroutines.core),得到类型安全的引用与 IDE 补全;升级时只改 toml 一处,所有模块同步。多模块项目里这是必做项——否则版本迟早在模块间漂移,运行期蹦出 NoSuchMethodError。

注意命名映射:toml 里的 coroutines-core 在脚本里写作 libs.coroutines.core(连字符变点)。

本页的版本基线

组件版本说明
Kotlin2.4.10本页所有代码在此版本;K2 编译器自 2.0 起就是默认
kotlinx-coroutines-core1.10.2kotlinx-coroutines-test 用同一版本号
kotlinx-serialization-json1.9.0还需要 plugin.serialization 编译器插件,版本跟 Kotlin 走
MockK / Turbine / Ktor查 Maven Central 取最新发布节奏与 Kotlin 无关,本页不写死具体号

jvmToolchain:别让「本机装了什么 JDK」决定产物

kotlin { jvmToolchain(21) } 声明用 JDK 21 来编译;Gradle 会在本机已装的 JDK 里定位对应版本——没装就直接构建失败,报 Cannot find a Java installation on your machine … Toolchain download repositories have not been configured.。想让它自动下载,得先在 settings.gradle.kts 里配 toolchain 解析仓库(foojay resolver)。定好之后,编译产物就与你用哪个 JDK,与运行 Gradle 本身用的是哪个 JDK 解耦。这样每个人和 CI 编出的字节码一致。没有它,字节码目标版本随开发者机器变,典型症状是「我这能跑,CI 上 UnsupportedClassVersionError」。选 LTS(17/21)最稳妥。

常用任务

命令做什么
./gradlew build编译 + 测试 + 打包,提交前跑的就是它
./gradlew test只跑测试;--tests "*CalculatorTest" 可筛选
./gradlew run由 application 插件提供,需设 mainClass
./gradlew jar打普通 jar(不含依赖,直接 java -jar 会缺类;要可执行胖包用 Shadow 插件或 application 的 installDist)
./gradlew --scan生成构建报告,排查「为什么这么慢/为什么重跑了」
// gradle/libs.versions.toml —— 版本集中在这一个文件
// [versions]
// kotlin        = "2.4.10"
// coroutines    = "1.10.2"
// serialization = "1.9.0"
//
// [libraries]
// coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
// coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
// serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
//
// [plugins]
// kotlin-jvm    = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
// kotlin-serial = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

// build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.kotlin.serial)
    application
}

repositories { mavenCentral() }        // 漏了这行会报 no repositories are defined

kotlin {
    jvmToolchain(21)              // 用 JDK 21 编译;本机没装 JDK 21 会直接构建失败
    compilerOptions {
        allWarningsAsErrors.set(true)   // 警告当错误,见本章后面
    }
}

dependencies {
    implementation(libs.coroutines.core)       // toml 里的 coroutines-core
    implementation(libs.serialization.json)
    testImplementation(kotlin("test"))         // 版本跟 Kotlin 插件走
    testImplementation(libs.coroutines.test)
}

application { mainClass.set("com.example.MainKt") }

tasks.test { useJUnitPlatform() }        // JUnit 5 必须显式开启

// ./gradlew build          编译 + 测试 + 打包
// ./gradlew test --tests "*CalculatorTest"
// ./gradlew run            需要 application 插件
// ./gradlew installDist    出带启动脚本的可分发目录(jar 任务不含依赖)
两个高频坑:① ./gradlew jar 打出来的 jar 不含依赖,java -jar app.jar 立刻 NoClassDefFoundError(连 kotlin-stdlib 都没有)——要分发得打 fat jar 或用 installDist 出带启动脚本的目录。② 直接在 dependencies 里手写版本号,几个模块各写一份,升级时漏改一个——运行期才以 NoSuchMethodError 形式爆出来。用版本目录从根上避免。
一律用项目自带的 ./gradlew(Gradle Wrapper)而不是本机装的 gradle:wrapper 把 Gradle 版本钉进仓库,任何人克隆下来构建行为都一致。gradle/wrapper/ 目录要提交进版本库,这是它起作用的前提。

kotlinx.serialization 是 Kotlin 官方方案,靠编译器插件在编译期为 @Serializable 类生成序列化器——不用反射,因此能跨 JVM/JS/Native 使用,也不怕代码混淆裁剪。

它是三件套,缺一不可

  1. 编译器插件:kotlin("plugin.serialization"),版本跟 Kotlin 走;
  2. 运行时库:kotlinx-serialization-json:1.9.0(格式各自独立:json / cbor / protobuf);
  3. 注解:类上标 @Serializable。

缺插件和忘标注解都会在编译期失败,报的是「找不到该类型的序列化器」这一类错误——两种原因症状相近,所以先确认编译器插件是否真的应用上了(打开 build 脚本看 plugins 块),再回头查注解。这是本库最常见的第一道坎。

Json 配置:三个几乎总要开的开关

配置作用建议
ignoreUnknownKeys = trueJSON 里多出的字段直接忽略对接外部 API 必开,否则对方加个字段你就崩
encodeDefaults值等于默认值的属性要不要写进输出默认 false(省体积);对方要求字段必须存在时才开
isLenient放宽引号等语法要求只在对接不规范的老接口时开

@SerialName("user_age") 把 JSON 字段名映射到 Kotlin 属性名,让下划线风格的接口不污染你的代码风格。属性有默认值时该字段就是可选的——这比声明成可空类型再到处判空干净得多(05 章)。

Ktor client:协程原生的 HTTP 客户端

HttpClient(CIO) { install(ContentNegotiation) { json() } },之后 client.get(url).body<User>() 直接拿到对象。它是 suspend 函数,天然融进结构化并发(10 章)。客户端持有连接池,应当复用一个实例,用完 close();每次请求新建一个是常见的资源泄漏源。JVM 上 Retrofit 也很成熟,suspend fun 原生支持,选哪个主要看是否需要跨平台。

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class User(
    val name: String,
    @SerialName("user_age") val age: Int,   // JSON 字段名 ≠ 属性名
    val role: String = "guest",               // 有默认值 = 反序列化时可缺省
)

// 配好一次,复用
val json = Json {
    ignoreUnknownKeys = true    // 对方加字段不会让你崩 —— 几乎总要开
    encodeDefaults    = false   // 默认值不写进输出(默认行为)
}

val s = json.encodeToString(User("Tom", 30))
// encodeDefaults=false → {"name":"Tom","user_age":30}   role 被省略
// encodeDefaults=true  → {"name":"Tom","user_age":30,"role":"guest"}

val u = json.decodeFromString<User>("""{"name":"Tom","user_age":30,"extra":1}""")
// extra 被忽略;role 取默认值 "guest"

// Ktor client:一个实例复用,用完 close()
val client = HttpClient(CIO) {
    install(ContentNegotiation) { json(json) }
}

suspend fun loadUser(id: Int): User =
    client.get("https://api.example.com/user/$id").body()

// 忘了编译器插件、或忘了标 @Serializable:
//   编译期失败,报「找不到该类型的序列化器」——先查 plugins 块
混用两套序列化框架会撞车:Jackson/Gson 依赖无参构造器和运行时反射,而 Kotlin 的 data class 只有带参构造器、默认参数在字节码里是另一套机制,Jackson 拿到的常是全 null 或直接失败——必须加 jackson-module-kotlin 才能正确处理构造参数与默认值(这类 Java 生态与 Kotlin 的接缝问题见 12 章)。同一个项目里尽量只用一套。
encodeToString / decodeFromString 都是 Json 实例上的方法,所以把配置好的 Json { … } 存成单例复用——每次现建一个既浪费又容易让不同调用点配置不一致。val json = Json { ignoreUnknownKeys = true } 放在顶层或伴生对象里。

把「写得糙」在合入主干前挡下来,靠的是三道闸门:格式、坏味道、编译器自己的话。分工搞混会让工具互相打架。

ktlint 与 detekt 的分工

ktlintdetekt
管什么格式:缩进、空行、import 顺序、命名坏味道:函数过长、圈复杂度、空 catch、可疑写法
可自动修大部分能(--format)基本不能,要人判断
可配置度刻意很低(按官方 code style 走)高,规则集可裁剪
类比PrettierESLint

两个一起用是常规做法:ktlint 在提交钩子里自动格式化,detekt 在 CI 里报告。别让两者都去管格式,否则会出现「A 改完 B 又改回去」。

先用好编译器自带的警告

装工具之前,Kotlin 编译器本身已经能报不少东西。开 allWarningsAsErrors(命令行是 -Werror)把警告升级成错误,警告就不会在日志里越积越多。(Kotlin 2.4.10):一段调用了 @Deprecated 函数的代码,默认只给一行 warning: 'fun oldApi(): Int' is deprecated. …;加上 -Werror 后编译直接失败,首行是 error: warnings found and -Werror specified。

建议:新项目一开始就开;老项目先把存量警告清掉再开,否则寸步难行。

@Suppress 的正确用法

  • 范围尽量小:能标在语句/属性上就别标在函数上,能标函数就别标文件(@file:Suppress 是最差的选择,会把之后新写的同类问题一起静音)。
  • 带上理由:紧挨着写一行注释说明为什么必须抑制。没有理由的 @Suppress 半年后没人敢删。
  • 参数是诊断 ID 字符串,如 @Suppress("DEPRECATION")、@Suppress("UNCHECKED_CAST")。给函数标 @Suppress("DEPRECATION") 后,同一份代码里那处调用的弃用警告消失,其余位置照报。

explicit API 模式:写库必开

库的公开 API 一旦发布就难改。开启 explicit API(Gradle 里 kotlin { explicitApi() },命令行 -Xexplicit-api=strict)后,编译器强制你为每个公开声明写明可见性和返回类型,杜绝「本想内部用、忘写 private 就成了公开 API」和「返回类型靠推断,改实现顺手改了公开签名」。把一段普通代码用 strict 模式编译,报错原文是:

  • error: visibility must be specified in explicit API mode.
  • error: return type must be specified in explicit API mode.

应用(非库)项目通常不开——那里公开成员多且无外部消费者,收益不抵噪音。相关的 API 设计取舍见 13 章。

// ① 编译器警告当错误
// build.gradle.kts
kotlin {
    compilerOptions { allWarningsAsErrors.set(true) }
    explicitApi()          // 写库时开;应用项目通常不开
}

// (kotlinc 2.4.10):
//   默认  → warning: 'fun oldApi(): Int' is deprecated. 用 newApi 代替.
//   -Werror → error: warnings found and -Werror specified   (编译失败)

// ② @Suppress:范围尽量小,并写明理由
@Deprecated("用 newApi 代替")
fun oldApi(): Int = 1

// 理由:v3 前需兼容旧客户端,2026Q4 随 oldApi 一起删
@Suppress("DEPRECATION")
fun legacyBridge(): Int = oldApi()      // 这一处静音,别处照报

// ③ explicit API 模式报错
class Service {                    // ← visibility must be specified
    fun compute() = 1 + 1          // ← visibility + return type must be specified
}
// 改成:
public class Service {
    public fun compute(): Int = 1 + 1
}

# ④ 工具:格式归 ktlint,坏味道归 detekt
# ./gradlew ktlintCheck    ./gradlew ktlintFormat
# ./gradlew detekt         # 报告写到 build/reports/detekt/
allWarningsAsErrors 有个现实副作用:升级 Kotlin 版本时,编译器新增的警告会让原本能构建的项目突然构建失败。这本身是好事(新警告往往对应真问题),但要有心理准备并预留时间——别在发版前一天升 Kotlin。另外 @Suppress("UNCHECKED_CAST") 是最危险的一个:它抑制的警告正对应运行期可能的 ClassCastException,09 章讲过类型擦除,标它之前先确认强转真的安全。
把这些放进 CI 而不是只靠自觉:./gradlew build 里挂上 ktlint 检查与 detekt,失败就挡合并。人工 code review 的注意力应该花在设计和边界条件上,格式与坏味道交给机器——这也是把 review 从「挑毛病」变成「聊方案」的关键一步。

高级特性与前沿

本章覆盖:反射的能力与代价(以及为什么编译期生成更好)、KMP 跨平台与 expect / actual、Kotlin 2.x 到 2.4 真正落地的语言变化、编译产物与怎么看字节码、以及一批被语言特性吃掉的设计模式。

最后一组话题:运行时反射的能力与代价、跨平台的现状与边界、Kotlin 编译成了什么,以及一批在 Java 里要写模式、在 Kotlin 里是语法的东西。

反射

要什么怎么写代价
依赖implementation(kotlin("reflect"))约 2~3 MB,Android 上要掂量
拿类obj::class · String::class · x::class.java(转成 Java 的 Class)—
遍历成员cls.memberProperties · cls.declaredFunctions · cls.primaryConstructor慢,别放热路径
读属性prop.get(obj);私有的要先 isAccessible = true—
注解cls.findAnnotation<MyAnn>()注解要 @Retention(RUNTIME)
用它的地方序列化、DI 容器、ORM、测试框架业务代码几乎不需要反射
更好的替代编译期代码生成(KSP / kotlinx.serialization)——没有运行时开销,也不怕裁剪与混淆—
与 AOT / 裁剪反射是 Native、Android R8、GraalVM 的天敌要配保留规则

KMP:一份代码多个目标

要点说明
目标JVM / Android / iOS / JS / WasmJs / Native(桌面、嵌入)
源集commonMain 放共享代码,jvmMain / iosMain 放平台实现
expect / actual共享层声明 expect fun platformName(): String,各平台给 actual 实现
共享到什么程度实践中共享的是业务逻辑、网络、数据层;UI 通常各写各的(除非用 Compose Multiplatform)
生态Ktor(网络)· kotlinx.serialization · kotlinx.coroutines · kotlinx.datetime · SQLDelight 都支持多平台
iOS 侧编译成 framework 给 Swift 调;Kotlin 的部分语言特性在 Objective-C 接口上会失真(泛型、密封类、挂起函数)
代价构建更复杂、调试链更长、平台特有 bug 更难查
判据业务逻辑重、UI 各平台差异大 → KMP 很合算;页面简单、逻辑薄 → 收益有限

编译产物

目标产出要点
JVM.class 字节码与 Java 完全互通;顶层函数变成 文件名Kt 类的静态方法
Android字节码 → D8/R8 → dexR8 同时做裁剪与混淆,反射要写 keep 规则
NativeLLVM → 原生二进制iOS / 桌面 / 嵌入;没有 JVM,GC 模型也不同
JS / WasmJS 或 WebAssembly—
看生成了什么IntelliJ:Show Kotlin Bytecode → Decompileinline、data class、协程、互操作的一切疑问都在这里能当场看清
常见发现suspend 函数多了 Continuation 参数;inline 展开到了调用处;data class 生成了六个方法—

被语言吃掉的设计模式

模式在 Kotlin 里是什么
单例object
建造者具名参数 + 默认值,或 apply { }
装饰器接口委托 by(06 章)
策略函数类型参数:fun sort(cmp: (T, T) -> Int)
观察者Flow / StateFlow(11 章)· Delegates.observable
访问者sealed + when
工厂顶层函数、companion object 的 operator fun invoke
适配器扩展函数
模板方法高阶函数
结论不是「设计模式没用了」,而是:它们中的一大半是在补语言的短板,语言补上了就不用再写那层样板
// ① 反射:能做,但先问有没有编译期方案
import kotlin.reflect.full.*

data class User(val id: Long, val name: String)

fun toMap(x: Any): Map<String, Any?> =
    x::class.memberProperties.associate { it.name to it.call(x) }

println(toMap(User(1, "张三")))          // {id=1, name=张三}
// 但序列化这件事,kotlinx.serialization 在编译期就做完了,零反射

// ② KMP:共享声明,平台实现
// commonMain/Platform.kt
expect fun platformName(): String

// jvmMain/Platform.jvm.kt
actual fun platformName() = "JVM ${System.getProperty("java.version")}"

// iosMain/Platform.ios.kt
actual fun platformName() = UIDevice.currentDevice.systemName

// ③ 被吃掉的模式:这四行在 Java 里分别是四个类
object Config                                     // 单例
val req = Request().apply { url = "x"; retry = 3 }  // 建造者
class Cached(r: Repo) : Repo by r                   // 装饰器
fun process(strategy: (Item) -> Int) { }             // 策略
反射是裁剪与混淆的天敌:本地跑得好好的,打成 release 包(R8 / ProGuard / GraalVM native)之后就 ClassNotFoundException 或字段找不到——因为那些类被裁掉或改名了。用了反射就必须写 keep 规则,而这件事通常在上线前一天才被发现。
KMP 不是「一次编写到处运行」:共享的是逻辑,UI 与平台能力仍要各写一份;把它当「省掉重复的业务代码」看待,期望才对得上。
需要「按名字取字段」时,先看有没有编译期方案:kotlinx.serialization、KSP 生成的代码都能做到同样的事而没有运行时开销,也不怕 R8 裁剪。
对语言行为有疑问时,反编译看字节码是最快的求证方式——协程、inline、互操作这三块尤其值得看一次。

Kotlin 的反射分两档:一档随 stdlib 附送,另一档要单独加依赖,不加就在运行时炸——这是最容易踩的一脚。

kotlin-reflect 是单独的依赖

把一段用到 findAnnotation 的程序编译好,运行时 classpath 里只有 kotlin-stdlib,Kotlin 2.4.10 / JRE 25 抛出:

  • Exception in thread "main" kotlin.jvm.KotlinReflectionNotSupportedError: Kotlin reflection implementation is not found at runtime. Make sure you have kotlin-reflect.jar in the classpath
  • 栈顶是 kotlin.jvm.internal.CallableReference.getReflected。

关键在于:编译期一点问题都没有,错误只在真正走到那行时才出现。把 kotlin-reflect 加进 classpath 后,同一程序输出 D ann=100、E name: kotlin.String、F Tom,一切正常。

同一次还确认:User::class.qualifiedName、User::class.java.name 这类轻量操作不需要 kotlin-reflect 也能跑(它们在 stdlib 里)。所以「用了 ::class 就得加依赖」是错的,界线在于有没有碰 kotlin.reflect.full 那一套。

KClass 与 Class

KClass<T>Class<T>
怎么拿User::classUser::class.java 或 obj.javaClass
知道什么Kotlin 视角:可空性、属性、data/sealed、默认参数JVM 视角:字段、方法、擦除后的泛型
互转kclass.java / clazz.kotlin,User::class.java === Class.forName("User") 为 true——同一个 JVM 类对象

要传给 Java 库(Jackson、JDBC…)就交 Class;要读 Kotlin 特有的信息就用 KClass(并记得加 kotlin-reflect)。

写注解:两个必填项

  • @Target(AnnotationTarget.FUNCTION)——能标在哪:类/函数/属性/参数/表达式…不写就是几乎哪儿都能标,容易被误用。
  • @Retention(...)——留到什么时候:SOURCE(只给编译器和 IDE 看)/BINARY(进 class 文件但运行时读不到)/RUNTIME(默认,反射能读)。想在运行时读,必须是 RUNTIME,这是「注解写了却读不到」的头号原因。

什么时候别用运行时反射

反射把「本该编译期确定的事」推迟到运行期:类型错误变成运行时异常、代码混淆/裁剪工具看不见这些调用会把它们删掉、启动时还要付出解析元数据的成本。凡是「扫一遍注解、生成一些样板代码」的需求,用 KSP(Kotlin Symbol Processing)在编译期做——产出真实源码,可读、可调试、可被编译器检查,也没有运行期开销。KSP 是 KAPT 的替代者(KAPT 需要先把 Kotlin 变成 Java 存根,慢且在 K2 下只以兼容模式运行)。运行时反射真正合理的场景很窄:通用序列化框架、依赖注入容器、测试框架这类无法预知使用方类型的基础设施。

import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.findAnnotation

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)   // 运行时要读,必须 RUNTIME
annotation class Validated(val maxLength: Int)

@Validated(maxLength = 100)
fun saveName(name: String) { }

data class User(val name: String, val age: Int)

fun main() {
    // —— 这几行不需要 kotlin-reflect ——
    println("A kclass=" + User::class.qualifiedName)   // A kclass=User
    println("B javaClass=" + User::class.java.name)   // B javaClass=User
    println(User::class.java === Class.forName("User"))  // true 同一个类对象

    // —— 以下需要 kotlin-reflect 在 classpath 上 ——
    println(::saveName.findAnnotation<Validated>()?.maxLength) // 100
    for (p in User::class.memberProperties)
        println("E ${p.name}: ${p.returnType}")      // E age: kotlin.Int …
    println(User::name.get(User("Tom", 3)))            // Tom
}

/* classpath 里没有 kotlin-reflect 时的输出(编译毫无问题):
Exception in thread "main" kotlin.jvm.KotlinReflectionNotSupportedError:
  Kotlin reflection implementation is not found at runtime.
  Make sure you have kotlin-reflect.jar in the classpath
    at kotlin.jvm.internal.CallableReference.getReflected(CallableReference.java:100)
                                                                              */

// 要用就显式声明,别指望传递依赖:
//   implementation(kotlin("reflect"))
KotlinReflectionNotSupportedError 是个Error 不是 Exception,而且只在执行到那一行时才抛——功能测试没覆盖到的分支会一路带着这个雷上线。Gradle 里 kotlin-reflect 常常是被某个库传递带进来的,你本地能跑,换个依赖组合就没了。要用就显式声明 implementation(kotlin("reflect")),别赖传递依赖。
写自定义注解前先确认它要被谁读:给 IDE/编译器看的用 SOURCE;给 KSP 处理器看的 SOURCE 就够(处理器读的是源码结构,不是 class 文件);只有真要运行时反射读才用 RUNTIME。默认就是 RUNTIME,很多人从没想过这件事。

KMP 的主张不是「一次编写到处运行」,而是共享该共享的,各写各的平台层。这条界线画在哪里,决定了 KMP 项目是省事还是添乱。

源集结构:编译到哪个平台,就用哪一套源码

  • commonMain——只能用 Kotlin 标准库和跨平台库,不能碰任何平台 API;
  • jvmMain / androidMain / iosMain / jsMain——各平台专属实现,这里才能用 java.io、UIKit、DOM;
  • 测试同构:commonTest 用 kotlin.test 写一次,各平台各跑一遍(15 章讲过 kotlin.test 为什么是个门面)。

编译某个平台时,commonMain + 该平台源集一起参与,所以「共享代码」并不是运行期动态选择,而是编译期就分好了。

expect / actual

commonMain 里 expect fun currentTimeMillis(): Long 声明「我需要这个东西」,每个平台源集用 actual 给出实现。编译器强制每个目标平台都必须有 actual,漏一个就编译失败——这比运行时 if-else 判平台可靠得多。

但 expect/actual 不是唯一手段,很多时候更好的做法是在 common 里定义接口,平台侧提供实现并从入口注入。接口方式更好测(可以塞 fake),也不受 expect/actual 一些结构限制的约束。

共享什么,不共享什么

层共享?说明
数据模型、校验规则共享收益最大、风险最小,通常从这里起步
网络与序列化共享Ktor client + kotlinx.serialization 都是跨平台的
业务逻辑、状态机共享KMP 的主要卖点
本地存储视情况SQLDelight 等库可共享,直接用平台 API 则不行
UI通常不共享各平台交互习惯不同;Compose Multiplatform 提供了共享 UI 的选项,但要不要用是个产品决策,不是技术默认值

生态成熟度:保守一点说

  • 共享业务逻辑这条路已被不少团队用于生产,主流跨平台库(Ktor、kotlinx.serialization、kotlinx.coroutines、SQLDelight)都支持多目标。
  • 但代价真实存在:构建配置比单平台复杂、iOS 侧调试要在 Kotlin 与 Xcode 之间来回、库的平台支持矩阵需要逐个核对、团队得同时懂两端。
  • 共享 UI、以及 Kotlin/Wasm 这类较新的目标,成熟度与 JVM/Android 不在同一档次,演进也快——决策前请以官方文档当时的状态为准,不要照搬任何教程(包括本页)里的现状描述。
// 源集结构(编译期就分好,不是运行期判断)
// src/commonMain/kotlin/   ← 只能用跨平台 API
// src/jvmMain/kotlin/      ← 可用 java.*
// src/iosMain/kotlin/      ← 可用 platform.Foundation.*
// src/commonTest/kotlin/   ← 一份测试,各平台各跑一遍

// —— commonMain:声明期望 ——
expect fun platformName(): String

data class Order(val items: List<Int>) {
    val total: Int get() = items.sum()      // 纯逻辑,天然可共享
}

// —— jvmMain:实际实现 ——
actual fun platformName(): String = "JVM " + System.getProperty("java.version")

// —— iosMain:实际实现 ——
// actual fun platformName(): String = UIDevice.currentDevice.systemName

// 少一个平台的 actual → 编译失败,不会拖到运行期

// 常常更好:common 里定接口,平台侧实现并注入(更好测)
interface Clock { fun nowMillis(): Long }

class OrderService(private val clock: Clock) {   // 测试可传 FakeClock
    fun isExpired(deadline: Long) = clock.nowMillis() > deadline
}

// build.gradle.kts(多目标声明的骨架)
kotlin {
    jvm()
    iosArm64(); iosSimulatorArm64()
    sourceSets {
        commonMain.dependencies { implementation(libs.serialization.json) }
        commonTest.dependencies { implementation(kotlin("test")) }
    }
}
最常见的错误是在 commonMain 里习惯性用了 JVM 的东西——java.time、String.format、Thread、正则的某些行为差异——本地跑 JVM 目标一切正常,一编 iOS 目标才报错。多目标项目要把所有目标都挂进 CI,不能只构建 JVM。另外 expect class 在不同平台上的成员必须严格对上,改一处忘了改另一处是家常便饭。
起步别贪:先把一个不依赖平台的模块(比如「订单金额计算 + 校验规则」)抽成 KMP 模块,两端各自接进去跑通一次。这一步能真实暴露构建、发布、调试上的成本,再决定要不要继续往上搬——比先搭一整套架构再发现走不通划算得多。

Kotlin 2.x 最大的一件事是编译器换代,语言层面的变化则是小步快跑。这里只写在 Kotlin 2.4.10 上确认过的,其余一律注明状态。

K2 编译器

自 Kotlin 2.0 起 K2 是默认前端,重写了类型推断与解析。对使用者的直接影响是:类型推断更准(少写显式类型也不报错)、诊断信息更清楚,以及——一些以前能编过的模糊写法现在会报错。升级 Kotlin 大版本时遇到「没改代码却编不过了」,多半就是这类收紧,通常是原来的写法本就有歧义。

when 的 guard conditions

is String if x.isEmpty() -> … 这种「分支类型 + 附加条件」的写法,在 Kotlin 2.4.10 上不需要任何 opt-in 参数,直接编译通过并正确运行(classify("")→空串、classify("abc")→串:3、classify(5)→正数、classify(-1)→其他)。它已经是可以放心用的语言特性。

价值在于:以前要么在分支体里再嵌一层 if(破坏 when 的表达式形态),要么把条件拆成两个分支。有了 guard,「同一个类型按条件分流」终于能写平。

data object

data object Home 相比普通 object 多了有意义的 toString()——打印 Route.Home 得到 Home 而不是 Route$Home@1b6d3586 这样的默认形式。用 sealed 层级建模状态时(Loading/Empty 这类无数据分支),日志和调试信息可读性差别很大。它的 equals/hashCode 仍按单例的同一性来——单例本来就只有一个实例。

context receivers → context parameters:仍在演进中

旧的 context receivers 实验特性已被context parameters 的设计取代。这类处在预览/实验阶段的特性,开启方式、语法细节和稳定时间都可能在小版本间变化——本页不给具体开关和语法,要用请查当时的官方文档并确认它在你的 Kotlin 版本上的状态。判断原则很简单:实验特性别进生产代码的核心路径,它们的迁移成本由你承担。

怎么跟进新特性(比记住清单更有用)

  • 语言变化的权威来源是 kotlinlang.org 的 What's new 和 YouTrack 上的 KEEP 提案;博客文章的版本号经常过期。
  • 看到一个特性,先确认三件事:在哪个版本稳定、要不要 opt-in、能不能编过——最后一件自己写五行代码编一次最快,比读十篇文章可靠。
// —— 以下全部在 Kotlin 2.4.10 上编译并运行通过 ——

// ① when 的 guard conditions:无需任何 opt-in
fun classify(x: Any): String = when (x) {
    is String if x.isEmpty() -> "空串"
    is String                -> "串:${x.length}"
    is Int if x > 0       -> "正数"
    else                      -> "其他"
}
// classify("")=空串  classify("abc")=串:3  classify(5)=正数  classify(-1)=其他

// ② data object:有意义的 toString
sealed interface Route {
    data object Home : Route              // println(Route.Home) → Home
    data class Detail(val id: Int) : Route
}

// ③ sealed + when 穷举:编译器确认分支齐全,不需要 else
fun title(r: Route): String = when (r) {
    is Route.Home   -> "首页"
    is Route.Detail -> "详情 ${r.id}"
}
// 以后给 Route 加一个子类型,这里会立刻编译失败 —— 正是想要的效果

// ④ 想知道某特性在你的版本上能不能用?写五行,编一次。
//    编过     → 可用
//    要 opt-in → 实验特性,别进核心路径
//    语法错   → 版本不够,或语法已变
网上大量 Kotlin 文章(包括看起来很新的)标注的版本号是写作当时的状态:某个特性当时要 opt-in,现在早已稳定;或者当时的语法后来改了。context receivers 就是活生生的例子——照着旧文章写,轻则编不过,重则用上一个已被废弃的设计。凡涉及版本,以官方文档 + 你手上编译器的实际反应为准。
「这个特性我这个版本能用吗」不要靠搜索,写五行代码编一次:编过就能用,报 opt-in 就是实验特性,报语法错就是版本不够。这里的结论都是这么来的,你也可以两分钟复现。

GoF 的很多模式,是在缺少某个语言特性的前提下发明的变通办法。Kotlin 把那些特性直接做进了语言,于是模式就退化成一个关键字——认出这一点,能省掉大量样板。

对照表

模式Java 里的样子Kotlin 里
单例私有构造 + 静态实例 + 双检锁object Counter { … },线程安全的延迟初始化由语言保证
建造者Builder 类 + 一串 withXxx() + build()默认参数 + 命名参数;嵌套结构才升级到 DSL(14 章)
策略接口 + 每个策略一个类函数类型:val discount: (Int) -> Int,传 lambda 即可
装饰器实现同接口 + 手写转发全部方法类委托 class Logged(inner: Repo) : Repo by inner,只重写想改的
模板方法抽象类 + 抽象钩子方法高阶函数把钩子作为参数传进去
观察者监听器接口 + 增删列表Flow / StateFlow(11 章)
访问者双分派 + accept/visit 一大套sealed 层级 + when 穷举,编译器保证不漏分支
空对象专门造一个「什么都不做」的实现可空类型 + ?.(05 章),不需要造对象

类委托 by:最被低估的一个

class LoggingRepo(private val inner: Repo) : Repo by inner { override fun find(id: Int) = … }——编译器把 Repo 的所有方法自动转发给 inner,你只重写关心的那一个。接口以后加方法,装饰器不用改。手写转发的版本则每加一个方法就要补一行,漏了就编译失败或行为不一致。这一条让「组合优于继承」从口号变成了比继承还省事的默认选择。

哪些模式没被吃掉

模式消失的是实现样板,不是设计意图。仓储(Repository)、适配器、外观、依赖注入这些讲的是模块怎么划分,与语言特性无关,Kotlin 里照样需要——只是写起来更短。区分方法很简单:如果一个模式的主要内容是「为了绕过语言限制而多造几个类」,Kotlin 大概率有直接写法;如果它讲的是「谁该知道谁」,那它依然成立。

// 单例:一个关键字
object AppConfig {
    val version = "1.0"          // 线程安全的延迟初始化由语言保证
}

// 建造者 → 默认参数 + 命名参数
data class ServerConfig(
    val host: String = "localhost",
    val port: Int = 8080,
    val timeoutMs: Long = 5000,
)
val cfg = ServerConfig(port = 9090)   // 没有 Builder,没有 build()

// 策略 → 函数类型
fun checkout(amount: Int, discount: (Int) -> Int) = discount(amount)
checkout(100) { it - 10 }
checkout(100) { it * 9 / 10 }

// 装饰器 → 类委托 by:编译器自动转发所有未重写的方法
interface Repo {
    fun find(id: Int): String?
    fun save(id: Int, v: String)
    fun delete(id: Int)
}

class LoggingRepo(private val inner: Repo) : Repo by inner {
    override fun find(id: Int): String? {      // 只重写关心的这一个
        println("find($id)")
        return inner.find(id)
    }
    // save / delete 自动转发;以后 Repo 加方法,这个类不用改
}

// 访问者 → sealed + when 穷举
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape

fun area(s: Shape): Double = when (s) {   // 无需 else,加子类型会编译失败
    is Circle -> 3.14159 * s.r * s.r
    is Rect   -> s.w * s.h
}
别把「模式没了」当成「设计没了」。最常见的过头做法是什么都用函数类型代替接口:策略只有一个方法时 (Int) -> Int 确实更轻,但一旦需要两个以上相关操作、或者需要名字来表达意图,具名接口(哪怕只有一个实现)比一串裸 lambda 好读得多——参数类型相同的多个函数参数还极易在调用点传错顺序。另外错误处理该用异常还是 Result、失败信息怎么建模,属于 API 设计范畴,见 13 章,这里只谈模式层面。
看到自己在写 Builder 类、单例双检锁、或一长串手写转发方法时,停一下——Kotlin 多半有一行的写法。反过来也成立:如果一个模式在 Kotlin 里还是要写很多样板,先确认你是不是在用 Java 的思路解 Kotlin 的问题。

Kotlin 的很多「零成本」说法,用 javap 一看就清楚了——也能看清哪些不是零成本。这里讲的不是优化技巧,是怎么自己去看。

用 javap 看编译产物

javap -p -c -cp out MyClassKt:-p 连私有成员一起显示、-c 反汇编字节码。不需要读懂每条指令,只看两件事就够用:有没有 new(分配了对象)、调的是什么方法(签名长什么样)。

inline 的代价:对比

同一段「循环里调 lambda」的逻辑,写成 inline fun 和普通 fun,字节码差别很直接(Kotlin 2.4.10):

非 inline 版本inline 版本
调用点做了什么new kotlin/jvm/internal/Ref$IntRef、invokedynamic … Function1、再 invokestatic notInline循环体直接展开在调用者里,只有 iload/iadd/goto
对象分配有:函数对象 + 捕获变量的包装无
代价—调用点代码体积变大,每个调用点复制一份

所以 inline 的正确用途是带 lambda 参数的高阶函数(消除函数对象、并支持 reified,见 09 章);给不带 lambda 的普通函数加 inline 只换来体积膨胀,编译器还会为此给出警告。

value class 什么时候装箱

@JvmInline value class UserId(val raw: String) 的 javap

  • fun takesId(id: UserId) → JVM 签名 takesId-Bu7z9Ig(java.lang.String):参数擦成底层类型,方法名带哈希后缀(后缀是为了避免与重载冲突,也是它对 Java 调用方不友好的原因,见 12 章);
  • 但 UserId 这个类确实存在,里面有 box-impl(String) 和 unbox-impl();
  • fun takesList(ids: List<UserId>) 的签名是 takesList(java.util.List<UserId>)——放进泛型容器就是装箱的。

结论:value class 在「直接当参数/返回值」时是擦除的,一旦作为泛型实参、放进集合、或被当作 Any 就会走 box-impl。它的首要价值是类型安全(别把 orderId 传成 userId),性能是顺带的(13 章讲它的 API 设计用法)。

集合链的中间对象

list.map { }.filter { } 里,map 会先把全部元素处理完并产出一个新 List,filter 再遍历它。打印执行顺序:List 版是 map 1, map 2, map 3, map 4, filter 2, filter 4, filter 6, filter 8;换成 asSequence() 则是 map 1, filter 2, map 2, filter 4, …——逐元素穿过整条链,不产出中间集合。同一次还确认 list.map { it } === list 为 false,中间结果确实是新对象。链条短、集合小时这点开销无关紧要;链条长或集合大时序列才有意义(07 章详述)。

什么时候别优化

  • 没测量之前:JVM 上直觉的命中率极低——JIT 会内联、逃逸分析可能消掉分配,你以为的热点常常不是。要下结论就用 profiler(JFR、async-profiler)和正经的基准框架(JMH),别靠读代码猜。
  • 不在热路径上:一段一次请求只跑一次的代码,怎么写都不影响整体。
  • 代价是可读性:把清晰的集合链改写成手写循环、到处塞 inline,换来的通常是维护成本而不是速度。

顺序永远是:先正确、再清晰、最后在有证据的地方优化。

# 看编译产物:-p 含私有成员,-c 反汇编
# javap -p -c -cp out MyFileKt

// ① inline vs 非 inline(Kotlin 2.4.10)
inline fun measureLoop(n: Int, body: (Int) -> Unit) { for (i in 0 until n) body(i) }
fun notInline(n: Int, body: (Int) -> Unit)   { for (i in 0 until n) body(i) }

fun useInline()    : Int { var s = 0; measureLoop(3) { s += it }; return s }
fun useNotInline() : Int { var s = 0; notInline(3)   { s += it }; return s }

/* javap 
   useInline()    → 只有 iload / iadd / goto,循环被展开,零分配
   useNotInline() → new kotlin/jvm/internal/Ref$IntRef
                    invokedynamic … Function1
                    invokestatic  notInline                        */

// ② value class 的装箱时机
@JvmInline
value class UserId(val raw: String)

fun takesId(id: UserId): Int = id.raw.length
fun takesList(ids: List<UserId>): Int = ids.size

/* javap 
   takesId-Bu7z9Ig(java.lang.String)   ← 擦除成 String,名字带哈希后缀
   takesList(java.util.List<UserId>)   ← 进了泛型容器,装箱
   class UserId 里有: box-impl(String) / unbox-impl()               */

// ③ 集合链的中间对象(执行顺序)
val nums = (1..4).toList()
nums.map { println("map $it"); it * 2 }.filter { println("filter $it"); it > 4 }
// map 1, map 2, map 3, map 4, filter 2, filter 4, filter 6, filter 8

nums.asSequence().map { … }.filter { … }.toList()
// map 1, filter 2, map 2, filter 4, …  逐元素穿过,无中间集合

// 顺序永远是:先正确 → 再清晰 → 最后在有证据的地方优化
微基准在 JVM 上极难写对:JIT 预热、死代码消除、常量折叠都会让手写的「循环一千万次计时」得出毫无意义的数字(编译器可能把整段被测代码删掉)。要测就用 JMH,它处理了预热与防优化。另外别把在某台机器上测到的具体数字当结论——不同 JVM 版本、不同硬件、不同负载下结论可能相反,能带走的只有「谁比谁快一个数量级」这种粗结论。
养成一个习惯:对不确定的语言特性,编一次 + javap 看一眼。「这个会不会装箱」「委托是不是每次都查表」「这个 lambda 有没有分配对象」——猜一小时不如看一分钟,而且看过一次就永远记住了。

Kotlin 的界面:Compose 与桌面

本章覆盖:Compose 核心 API(@Composable 与 remember)、状态提升与重组范围、副作用 API 怎么选(LaunchedEffect 家族)、以及桌面 / 多平台 / 终端各条路线的对照。Compose 一套写法同时覆盖 Android、桌面与 iOS。

Compose 的心智模型和 Kotlin 其余部分一脉相承:界面是「当前状态的函数」——状态变了,框架重新调用相关的可组合函数,自己算出差异。

核心 API

要什么怎么写
定义组件@Composable fun Greeting(name: String) { Text("你好 $name") }
规矩@Composable 函数只能被 @Composable 调用;要幂等、无副作用(可能一秒被调很多次)
记住一个值val n = remember { mutableStateOf(0) }——没有 remember,每次重组都会重新初始化
更顺手的写法var n by remember { mutableStateOf(0) }(要 import androidx.compose.runtime.getValue/setValue)
跨配置变更保留rememberSaveable { }
按 key 重算remember(id) { expensive(id) }——key 变了才重算
布局Column · Row · Box · LazyColumn(长列表用它,只组合可见项)· Spacer
修饰符Modifier.padding(16.dp).fillMaxWidth().clickable { }——顺序有意义:padding 在 background 前后效果不同
主题MaterialTheme.colorScheme.primary · MaterialTheme.typography.titleMedium
预览@Preview @Composable fun XPreview() { … }

状态与重组

要点说明
状态提升把状态移到调用方,组件只收 value 和 onValueChange——这样的组件无状态、可复用、好测试、好预览
单向数据流状态往下传,事件往上抛
重组范围只有读了那个状态的可组合函数会重组,不是整棵树
稳定性参数类型「稳定」时 Compose 能跳过重组;data class + 不可变属性 + 只读集合最友好,MutableList 会让它放弃优化
列表 keyitems(list, key = { it.id })——没有 key,增删时状态会错位
派生状态val visible by remember { derivedStateOf { scroll.value > 100 } }——避免每帧都重组
与 Flow 对接val state by viewModel.state.collectAsStateWithLifecycle()
别做的事不要在可组合函数体里发请求、写文件、改全局变量——它随时可能被重新调用

副作用 API:什么时候用哪个

API什么时候触发典型用途
LaunchedEffect(key)进入组合时、key 变化时重启发起请求、启动协程;LaunchedEffect(Unit) 表示只跑一次
rememberCoroutineScope()—在事件回调里启动协程(点击后滚动、弹提示)
DisposableEffect(key)进入时执行,离开时 onDispose注册/注销监听器
SideEffect { }每次成功重组后把状态同步给非 Compose 的对象
produceState—把非 Compose 的数据源变成状态
snapshotFlow { }—把 Compose 状态变成 Flow
共同点一切副作用都要放进这些 API 里,不能裸写在函数体

方案对照

方案覆盖平台什么时候选
Jetpack ComposeAndroidAndroid 新项目的默认选择
Compose MultiplatformAndroid / 桌面 / iOS / Web想一套 UI 多端复用;桌面与 Android 成熟度高,Web 较新
Swing / JavaFX桌面老项目、或需要成熟的桌面控件生态
Ktor + 模板/前端Web服务端渲染或前后端分离
终端—Clikt(命令行解析)· Mordant(彩色输出)
Android View 体系Android存量项目;可以与 Compose 混用(ComposeView / AndroidView)
// ① 状态提升:无状态组件 + 有状态包装
@Composable
fun Counter(count: Int, onInc: () -> Unit) {      // 无状态:好测、好预览、好复用
    Button(onClick = onInc) { Text("点了 $count 次") }
}

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }      // 状态在这一层
    Counter(count) { count++ }
}

// ② 副作用:请求放 LaunchedEffect,不要裸写在函数体
@Composable
fun UserScreen(id: Long, vm: UserViewModel) {
    val state by vm.state.collectAsStateWithLifecycle()

    LaunchedEffect(id) {                          // id 变了就重新加载
        vm.load(id)
    }

    when (val s = state) {                        // sealed + when(06 章)
        UiState.Loading -> CircularProgressIndicator()
        is UiState.Ok  -> UserCard(s.user)
        is UiState.Err -> ErrorView(s.msg, onRetry = { vm.load(id) })
    }
}

// ③ 长列表:LazyColumn + key
LazyColumn {
    items(users, key = { it.id }) { u ->        // 没有 key,增删时状态会错位
        ListItem(headlineContent = { Text(u.name) })
    }
}

// ④ 事件回调里要开协程:用 rememberCoroutineScope
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
    Text("回到顶部")
}
忘了 remember 是 Compose 头号错误:var n = mutableStateOf(0) 每次重组都会造一个新的,状态永远回到初始值,而且不报错。
可组合函数可能一秒被调用很多次,所以在函数体里直接发请求 = 请求被发很多次。这类 bug 在开发时不一定看得出来,一上真机滚动起来就暴露。
LazyColumn 的 items 不传 key:删掉第一项后,剩下项的内部状态(展开、输入框内容)会错位到别的行上——和其它声明式框架里的列表 key 问题是同一回事。
两条能让 Compose 代码立刻变好的规矩:组件默认写成无状态的(状态提升到调用方),以及任何副作用都进 LaunchedEffect / DisposableEffect。
Modifier 的顺序有意义:Modifier.padding(8.dp).background(Color.Red) 与调换顺序的效果完全不同——记成「从外往里」。

别的语言得在几个 UI 库之间挑,Kotlin 这边基本只有一个答案:Compose。它先在 Android 上取代了 XML 布局,随后被 JetBrains 扩展到桌面和 iOS——同一套声明式写法,跨平台复用界面代码,这是 Kotlin 相对 Java 最实在的差异之一。

两个名字,一件东西

  • Jetpack Compose:Google 出品,Android 官方推荐的 UI 工具包,已经是新项目的默认选择,XML 布局属于维护存量的路径。
  • Compose Multiplatform:JetBrains 在同一套 API 上做的多平台扩展(当前稳定 1.11.1),把目标扩到 桌面(JVM)、iOS、Web(Wasm)。
  • 关系是:Compose Multiplatform 包含并复用 Jetpack Compose 的 API。学一次,Android 和桌面都能写。
  • 它和 16 章的 KMP 是两层:KMP 共享的是业务逻辑,Compose Multiplatform 共享的是界面。可以只用 KMP 而各平台界面各写各的,也可以两者都用。

各目标平台的成熟度不一样

目标状态底层怎么画
Android官方推荐,最成熟Android 原生渲染
桌面(JVM)稳定可用Skia(经 Skiko),窗口基于 AWT/Swing
iOS稳定Skia
Web(Wasm)最新,仍在快速演进Canvas + Skia 编译成 Wasm

写之前先确认目标平台的成熟度——桌面和 Android 可以放心用,Web 那条线要做好跟着版本改的准备。

桌面 Compose 值得单独说一句

  • 它跑在 JVM 上,能直接用整个 Java 生态:文件、网络、数据库、你已经会的那些库,一个不缺。
  • 界面不用系统原生控件,而是用 Skia 自己画——好处是三个平台观感完全一致,代价是「不像原生程序」,无障碍与输入法的支持也不如原生控件。
  • 分发和 Java 一样走 jpackage 那条路(Gradle 插件已封装好 packageDistributionForCurrentOS),同样面临「要带一个 JVM 运行时」的体积问题。
// build.gradle.kts —— 桌面 Compose 的最小配置
plugins {
    kotlin("multiplatform")
    id("org.jetbrains.compose") version "1.11.1"
}

// Main.kt —— 一个能跑的桌面窗口
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.window.*

fun main() = application {
    Window(onCloseRequest = ::exitApplication, title = "温度换算") {
        var c by remember { mutableStateOf(25f) }

        Column {
            Text("%.0f °C = %.1f °F".format(c, c * 9 / 5 + 32))
            Slider(
                value = c,
                onValueChange = { c = it },      // 改状态即触发重组
                valueRange = -40f..100f,
            )
        }
    }
}

// $ ./gradlew run                          跑起来
// $ ./gradlew packageDistributionForCurrentOS   出安装包
Compose 的版本管理容易出错:Kotlin 编译器版本、Compose 编译器插件版本、Compose 库版本三者互相绑定,随便升一个就可能报「This version of the Compose Compiler requires Kotlin version X」。Kotlin 2.0 起编译器插件已并入 Kotlin 本体(org.jetbrains.kotlin.plugin.compose),跟着 Kotlin 版本走即可——但网上大量教程还停在旧的对应表上,照抄必然对不上。
学 Compose 的顺序建议反着来:先在桌面上练。./gradlew run 几秒就起来,不用装 Android SDK、不用等模拟器;等写法熟了再迁到 Android,API 是同一套。

从 XML 布局或 Swing 转过来,最大的转变不是语法,是你不再持有控件、也不再手动更新它们。Compose 里界面是状态的函数:状态变了,框架重新调用相关的函数,自己算出该改哪里。

命令式 vs 声明式,差在哪

  • 命令式(Swing / Android XML):你拿到一个 TextView 的引用,数据变了就 setText(...)。界面状态和数据状态是两份,同步不上就是 bug。
  • 声明式(Compose):你写一个 @Composable fun 描述「在当前状态下界面长什么样」。状态变了,框架重新调用这个函数,比对前后差异并只更新变化的部分。这个过程叫重组(recomposition)。
  • 直接后果:状态只有一份,界面和数据不可能不同步——这类经典 bug 从根上消失了。

三个必须搞清的关键字

  • mutableStateOf(...):造一个可被观察的状态。用普通 var 存的值改了不会触发重组——这是最常撞的第一个坑。
  • remember { ... }:让这个值跨重组存活。不写 remember,每次重组都会重新初始化,状态永远回到初值。
  • rememberSaveable:再进一步,跨配置变更(屏幕旋转)和进程重建也保住。
  • 组合起来的惯用写法就是 var c by remember { mutableStateOf(25f) }——by 是委托属性(08 章),让你像用普通变量一样读写它。

状态提升:Compose 的核心设计规矩

  • 惯例是让 composable 无状态:不自己持有状态,而是接收 value 和 onValueChange 两个参数——状态被「提升」到调用方那里。
  • 好处很实际:这样的组件可复用、可预览、可测试,因为它的输出完全由入参决定。
  • 判断依据:如果一个组件的状态会被别的组件读到或改到,就必须提升;纯粹自用的(比如展开/收起)可以自己留着。

重组的两条纪律

  • composable 函数可能被调用很多次、顺序不定、甚至被跳过。所以函数体里不能有副作用——别在里面发网络请求、写文件、改全局变量。要做这些用 LaunchedEffect / SideEffect 这类专门的入口。
  • 别在 composable 里做重计算。要算就 remember(key) { 重活() } 缓存起来,key 变了才重算。
import androidx.compose.runtime.*

// ❌ 有状态:不好复用,也不好测
@Composable
fun 温度控件坏版() {
    var c by remember { mutableStateOf(25f) }   // 状态藏在里面
    Slider(value = c, onValueChange = { c = it })
}

// ✅ 状态提升:组件无状态,输出完全由入参决定
@Composable
fun 温度控件(值: Float, 改变: (Float) -> Unit) {
    Slider(value = 值, onValueChange = 改变, valueRange = -40f..100f)
}

@Composable
fun 页面() {
    var c by remember { mutableStateOf(25f) }   // 状态在调用方
    Column {
        温度控件(值 = c, 改变 = { c = it })

        // 重计算要缓存:key 没变就不重算
        val 华氏 = remember(c) { c * 9 / 5 + 32 }
        Text("%.1f °F".format(华氏))
    }
}

// 副作用要走专门入口,不能直接写在函数体里
LaunchedEffect(用户id) {
    数据 = 拉取(用户id)        // 挂起函数,随组件生命周期取消
}
忘了 remember 是最高频的错:写成 var c by mutableStateOf(25f)(没有 remember),拖动滑块时值确实变了、也触发了重组,但重组时这一行又把它重新初始化回 25——现象是「控件回弹、怎么也拖不动」,而且不报任何错。看到这个症状先查 remember。
@Preview 是 Compose 最被低估的部分:给无状态组件写几个不同入参的预览,IDE 里直接看到各种状态下的样子,不用跑起整个应用。这也正是「状态提升」在实际开发里立刻兑现的好处。

Compose 不是唯一选项,有两种情况该看别处:你要接一份已有的 Java 桌面代码,或者这个工具根本不需要图形界面。

Swing / JavaFX:Kotlin 用起来毫无障碍

  • Kotlin 跑在 JVM 上,Java 的界面库全部可以直接用——Swing 在 JDK 里(见 Java 页的界面章),JavaFX 单独引,写法和 Java 一样,只是更简洁:lambda、具名参数、apply 作用域函数(08 章)搭界面特别顺手。
  • 桌面 Compose 本身就跑在 AWT/Swing 之上,所以两者能混用:ComposePanel 把 Compose 界面塞进 Swing 窗口,SwingPanel 把 Swing 控件嵌进 Compose 布局。
  • 这条路的价值在于渐进迁移:老的 Swing 程序不必推倒重来,可以一块一块换成 Compose。

终端:Clikt + Mordant(同一个作者)

  • Clikt(当前 5.1.0):命令行参数解析。子命令、参数校验、自动生成帮助、交互式提示——用 Kotlin 的属性委托表达,声明一个参数就是一行 val 名字 by option()。
  • Mordant(当前 3.0.2):终端输出的排版层。真彩色、表格、进度条、Markdown 渲染,并且会自动检测终端能力——不支持颜色时降级,输出重定向到文件时自动去掉转义序列。
  • 两者同源,配合无缝:Clikt 负责收参数,Mordant 负责把结果打印得好看。对「一个人写的小工具」这个场景,这套组合的性价比高过任何 GUI。

怎么选

你的情况选
新写的桌面或移动应用Compose Multiplatform
要接手 / 逐步改造已有 Swing 程序Swing + ComposePanel 渐进迁移
命令行工具,要参数解析和好看的输出Clikt + Mordant
只想给脚本加点颜色和进度条只用 Mordant
// Clikt:参数就是属性委托,一行一个
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.*

class 换算 : CliktCommand() {
    val 摄氏度 by option("-c", "--celsius", help = "输入温度")
        .float().default(25f)
    val 详细 by option("-v").flag()

    override fun run() {
        echo("%.1f °F".format(摄氏度 * 9 / 5 + 32))
    }
}

fun main(args: Array<String>) = 换算().main(args)
// --help 自动生成,参数写错自动报错并提示

// Mordant:表格与颜色,且会自动适配终端能力
import com.github.ajalt.mordant.rendering.*
import com.github.ajalt.mordant.table.table
import com.github.ajalt.mordant.terminal.Terminal

val t = Terminal()
t.println(table {
    header { row("°C", "°F") }
    body { (-40..100 step 20).forEach { row(it, it * 9 / 5 + 32) } }
})
Compose 和 Swing 混用时别忘了两边的线程规矩不同却又共用同一个线程:Swing 要求控件操作在 EDT 上,桌面 Compose 也跑在 AWT 事件线程上。在 SwingPanel 的回调里改 Compose 状态、或在 Compose 的副作用里碰 Swing 控件,都必须确认自己在 EDT 上——混用场景下的诡异卡顿和丢更新,多半出在这条边界。
Mordant 自动降级这一点在写工具时特别省心:同一份代码,交互式终端里出彩色表格,被 | 管道接走或重定向进文件时自动输出纯文本——不用你自己判断 isatty,也不会在日志文件里留一堆转义乱码。

资料索引:项目 · 读物 · 自测

速查表能告诉你「怎么写」,但判断力只能靠写出来。最后这一章给出收尾用的三样东西:难度递进的动手项目、按用途排的资料,以及一条自测标准。

Kotlin 的精通路线有两条腿:语言本身(空安全、协程、DSL 能力)和一个主战场(Android 或服务端,KMP 是两者的汇合点)。按下面的顺序走,每一步都有明确产出物。

动手项目(难度递进)

  • ① 控制台工具:纯 Kotlin 写一个记账或单位换算 CLI——用 data class + sealed class 建模(06 章)、kotlinx.serialization 读写 JSON(15 章),交付一个 Gradle 可执行 jar——练语言本身,不碰框架。
  • ② Android 小应用(Compose):从计数器做到备忘录,带 ViewModel、状态提升与列表动画,交付一个可安装的 APK——练声明式 UI 与生命周期。
  • ③ Ktor 后端:REST API + 数据库(Exposed)+ 协程并发调用外部服务,带日志与异常处理,交付 Docker 镜像——练服务端 Kotlin 与结构化并发(10、11 章)。
  • ④ KMP 共享模块:把 ②③ 的业务逻辑(模型、校验、API 客户端)抽成 Multiplatform 模块,Android 与 iOS(或桌面)共用——练 expect/actual 与跨平台工程组织(16 章)。

书与资料(按阶段)

  • 入门:官方文档(kotlinlang.org)质量极高,配套的 Kotlin Koans 交互练习适合边读边做。
  • 系统进阶:《Kotlin in Action (2nd Edition)》——由 Kotlin 团队成员撰写、覆盖协程与 DSL,K2 时代最值得通读的一本。
  • Android 方向:Android 官方 Codelab 的 Compose 路线(developer.android.com/courses)手把手带完整个现代开发栈。
  • 跟进:Kotlin 官方博客与 KotlinConf 演讲,跟踪 KMP 与编译器演进。

怎么判断自己卡住了

  • 写出来的还是「加了糖的 Java」——满屏 !!、到处 if (x != null)、集合操作用 for 循环手写:回 05 章的惯用法卡和 07 章;
  • 协程只会 launch 和 runBlocking,说不清作用域归谁、取消怎么传播:回 10 章的结构化并发卡——这是协程里唯一真正要理解的东西;
  • 作用域函数用得越来越花,自己回头读也要想一下 it 指谁:回 08 章那张对照表,把选择标准固定下来;
  • 和 Java 代码打交道时反复撞上空指针:回 12 章,问题几乎一定出在平台类型的边界上。
两个最常见的停滞方式。一是把语法学完就以为学会了:Kotlin 的难点不在语法(一周能读完),而在什么时候用哪个——五个作用域函数选哪个、错误用异常还是 Result 还是 sealed、协程作用域归谁管,这些只能靠写真实项目积累。二是只在 Android 或只在服务端里用它:两边的惯用法差异不小(生命周期 vs 请求作用域、Main dispatcher 的有无),只见过一边容易把局部经验当成语言规则。上面四个项目特意跨了这两个场景,别只挑一个做。
一条自测标准:给你一个 200 行的 Java 业务类,你能否把它重写成地道的 Kotlin——data class、空安全、扩展函数、when 表达式各就各位,行数减半而语义不丢——能做到,这一页就毕业了。