全景:Kotlin 的定位与现状
钻进语法之前先回答三个问题:Kotlin 解决什么问题、今天它用在哪里、和邻近语言怎么选。后面每一章都是这张地图的放大。
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上手:跑通第一个 Kotlin 程序
在讲任何语法之前,先让机器把你写的东西跑起来。Kotlin 的入口比多数语言绕:它没有自带运行时的解释器,编译产物要跑在 JVM 上,而真实项目又几乎全部由 Gradle 托管。这一章把这条链路一次讲通——挑一条上手路径、跑通第一个 fun main()、看懂它编译成了什么、读懂它报的第一条错,最后把单文件升级成能用第三方库的 Gradle 项目。后面所有章节的代码片段都默认你已经有一个能编译运行的环境。
Kotlin 没有「装好就能直接跑脚本」这回事——它先编译成 JVM 字节码再由 JVM 执行,所以无论走哪条路 JDK 都是前提(java -version 能打印版本就算有,没有就装 17+)。区别只在于「谁替你调用编译器」。
三条路的对照
| 路径 | 装什么 | 第三方库 | 断点 |
|---|---|---|---|
| ① Playground | 什么都不用 | 不能 | 不能 |
| ② 命令行 kotlinc | JDK + 解压编译器进 PATH | 要自己塞 -cp | 不便 |
| ③ IntelliJ IDEA | 一个 IDE,建项目时自动配好 | 能 | 能 |
学语法用 ①/②(想看编译产物或复现报错原文就用 kotlinc),代码一旦超过一个文件、或要 import 非标准库的东西就换 ③。Android 直接装 Android Studio。
三种跑法
- 编译成 jar:
kotlinc hello.kt -include-runtime -d hello.jar再java -jar hello.jar。产出的不是本机可执行文件,清单里的Main-Class: HelloKt是编译器按文件名推出来的(下一卡细讲); - IDE 的绿三角:
fun main行号左边那个三角——日常开发唯一会用的方式; - 当脚本跑:扩展名改
.kts、不要fun main,kotlinc -script greet.kts编译并立即执行;build.gradle.kts本身就是这么一个脚本。
// ① Kotlin Playground —— play.kotlinlang.org,粘贴、点运行
// 零安装;不能加第三方依赖,不能断点调试
// ② 命令行 kotlinc
// java -version → 先确认 JDK 就位(要 17+)
// 解压 kotlin-compiler-2.4.10.zip,把其中的 bin/ 加进 PATH
// kotlinc -version → info: kotlinc-jvm 2.4.10 (JRE 25.0.1+8-LTS-27)
// ③ IntelliJ IDEA Community / Android Studio
// New Project → Kotlin,构建系统选 Gradle(Kotlin DSL)
// src/main/kotlin/Main.kt 里已经有一个 fun main(),点绿三角即可
// 三条路的共同前提:编译产物是 JVM 字节码,要有 JVM 才能跑kotlinc 是编译器,默认只产出 .class 或 .jar,不会顺手执行。-include-runtime 把标准库一起打进 jar。不带它不一定立刻出错,但只要用上任何标准库设施,编译零报错、运行时才抛 NoClassDefFoundError: kotlin/…。四行代码里有三处「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 生成的方法、扩展函数其实是静态方法,一看就清楚。初学阶段大半时间花在读报错上。好消息是 Kotlin 的报错措辞高度模式化,认出模式就等于定位了原因。下面是 2.4(K2)的原文。
两条最常撞上的
initializer type mismatch: expected 'String', actual 'String?'——把可空值给了不可空变量,String和String?是两个类型;unresolved reference 'X'——三种成因:拼错、忘了 import、没加依赖。判据是看缺的是类名还是包名:缺包名(如kotlinx)基本就是依赖没加。
// ===== 编译期:带 文件:行:列 前缀,不会产出任何东西 =====
fun find(): String? = null
val s: String = find()
// e5.kt:3:19: error: initializer type mismatch: expected 'String', actual 'String?'.
val t: String? = find()
println(t.length)
// e6.kt:4:14: error: only safe (?.) or non-null asserted (!!.) calls are
// allowed on a nullable receiver of type 'String?'.
// ---- unresolved reference 的三种成因 ----
printn("typo") // 拼错
// e2.kt:2:5: error: unresolved reference 'printn'.
File("a.txt") // 忘了 import java.io.File
// e8.kt:2:13: error: unresolved reference 'File'.
import kotlinx.coroutines.runBlocking // 没加依赖
// e4.kt:1:8: error: unresolved reference 'kotlinx'. ← 画在最外层包名上
// ===== 运行期:没有行列前缀,换成 Exception in thread + 栈帧 =====
val u: String = find()!!
// Exception in thread "main" java.lang.NullPointerException
// at R1Kt.main(r1.kt:4) ← 注意不是 KotlinNullPointerExceptionNoClassDefFoundError 和 unresolved reference 当同一个问题——方向相反:前者是运行期找不到类(打包或 classpath 漏了),后者是编译期编译器就不认识这个名字。一个查运行时 classpath,一个查依赖声明。Type mismatch: inferred type is X but Y was expected 就是今天的 initializer/argument type mismatch。变量与类型系统
从 val / var 这对最小的选择开始,到基本类型在 JVM 上的真实形态、字符串模板、编译期常量,最后落到「类型推断能帮你到哪一步、从哪一步开始必须自己写」。本章的目标不是背 API,而是建立一个准确的心智模型:Kotlin 写起来像动态语言,跑起来是彻底的静态强类型。
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 表示 | 可空 / 作泛型实参时 |
|---|---|---|
Int | int | java.lang.Integer |
Long | long | java.lang.Long |
Double / Float | double / float | java.lang.Double / Float |
Boolean | boolean | java.lang.Boolean |
Char | char | java.lang.Character |
IntArray | int[](javap 里写作 [I) | — |
Array<Int> | java.lang.Integer[] | 元素永远是装箱的 |
String | java.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,不是 MutableListvar y = null:它不报错,推断出来的类型是 Nothing?——这个类型唯一能装的值就是 null,所以后面任何赋值都会失败,报错写着 but 'Nothing?' was expected,第一次见很难联想到「问题出在初始化那行」。同类的还有 val list = mutableListOf():报错指向 listOf() 那行说推不出 T,而你以为问题在后面的 add。凡是初始化值本身不携带类型信息(null、空集合、空数组),就把类型写出来。explicitApi(),编译器会强制所有公开声明写出返回类型和可见性——一次性把这条规矩变成机器检查。控制流
Kotlin 的 if 和 when 都是表达式(有返回值),比 Java 的 switch 强大得多;循环则彻底放弃了 C 风格三段式,全部走「遍历某个东西」的路子。本章还要把 return 在 lambda 里的特殊行为和 Nothing 类型讲透——这两件事看似冷僻,实则是后面读懂标准库和写出惯用代码的前提。最后一卡用控制台与文件 I/O 把学到的语法串成第一个能交互的小程序。
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;逗号分隔即「或」 |
带主体 + in | in 400..499 -> ... / !in list -> ... | 区间与集合成员判断 |
带主体 + is | is 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..5 | 1 2 3 4 5 | 闭区间,含两端 |
1..<5 (等价 1 until 5) | 1 2 3 4 | 半开区间,不含右端;遍历下标就用它 |
5 downTo 1 | 5 4 3 2 1 | 递减必须用它 |
1..10 step 3 | [1, 4, 7, 10] | 步长;step 必须为正 |
5..1 | [](空) | 不会自动倒序,直接是空区间——想倒序只能 downTo |
'a'..'e' | [a, b, c, d, e] | 字符也能构成范围 |
list.indices | 0 .. 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 21for (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 keyforEach { ... 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 惰性逐行,内存占用与文件大小无关。charset("GBK") 参数。另外 writeText() 是整体覆盖、appendText() 才是追加——两者写反会静默丢数据。想边写边看效果,把这一卡的代码贴进 01 章跑通的那个 main 里即可;控制台程序被管道重定向(echo hi | java ...)时,readln() 读的就是管道内容,这是测试交互逻辑最省事的办法。函数基础
Kotlin 的函数远比 Java 的方法灵活:可以脱离类独立存在、可以嵌套在另一个函数里、可以带默认值、可以中缀调用、可以作为值传递。本章从最基本的声明形态出发,一路走到 javap 层面看它们在 JVM 上究竟变成了什么——最后一卡的 @JvmOverloads,正是通往 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=3fun 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 是无效的。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 章。空安全机制
空安全是 Kotlin 立身的头号卖点:类型系统在编译期就把「可能为 null」写进类型里,让 NullPointerException 从运行时崩溃变成编译期错误。本章按「怎么用 → 边界在哪 → 怎么设计成根本不需要 null」三层推进:先讲 ?. / ?: / !! 这套操作符和智能转换,再讲这套体系唯一的漏洞——从 Java 来的平台类型,最后讲怎么用默认参数、空集合、sealed 类型把 null 从 API 里赶出去。本章所有报错原文与异常类型都是在 Kotlin 2.4.10 / JRE 25 上真跑出来的。
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)
}val name: String = ... 这一行,而不是几十行之后真正 name.length 的地方——如果那次赋值发生在构造函数或者某个初始化方法里,栈顶指向的会是一个「看起来什么都没干」的位置。看到 must not be null 就顺着方法名往 Java 侧找,别在 Kotlin 代码里绕圈。@Nullable / @NonNull,IDE 会据此给出正确的可空性;而你公司内部那些十年前的 Java 工具类多半是裸的。真正危险的正是后者——恰恰又是最常被调用的。写 Java 代码时顺手加注解,比在 Kotlin 侧一处处补 ?. 划算得多。反射、JSON 反序列化等绕过构造器写字段的场景同样能把 null 塞进非空属性,机制与此同源,见 12 章。前面几卡都在教「拿到 null 之后怎么办」。但工程上真正有效的顺序是反的:第一步永远是问「这个值能不能一开始就不为 null」。Kotlin 的可空类型好用到会让人上瘾——满屏 ?. 的代码不是空安全做得好,而是 API 设计得差。
第一层:让 null 根本不出现
| 场景 | 别这么写 | 该这么写 |
|---|---|---|
| 可选参数 | fun f(timeout: Int?) 里面 timeout ?: 30 | fun 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 的返回值是什么。?,就是在对所有调用方说「这里你必须处理没有值的情况」——这个负担要么值得,要么就该被消掉。一个实用的自检:如果每个调用方都在用同一个 ?: 默认值,那这个默认值就该搬进函数里当默认参数。返回类型同理: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))IOException——这是 Kotlin 用编译期安全换来的便利,代价必须自己承担:写库函数时用 KDoc 的 @throws 标注,写应用时在架构边界(HTTP handler、消息消费者、协程根作用域)统一兜底,别指望在中间层被编译器提醒。另一个真会咬人的坑:runCatching 会连 CancellationException 一起吞掉,在协程里用它包 delay 之类的挂起函数,协程被取消后 onFailure 会拿到 JobCancellationException、而且后面的代码照常继续执行——这与「取消应该终止执行」的直觉完全相反,见 10 章。*OrNull + ?: 或返回类型建模(见上一卡);数组越界、状态机走到不可能的分支——这些是 bug,让它抛出去、别 catch。判据:如果你 catch 之后写不出比「记个日志继续跑」更有意义的处理,那就不该 catch。另外 try 是表达式这件事也适用于 when 和 if,Kotlin 里绝大多数控制结构都有值,这是它比 Java 少一半临时变量的原因。面向对象编程
Kotlin 的类体系是对 Java 的一次「默认值重置」:类和方法默认 final(要继承得显式 open)、主构造器直接写在类头、data class 把值语义的样板代码全生成掉、sealed 把「就这么几种情况」变成编译器能校验的事实。本章除了讲怎么写,还会用 javap 拆开看这些语法糖在 JVM 上到底生成了什么——因为它们的坑几乎都藏在生成结果与直觉的落差里。
Kotlin 的类头本身就是构造器,属性也不是「字段 + getter/setter」的简写——它就是一对访问器,字段只是可能存在的实现细节。这两点决定了初始化顺序和 field 关键字的行为。
初始化顺序:属性初始化器和 init 块按书写顺序交替执行
不是「先所有属性、再所有 init」,而是从上到下依次执行。一个交替书写的类,输出是:
| 执行顺序 | 来源 |
|---|---|
| 1 | 第一个属性初始化器 |
| 2 | 第一个 init 块 |
| 3 | 第二个属性初始化器 |
| 4 | 第二个 init 块 |
| 5 | 次构造器的函数体(在委托给主构造器之后) |
推论:init 块里只能用到写在它上面的属性;次构造器体里能用全部属性,因为主构造器已经跑完了。
属性不是字段:field 只在访问器里存在
- Kotlin 语言层面没有字段,只有属性。
val x = 1生成的是一个私有字段 + 一个公有getX(); - 幕后字段(backing field)只在需要时才生成:写了自定义 getter 且不引用
field的属性,根本没有字段——它每次读都在执行代码(这正是上一章「智能转换对自定义 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=nullSub.init tag=sub注意第一行:
tag 声明成非空 String,却打印出 null。原因是基类构造器先跑,而此时子类的 override val tag = "sub" 尚未执行,读到的是子类字段的零值。这个 null 能悄悄流进你的非空类型里,是 Kotlin 空安全少数几个被绕过的地方(和 05 章的平台类型并列)。规避方式:基类的 init 块和属性初始化器里不要碰 open 成员;确实需要就改用 by lazy 延迟到第一次真正使用时求值,或者把初始化挪到一个显式的 initialize() 方法里。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()) }@Transactional、@Cacheable 等靠 CGLIB 生成子类做代理,JPA 需要给实体生成代理,Mockito 的 mock() 也要子类化——类是 final 的时候,这些要么直接抛异常,要么更糟:注解静默失效(事务根本没开,测试还全绿)。别为此把业务类全标 open,正确做法是上 all-open 编译器插件(Spring 项目直接用 kotlin-spring,JPA 用 kotlin-jpa),它按注解自动放开对应的类。另一个容易忘的点:override 的成员默认仍然是 open 的——你以为重写完就封死了,实际孙子类还能继续改,要封死得写 final override。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 class | modifier 'open' is incompatible with 'data'. |
data class NoArgs() | data class must have at least one primary constructor parameter. |
data inner class | modifier '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 class | sealed 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)
}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 会安静地跑到线上。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 这个键,键名再也不会和属性名写岔。别为炫技给普通属性套委托——每个委托属性都会多一个对象和一次间接调用,直接写字段最省心。集合与序列
集合是日常写得最多的东西,Kotlin 在这里做了两件 Java 没做的事:把「只读」和「可变」分成两套接口,以及提供惰性的 Sequence。本章讲机制与取舍——只读到底保证了什么(比你以为的少)、几十个相似 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——「只读」保证的是你拿这个引用改不了它,不是「没人能改它」。
三大集合的构造函数
| 类型 | 只读构造 | 可变构造 | 要点 |
|---|---|---|---|
| List | listOf · emptyList · listOfNotNull · buildList { } | mutableListOf · arrayListOf | listOfNotNull(1, null, 3) 得 [1, 3],过滤 null 的最短写法 |
| Set | setOf · emptySet · buildSet { } | mutableSetOf · hashSetOf · linkedSetOf | setOf 落到 LinkedHashSet,保留插入顺序;hashSetOf 不保 |
| Map | mapOf · emptyMap · buildMap { } | mutableMapOf · hashMapOf · linkedMapOf | mapOf("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<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 mapNotNull | listOf("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()。.toList()。这一次拷贝换掉的是一整类「远处的代码改了我的数据」的悬案。至于线程安全:Kotlin 的只读接口一点也不提供,多线程共享可变集合仍需 ConcurrentHashMap、CopyOnWriteArrayList 或把状态收进协程(见 10 章)。本卡是反查表:从「我要干的事」查「叫什么名字」。为什么这么选、有什么坑,前四卡已经讲透,这里只管查得快。
变换与过滤
| 我想…… | 用 |
|---|---|
| 每个元素换个样子 | map { } |
| 换个样子,顺手丢掉转换失败的 | mapNotNull { } |
| 一个元素展开成多个,再拉平 | flatMap { };已经是 List<List<T>> 就 flatten() |
| 只留符合条件的 | filter { } / 反过来 filterNot { } |
把 List<T?> 变成 List<T> | filterNotNull() |
| 只留某个子类型的元素 | filterIsInstance<Dog>()(结果类型也收窄了) |
| 去重 | distinct() / 按某个字段去重 distinctBy { } |
| 知道下标的 map / filter | mapIndexed { 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 { } |
| 多级排序 / 处理 null | sortedWith(compareBy({ }, { }))、nullsLast(naturalOrder()) |
原地排(仅 MutableList) | sortBy { }(没有 ed 的是原地版) |
| 取第一个/最后一个 | firstOrNull() / lastOrNull()(裸版本空集合抛异常) |
| 按条件找一个 | find { }(= firstOrNull { })/ indexOfFirst { } |
| 断言「有且仅有一个」 | singleOrNull { } |
| 取前 n / 跳过 n | take(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 { } |
| 过滤 Map | filterKeys { } / 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,并写回 mapsorted() 和 sort()、reversed() 和 reverse() 只差一个字母,行为却相反:前者返回新集合、原集合不动,后者原地改、返回 Unit。val sorted = list.sort() 会得到一个 Unit,编译器不一定拦得住你继续往下写。同理,链式调用的中途出现 sortBy 就是断链。OrNull = 失败给 null,没有它 = 失败抛异常;②后缀 By = 传一个「取键/取值」的 lambda,With = 传一个 Comparator;③过去分词是拷贝、动词原形是原地改(sorted() 返回新集合、sortBy { } 就地排 MutableList);④前缀 as = 视图,to = 拷贝。四条规则覆盖标准库九成的集合 API。函数式编程
Kotlin 不是函数式语言,但把函数式的好用部分几乎全搬了过来:函数是一等公民、lambda 到处能写、扩展函数让别人的类也长出你的方法。本章讲这几件工具的机制——lambda 编译成了什么、作用域函数怎么选、扩展函数为什么不是多态的——以及 Kotlin 在函数式上停在了哪里。
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 不能(两边都验证过)
| Kotlin | Java | |
|---|---|---|
| 捕获局部变量 | 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 finalreturn 是从外层函数返回,不是从 lambda 返回。list.forEach { if (x) return } 会直接结束整个外层函数(这叫非局部返回,只有 inline 的高阶函数才允许,见 09 章)——想「跳过这一个」得写 return@forEach。另一处是闭包捕获 var:它捕获的是变量本身不是当时的值,在循环里造 lambda 存进列表,最后所有 lambda 看到的都是循环结束后的那个值。要冻住当下的值,在循环体里先 val snapshot = i 再捕获 snapshot。it、尾随 lambda、隐式返回)在短 lambda 上是可读性净赚,在长 lambda 上是净亏——尤其嵌套之后 it 指谁全靠数括号。抽出来还有额外好处:具名函数能写 KDoc、能单独测、能被 :: 引用复用。五个作用域函数做的事一模一样——在一个对象上开一段临时作用域——区别只有两个维度:作用域里怎么称呼那个对象(this 还是 it),以及返回什么(对象自己还是 lambda 的结果)。把这张 2×3 表刻进脑子,选择题就没有了。
2 × 3 对照表(返回值均)
| 返回 lambda 的结果 | 返回 对象自己 | |
|---|---|---|
用 it 称呼(可改名,能嵌套不打架) | letsb.let { it.append("z"); 111 } → 111 | alsosb.also { it.append("w"); 222 } → sb 本身 |
用 this 称呼(成员可省前缀) | run(扩展式,可 ?.run)sb.run { append("y"); 999 } → 999with(普通函数,对象当参数)with(sb) { length } → 长度 | applysb.apply { append("x"); 999 } → sb 本身,999 被丢弃 |
怎么选:按目的对号入座
| 我要…… | 用 | 为什么 |
|---|---|---|
| 对可空对象「不为 null 才做」 | ?.let { } | 唯一能跟 ?. 自然连用的一档,见 05 章 |
| 把 A 转换成 B | let / 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" } } // ✅ 显式命名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(见本章第一卡),编译器不拦; - 所以「纯」只能靠团队自律和 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 更新一个深层字段,写起来长、跑起来也不便宜——深层更新频繁的场景,这正是该退回可变模型的信号。val,需要改再换 var;②函数签名收发只读集合,函数体内部爱怎么可变怎么可变——可变性被关在一个函数里就不会伤人;③递归深度不可控就写 tailrec,写不成尾递归就老实用循环加显式栈。追求「纯」到跟语言对着干(自己造 Monad 层、到处 copy())反而会写出团队里没人愿意维护的代码。泛型与高级类型
泛型是「一份代码适配多种类型」的手段,但 JVM 上它有一条硬约束——类型擦除:泛型信息只活到编译期。Kotlin 在这条约束上加了三样东西:声明处型变(out/in)、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 把类型信息真的带进来。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 % b | plus minus times div rem | 都要标 operator |
+a -a !a | unaryPlus unaryMinus not | |
a[i] a[i] = v | get(i) set(i, v) | 可以多个下标:a[i, j] |
a(x) | invoke(x) | 让对象像函数一样调用 |
x in a | a.contains(x) | 注意接收者是右边 |
a < b a >= b … | compareTo(b) | 一个方法管四个运算符 |
a == b | a?.equals(b) ?: (b === null) | 自带 null 处理,不用 operator;=== 不可重载 |
a += b | plusAssign,没有就退回 a = a.plus(b) | 两个都定义会编译报错 |
a..b a..<b | rangeTo rangeUntil | |
val (x, y) = a | a.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(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() // ❌ 返回类型信息全丢了<T>,结果三年后这个 T 从头到尾只被填过一种类型,却让每一处签名、每一条报错、每一次 IDE 补全都多背一层尖括号。真要扩展的那天,把具体类型抽成泛型是个机械的重构,IDE 一键就能做——提前付的这笔成本完全不必要。另一个具体的坑:类上拿不到 reified(类不能 inline),很多人在泛型类里写 T::class 撞墙后才发现要改设计——正确做法是把 Class<T>/KClass<T> 收进构造函数,再配一个 inline fun <reified T> of() 的伴生工厂给调用方省事。as」,那就加;如果答案是「没什么区别,只是显得更通用」,那就别加。同样地,out/in 是纯赚的:标对了调用方少写转换,标错了编译器当场报错,不存在中间地带——所以设计接口时该标就标。协程与结构化并发
协程是 Kotlin 处理异步与并发的核心方案。它不是「更轻的线程」这么一句话就完的事——真正的机制是挂起:函数能在中途让出线程、把「接下来要做什么」保存成一个对象,等结果到了再接着跑。理解了挂起,再理解结构化并发(谁是谁的父、谁取消谁、异常往哪传),协程就从「魔法」变成了可以推理的东西。本章以纯 JVM 为主线,Android 的 Dispatchers.Main 等只在需要区分平台时点名;异步数据流(一次拿到一串值)见 11 章。
协程只有一个核心机制:挂起(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。
三者对照
| 构建器 | 返回 | 阻塞当前线程? | 异常什么时候抛 | 用途 |
|---|---|---|---|---|
launch | Job | 不 | 立刻向父协程传播 | 「发射后不管」,不需要返回值 |
async | Deferred<T> | 不 | 推迟到 await() 时 | 要并发算出一个结果 |
runBlocking | T(块的值) | 会 | 直接抛给调用方 | 只该出现在 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() // 多出来的只有 awaitasync 的异常是延迟的:协程里抛了异常,只要没人调 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 什么都不会执行也不会报错——一个用完就废的作用域,排查起来极其费时。
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() } // 超时得到 nullCancellationException 是正常的控制流信号,不是失败。这决定了两件反直觉的事:一,子协程因为取消而抛它,父协程不会被当成失败处理;二,你的 catch (e: Exception) { log.error(...) } 会把大量取消当成错误刷进日志——线上日志里成片的 JobCancellationException 几乎都是这么来的。
另外别在 catch 里对已取消的协程继续做挂起调用(重试、上报),它们会立刻再抛一次取消异常;要么在 NonCancellable 里做,要么放到调用方去做。
withTimeout(ms) { } 超时会抛 TimeoutCancellationException(它是 CancellationException 的子类,所以只取消这个块、不影响父协程);不想处理异常就用 withTimeoutOrNull(ms) { },超时返回 null。这两个是给不受控的外部调用加保险的标准手法。Flow 与响应式流
suspend 函数解决的是「异步地拿到一个值」,Flow 解决的是「异步地拿到一串值」。它是建立在协程之上的冷数据流:声明时什么都不做,被 collect 时才开始产生数据,全程可挂起、可取消、遵守结构化并发。本章讲清楚冷流与热流的分界、上下文保持这条硬规则、三种背压策略的实际差异,以及 StateFlow / SharedFlow / Channel 各自的适用场景。前置知识见 10 章。
把 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 · transform | transform 最通用:一个输入可 emit 零到多个输出 |
| 限流/截断 | take · drop · distinctUntilChanged · debounce | take(n) 拿够就取消上游 |
| 副作用 | onEach · onStart · onCompletion | 都是中间操作,不会触发流 |
| 组合 | zip · combine · flatMapLatest | zip 严格配对;combine 任一有新值就出 |
| 异常 | catch · retry | catch 只捕上游异常 |
| 终端 | 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的值直接丢弃(先 emit99再订阅,订阅者只收到之后的1, 2); replay = 2时,先 emit1,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 能在 JVM 上站住脚,靠的不是语法糖,而是它和 Java 之间那条几乎无损的双向通道——你可以在同一个模块里混写两种语言,Java 的整个生态直接就是 Kotlin 的生态。但「几乎无损」不等于无缝:Java 不知道什么叫可空,Kotlin 不认受检异常,Kotlin 的属性和默认参数在字节码里也不是 Java 那一套。这一章把这条边界上会发生的事逐条摊开,并且全程用 javap 看真实签名。本章所有的签名、报错原文和运行输出,都是在本机用 kotlinc 2.4.10 + JDK 25 混编真跑出来的。
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>! |
Object | Any! |
int / int[] | Int / IntArray |
getX() / setX() 成对出现 | 属性 x(只有 getter 就是只读属性) |
static 成员 | 用类名点出来,JavaSide.TAG |
int... xs | vararg 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 就可以Cannot invoke "String.length()" because "plat" is null,栈顶是你的 Kotlin 代码,而真正的肇事者是几层之外那个返回 null 的 Java 方法——异常里完全没有它。更糟的是平台类型会顺着数据结构扩散:把 String! 放进 data class 的非空字段里,构造那一刻不一定报错,等到几个模块之后再炸。规则很简单:从 Java 拿到的值,别让它活过当前这个函数。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 只能全参调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) | |
|---|---|---|
不加 @Throws | String 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: yexception 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。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>vsT?: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()synthetic 的,Kotlin 侧调不到,但它确实存在——意味着框架可以造出一个所有字段都是默认值(引用类型全是 null)的实例,哪怕你声明的是非空 val。JPA 的懒加载代理、Hibernate 的部分初始化实体,都可能让你在 val name: String 上读到 null。另外 data class 当 JPA 实体本身就是个坏主意:equals/hashCode 由全部字段生成,而实体的身份应该只由主键决定,放进 Set 或做懒加载时行为会很奇怪。实体老实写普通 class,data class 留给 DTO。data class + val 原样上;不认(老式 JPA、老式 BeanUtils、部分 XML 绑定库)才上编译器插件。最坏的做法是为了迁就框架,把整个领域模型改成 var + 全可空 + 无参构造器——那等于把 Kotlin 的核心价值退掉,换来的只是少配一个插件。前面五卡讲了怎么混编,这一卡讲要不要混。互操作能力强不代表混编没有成本——它有一个具体的、可以量化的成本:构建复杂度和认知负担。什么时候这笔账算得过来,值得先想清楚。
一个模块里两种语言,编译分两趟
这不是构建工具的实现细节,是必然的:Kotlin 要引用 Java 类,Java 也要引用 Kotlin 类,循环依赖只能靠分阶段打破。的顺序是——
- kotlinc 先跑,参数里同时给它
.kt和.java。它会解析.java的源码来做符号解析,但不为它们产出字节码:跑完之后输出目录里只有MainKt.class/KHandler.class这些 Kotlin 的产物,没有JavaSide.class。 - 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) { ... }?. 和 !!;Java 的 static 字段变成 companion object 里的属性,Java 调用方那边全部编译失败;for 循环、三元表达式会被逐字翻译成笨拙的 Kotlin。转换只是第一步,之后必须做三件事:① 逐个 !! 判断该收敛成什么;② 检查有没有 Java 调用方被这次转换打断(@JvmStatic / @JvmField / @JvmName 补上);③ 跑一遍测试。跳过这三步的「一键转换」,基本等于把编译期错误换成了运行期错误。惯用法与 API 设计
语法学完了,接下来的差距在别处:同样的功能,有人写出来别人一眼能读懂、改起来不怕,有人写出来处处是坑。这一章讲 Kotlin 里那些「知道就少走很多弯路」的东西——用 value class 让类型系统替你抓参数写反、用 require/check 把前置条件说清楚、在异常与 Result 与 sealed 之间做出有理由的选择、用 buildList 这类构建器代替一堆临时变量,以及可见性和 API 稳定性该怎么把握。本章的擦除签名、异常类型、编译报错原文全部在本机 kotlinc 2.4.10 + JDK 25 上。
下面这个签名有什么问题: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.IllegalArgumentException | Failed requirement. |
requireNotNull(v) { msg } | 同上,专管 null,返回非空值 | java.lang.IllegalArgumentException | Required value was null. |
check(cond) { msg } | 对象自己的状态不对 | java.lang.IllegalStateException | Check failed. |
checkNotNull(v) { msg } | 同上,专管 null,返回非空值 | java.lang.IllegalStateException | Required value was null. |
error(msg) | 无条件终止,返回类型是 Nothing | java.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 抛回去。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 算 value | listOf("aa","bbb").associateWith { it.length } → {aa=2, bbb=3} |
associateBy { } | 元素当 value,lambda 算 key | listOf("aa","bbb").associateBy { it.length } → {2=aa, 3=bbb} |
takeIf { } / takeUnless { } | 满足条件返回自己,否则返回 null | 5.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 class | public 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 classf(oldName = x),你把参数改成 newName 他就编译不过。data class 尤其危险——它的 copy() 几乎总是用命名参数调的(u.copy(name = "new")),改一个属性名会同时打断构造器、copy 和解构(component1() 的顺序)三处。所以给 data class 的属性起名要一次到位,和给函数起名一样慎重。Config(host = "x", port = 8080)),根本不需要 Builder 模式。Java 里那套 new Builder().setA().setB().build() 在 Kotlin 里几乎总是多余的——除非这个 API 要给 Java 用,那时 12 章讲的默认参数不可见问题就来了,Builder 反而成了最省事的方案。DSL 构建
带接收者的 lambda 让 Kotlin 能把一段普通代码写成「看起来像另一门语言」的样子。这一章从这个机制本身讲起,做出一个真能跑的 HTML 构建器,再讲清 @DslMarker 解决的那个隐蔽 bug——最后讲什么时候该忍住不写 DSL。
Kotlin 的 DSL 没有任何魔法,全部来自一个类型:T.() -> Unit。它和普通的 (T) -> Unit 只差一件事——lambda 体内的 this 是谁。
两种函数类型的唯一区别
| 类型 | lambda 里怎么写 | 怎么调用它 |
|---|---|---|
(Menu) -> Unit | { m -> m.item("茶") },必须带前缀 | block(m) |
Menu.() -> Unit | { item("茶") },this 就是 Menu | m.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
}this 被换掉了,外层的 this 就被遮住。在类的成员函数里写 menu { … },块内的 this 是 Menu 而不是那个类——要访问外层实例得写限定形式 this@MyClass。这也是下一步 @DslMarker 要解决的问题的另一面。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 大多是这种「静默丢内容」,写完第一件事是跑一遍看输出,别只看编译过没过。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 里照样能调到外层的局部变量和同名的顶层函数,它拦不住。另外注解漏标是最常见的失效原因——加了注解却发现还能串味时,先检查是不是有个构建器类忘了标。@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 层还是逻辑层title("a"); title("b") 会怎样?漏调 body 又会怎样?)——这些在类型系统里表达不出来,必须在 build() 那一步集中检查并抛出可读的异常(require 抛 IllegalArgumentException,见 13 章)。很多自制 DSL 的坑就是「少写一句,静默出空结果」。测试与工具链
语言写得对,不等于工程立得住。这一章讲把 Kotlin 代码变成可交付工程的四件事:测试框架、协程的虚拟时间测试、Gradle 工程化配置与依赖版本管理、以及把「写得糙」在合入前挡下来的静态检查。
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"、coroutines1.10.2、serialization-json1.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 块,别在注解上找原因。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(连字符变点)。
本页的版本基线
| 组件 | 版本 | 说明 |
|---|---|---|
| Kotlin | 2.4.10 | 本页所有代码在此版本;K2 编译器自 2.0 起就是默认 |
| kotlinx-coroutines-core | 1.10.2 | kotlinx-coroutines-test 用同一版本号 |
| kotlinx-serialization-json | 1.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 使用,也不怕代码混淆裁剪。
它是三件套,缺一不可
- 编译器插件:
kotlin("plugin.serialization"),版本跟 Kotlin 走; - 运行时库:
kotlinx-serialization-json:1.9.0(格式各自独立:json / cbor / protobuf); - 注解:类上标
@Serializable。
缺插件和忘标注解都会在编译期失败,报的是「找不到该类型的序列化器」这一类错误——两种原因症状相近,所以先确认编译器插件是否真的应用上了(打开 build 脚本看 plugins 块),再回头查注解。这是本库最常见的第一道坎。
Json 配置:三个几乎总要开的开关
| 配置 | 作用 | 建议 |
|---|---|---|
ignoreUnknownKeys = true | JSON 里多出的字段直接忽略 | 对接外部 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 块data class 只有带参构造器、默认参数在字节码里是另一套机制,Jackson 拿到的常是全 null 或直接失败——必须加 jackson-module-kotlin 才能正确处理构造参数与默认值(这类 Java 生态与 Kotlin 的接缝问题见 12 章)。同一个项目里尽量只用一套。encodeToString / decodeFromString 都是 Json 实例上的方法,所以把配置好的 Json { … } 存成单例复用——每次现建一个既浪费又容易让不同调用点配置不一致。val json = Json { ignoreUnknownKeys = true } 放在顶层或伴生对象里。把「写得糙」在合入主干前挡下来,靠的是三道闸门:格式、坏味道、编译器自己的话。分工搞混会让工具互相打架。
ktlint 与 detekt 的分工
| ktlint | detekt | |
|---|---|---|
| 管什么 | 格式:缩进、空行、import 顺序、命名 | 坏味道:函数过长、圈复杂度、空 catch、可疑写法 |
| 可自动修 | 大部分能(--format) | 基本不能,要人判断 |
| 可配置度 | 刻意很低(按官方 code style 走) | 高,规则集可裁剪 |
| 类比 | Prettier | ESLint |
两个一起用是常规做法: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 章讲过类型擦除,标它之前先确认强转真的安全。./gradlew build 里挂上 ktlint 检查与 detekt,失败就挡合并。人工 code review 的注意力应该花在设计和边界条件上,格式与坏味道交给机器——这也是把 review 从「挑毛病」变成「聊方案」的关键一步。高级特性与前沿
最后一组话题:运行时反射的能力与代价、跨平台的现状与边界、Kotlin 2.x 到 2.4 真正落地的语言变化、被语言特性吃掉的那些设计模式,以及看懂编译产物之后该有的性能观。
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::class | User::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")),别赖传递依赖。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")) }
}
}java.time、String.format、Thread、正则的某些行为差异——本地跑 JVM 目标一切正常,一编 iOS 目标才报错。多目标项目要把所有目标都挂进 CI,不能只构建 JVM。另外 expect class 在不同平台上的成员必须严格对上,改一处忘了改另一处是家常便饭。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 → 实验特性,别进核心路径
// 语法错 → 版本不够,或语法已变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 章,本卡只谈模式层面。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, … 逐元素穿过,无中间集合
// 顺序永远是:先正确 → 再清晰 → 最后在有证据的地方优化javap 看一眼。「这个会不会装箱」「委托是不是每次都查表」「这个 lambda 有没有分配对象」——猜一小时不如看一分钟,而且看过一次就永远记住了。Kotlin 的界面:Compose 与桌面
Kotlin 是这几门语言里界面故事最集中的一个:Compose 一套写法同时覆盖 Android、桌面与 iOS。这一章讲 Compose 的心智模型、它和 Swing/JavaFX 的关系,以及不需要图形界面时终端这条路。
别的语言得在几个 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 出安装包org.jetbrains.kotlin.plugin.compose),跟着 Kotlin 版本走即可——但网上大量教程还停在旧的对应表上,照抄必然对不上。./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) } }
})SwingPanel 的回调里改 Compose 状态、或在 Compose 的副作用里碰 Swing 控件,都必须确认自己在 EDT 上——混用场景下的诡异卡顿和丢更新,多半出在这条边界。| 管道接走或重定向进文件时自动输出纯文本——不用你自己判断 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 章,问题几乎一定出在平台类型的边界上。
Result 还是 sealed、协程作用域归谁管,这些只能靠写真实项目积累。二是只在 Android 或只在服务端里用它:两边的惯用法差异不小(生命周期 vs 请求作用域、Main dispatcher 的有无),只见过一边容易把局部经验当成语言规则。上面四个项目特意跨了这两个场景,别只挑一个做。