Kotlin 完整知识体系交互讲解

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

上手:跑通第一个 Kotlin 程序

在讲任何语法之前,先让机器把你写的东西跑起来。Kotlin 的入口比多数语言绕:它没有自带运行时的解释器,编译产物要跑在 JVM 上,而真实项目又几乎全部由 Gradle 托管。这一章把这条链路一次讲通——挑一条上手路径、跑通第一个 fun main()、看懂它编译成了什么、读懂它报的第一条错,最后把单文件升级成能用第三方库的 Gradle 项目。后面所有章节的代码片段都默认你已经有一个能编译运行的环境。

Kotlin 没有「装好就能直接跑脚本」这回事——它先编译成 JVM 字节码再由 JVM 执行,所以无论走哪条路 JDK 都是前提java -version 能打印版本就算有,没有就装 17+)。区别只在于「谁替你调用编译器」。

三条路的对照

路径装什么第三方库断点
① Playground什么都不用不能不能
② 命令行 kotlincJDK + 解压编译器进 PATH要自己塞 -cp不便
③ IntelliJ IDEA一个 IDE,建项目时自动配好

学语法用 ①/②(想看编译产物或复现报错原文就用 kotlinc),代码一旦超过一个文件、或要 import 非标准库的东西就换 ③。Android 直接装 Android Studio。

三种跑法

  • 编译成 jarkotlinc hello.kt -include-runtime -d hello.jarjava -jar hello.jar。产出的不是本机可执行文件,清单里的 Main-Class: HelloKt 是编译器按文件名推出来的(下一卡细讲);
  • IDE 的绿三角fun main 行号左边那个三角——日常开发唯一会用的方式;
  • 当脚本跑:扩展名改 .kts不要 fun mainkotlinc -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 才能跑
最常见的卡点是把 Kotlin 当脚本语言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 不用 importkotlin.iokotlin.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(连字符变下划线),你按 MyAppKtjava -cp 会找不到。要固定这个名字,在文件顶部加 @file:JvmName("Hello")
javap 是 JDK 自带的,整本 Kotlin 都值得随手用它——Kotlin 的很多「魔法」在字节码层面都平平无奇:data class 生成的方法、扩展函数其实是静态方法,一看就清楚。

初学阶段大半时间花在读报错上。好消息是 Kotlin 的报错措辞高度模式化,认出模式就等于定位了原因。下面是 2.4(K2)的原文。

两条最常撞上的

  • initializer type mismatch: expected 'String', actual 'String?'——把可空值给了不可空变量StringString? 是两个类型;
  • 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)     ← 注意不是 KotlinNullPointerException
别把 NoClassDefFoundErrorunresolved reference 当同一个问题——方向相反:前者是运行期找不到类(打包或 classpath 漏了),后者是编译期编译器就不认识这个名字。一个查运行时 classpath,一个查依赖声明。
报错从第一条开始改,改完重编一次再看——类型推断会让一个错误连累后面一串。另外 K2(2.0 起)重写了诊断措辞,旧答案里的 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 a1val a2…)反而更难读——可读性优先于教条
val name = "Kotlin"      // 类型推断为 String
var count = 0              // 可重新赋值
count++                       // ✅ 合法
// name = "Java"            // ❌ val cannot be reassigned

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

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

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

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

基本类型与它们的 JVM 表示

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

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

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

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

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

  • JVM 对 -128..127Integer 有缓存,所以两个装箱的 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.20.30000000000000004,钱一律别用 DoubletoInt() 是截断不是四舍五入3.9.toInt()3(-3.9).toInt()-3(向零截断,要四舍五入用 roundToInt())。这三条都不会有任何警告,只会让结果悄悄错掉。
UInt / ULong / UByte / UShort 用于把位模式当无符号数解释的场合(协议解析、哈希、位运算)。它们不是「更大的整数」——UInt 照样 32 位,0u - 1u4294967295(回绕而不是报错)。日常业务算数别用它,类型间不能隐式互转会传染到整条调用链;需要更大范围就上 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 的三条限制(报错原文)

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

为什么值得区分 const valval

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

typealias 不是新类型

  • 只是个别名typealias UserId = String 之后,UserIdString 可以互相赋值,把订单号传进要用户 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 = 1val big: Long = 1(不写就是 Int);
  • 初始化表达式很长时,写出类型比让读者推演一遍链式调用更友好。

lambda 参数与 Nothing

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

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

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

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

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

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

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

控制流

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;逗号分隔即「或」
带主体 + inin 400..499 -> ... / !in list -> ...区间与集合成员判断
带主体 + isis String -> obj.length类型分派;命中后自动智能转换,分支里直接用具体类型的成员
无主体when { score >= 90 -> "A"; ... }替代 if-else 链;每个分支是任意布尔表达式

guard conditions:is X if 条件 ->

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// —— 标签跳转:定义处 outer@,使用处 break@outer ——
outer@ for (i in 1..3) {
    for (j in 1..3) {
        if (i == 2 && j == 2) break@outer
        print("$i$j ")
    }
}                                     // 11 12 13 21
for (i in 0..list.size) 必定越界。..区间,会一路数到 size 本身。在 listOf("a","b","c") 上跑,第四次迭代抛 java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3(注意异常类型是 ArrayIndexOutOfBoundsException——listOf 底层是数组包装,不是你以为的 IndexOutOfBoundsException,按后者去搜索会走弯路)。正确写法是 0..<list.sizelist.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@forEachreturn@mapreturn@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() 返回类型也是 Nothingerror("boom")java.lang.IllegalStateException: boomTODO("later")kotlin.NotImplementedError: An operation is not implemented: later
  • Nothing? 则是只装得下 null 的类型——这正是 var y = null 推断出来的东西(见 02 章)。

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

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

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

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

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

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

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

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

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

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

控制台输出与输入

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

文件读写

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

这一卡的写法只适合小工具

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

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

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

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

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

函数基础

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

varargUnitNothing 这三样东西的共同点是:它们在 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.namekotlin.UnitUnit === Unittrue(它是 object 单例);
  • val u: Any = println("x") 之后 u === Unittrue——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.ktFooKt」,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 sbvar count 的局部函数 emit,编译成了 private static final void report$emit(kotlin.jvm.internal.Ref$IntRef, java.lang.StringBuilder, java.lang.String)——注意两点: 捕获的变量变成了显式参数;修改var count 被包进了 Ref$IntRef 这个可变盒子;
  • 这正是 Kotlin 能突破「Java lambda 只能捕获 effectively final 变量」的方式——它不捕获变量本身,而是捕获一个装着变量的盒子。

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

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

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

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

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

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

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

写法得到的类型说明
::greet(String) -> String顶层 / 局部函数的引用;::greet.name 得到 "greet"
g::hig 是实例)(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 的 KClassx::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 + 14(即 2 pow2 (1 + 1)),而 (2 pow2 1) + 13——和多数人的直觉(「先算左边这个中缀」)相反,混写数学表达式时务必加括号。第二处是 ::class 能用不代表反射能用x::class.java 这类基本操作不需要额外依赖,但一旦碰 returnTypememberProperties 这些,运行时会抛 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」提升成了类型的一部分StringString? 是两个不同的类型,编译器不允许你把后者当前者用。于是「忘了判空」这类 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 KotlinNullPointerExceptionfalseKotlinNullPointerException 这个类型确实存在,但 !! 不用它。更麻烦的是它的 messagenullmsg=[null])——异常里除了行号什么线索都没有,所以别指望 !! 崩了以后日志能告诉你什么。真要断言就用 requireNotNull / checkNotNull 并写上消息。另外 x?.length 的类型是 Int? 不是 Int,拿去做算术还得再兜一次底,别以为 ?. 之后就万事大吉了。
?: 右边可以是 return / throw / continue——因为它们的类型是 Nothing,能塞进任何位置。这就是 val id = params["id"] ?: return 成立的原因,也是 Kotlin 代码普遍比 Java 平坦的原因:把「不满足就走人」写在一行里,剩下的代码全在主干上,不用嵌在 if 里。选断言函数时按「谁的错」分:参数不对是调用方的错,用 requireNotNullIllegalArgumentException);状态不对是自己的错,用 checkNotNullIllegalStateException)。

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

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

  • if (obj is String) { obj.uppercase() }——分支内 obj 直接当 String 用;
  • 对 null 同理:if (x != null) { x.length }——分支内 xString? 收窄成 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.NullPointerExceptionmessage 为 null——什么线索都没有

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

怎么堵这个缺口

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

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

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

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

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

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

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

第一层:让 null 根本不出现

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

try 是表达式

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

finallyuse:资源一定要关

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

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

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

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

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

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

// ── 把异常包成值:runCatching(详见 13 章)──
val r: Result<Int> = runCatching { input.toInt() }
println(r.getOrDefault(0))
没有受检异常意味着「会不会抛」只存在于文档里。你调用一个第三方 Kotlin 库的函数,编译器不会提醒它可能抛 IOException——这是 Kotlin 用编译期安全换来的便利,代价必须自己承担:写库函数时用 KDoc 的 @throws 标注,写应用时在架构边界(HTTP handler、消息消费者、协程根作用域)统一兜底,别指望在中间层被编译器提醒。另一个真会咬人的坑:runCatching 会连 CancellationException 一起吞掉,在协程里用它包 delay 之类的挂起函数,协程被取消后 onFailure 会拿到 JobCancellationException、而且后面的代码照常继续执行——这与「取消应该终止执行」的直觉完全相反,见 10 章。
「可预期的失败」和「意外」要分开处理。用户输入不是数字、文件不存在、网络超时——这些都在预期之内,用 *OrNull + ?: 或返回类型建模(见上一卡);数组越界、状态机走到不可能的分支——这些是 bug,让它抛出去、别 catch。判据:如果你 catch 之后写不出比「记个日志继续跑」更有意义的处理,那就不该 catch。另外 try 是表达式这件事也适用于 whenif,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=null
Sub.init tag=sub
注意第一行:tag 声明成非空 String,却打印出 null。原因是基类构造器先跑,而此时子类的 override val tag = "sub" 尚未执行,读到的是子类字段的零值。这个 null 能悄悄流进你的非空类型里,是 Kotlin 空安全少数几个被绕过的地方(和 05 章的平台类型并列)。规避方式:基类的 init 块和属性初始化器里不要碰 open 成员;确实需要就改用 by lazy 延迟到第一次真正使用时求值,或者把初始化挪到一个显式的 initialize() 方法里。
次构造器在 Kotlin 里很少需要。「几种不同的构造方式」用默认参数就够了——class User(val name: String, val age: Int = 18) 一句顶 Java 的两个重载构造器;「从别的类型转过来」用伴生对象里的工厂函数User.fromJson(...))比次构造器语义更清楚、还能起名字、还能返回缓存实例。真正需要次构造器的场景基本只剩「继承一个有多个构造器的 Java 类」。要给参数很多的类提供便利,先想默认参数 + 具名实参,别写 Builder。

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

三个修饰符的分工

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

属性也能重写

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

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

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

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

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

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

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

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

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

它到底生成了什么(javap

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

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

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

两个 Userid/name 相同但 note 一个是 "AAA"、一个是 "BBB"

  • a == btruea.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.memberstrue——两个对象共享同一个列表。copy 只是把引用照抄一遍。所以 data class 的属性应该是不可变类型List 而非 MutableListval 而非 var),否则「值语义」只是个错觉。

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

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

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

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

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

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

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

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

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

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

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

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

怎么选

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

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

enum 的 entries

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

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

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

    fun isWarm() = this == RED
}

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

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

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

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

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

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

by 类委托:装饰器零样板

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

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

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

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

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

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

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

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

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

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

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

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

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

val once = object : Logger {         // 匿名对象,一次性实现
    override val prefix = "tmp"
    override fun log(msg: String) { println(msg) }
}
by 委托不产生多态:被委托对象调用自己的方法时,不会走到你的重写上。上面的 Prefixedp.warn("careful") 输出的是 C: W: careful 而不是 C: [p] W: careful。原因是编译器给 warn 生成的转发实现是 inner.warn(...),而 Logger.warn 的默认实现里调的 loginner 自己的 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丢弃这次赋值scorevetoable { 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 而不是返回 nullKey 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——「只读」保证的是你拿这个引用改不了它,不是「没人能改它」。

三大集合的构造函数

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

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

它们运行时到底是什么

表达式运行时 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 不可变列表」——除了 EmptyListListBuilder,其余全是 JDK 自带的类。Kotlin 只读集合是编译期的类型约定,运行时并没有一层新的数据结构。

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

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

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

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

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

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

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

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

六族与判据

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

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

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

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

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

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

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

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

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

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

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

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

写法回调实际调用顺序含义
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、带种子的 generateSequencesequence { })随便消费几次都行;只有包着一个外部一次性源的才会炸——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.ConcurrentModificationExceptionmessagenull)。原因是 JDK 集合的迭代器带一个 modCount 快照,发现集合被绕过迭代器改动就立刻失败(fail-fast)。Mapfor ((k, v) in map) 里往 map 塞新键,同样抛。三种正确写法:

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

Kotlin 只读 vs Java unmodifiableList

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

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

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

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

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

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

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

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

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

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

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

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

变换与过滤

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

聚合与判断

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

分组、排序、取元素

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

Map 专属 · 拷贝 · 序列

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

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

// Map 的四个高频招
val m = mapOf("a" to 1)
m["z"]                       // null
m.getOrElse("z") { -1 }      // -1
m.getValue("z")              // NoSuchElementException: Key z is missing in the map.
mutableMapOf<String, Int>().getOrPut("k") { 9 }   // 9,并写回 map
sorted()sort()reversed()reverse() 只差一个字母,行为却相反:前者返回新集合、原集合不动,后者原地改、返回 Unitval 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::printlnPerson::name 可以直接当函数值用;
  • val f: (Int) -> String 在 JVM 上的运行时接口是 kotlin.jvm.functions.Function1——参数个数编进了名字(Function0Function22)。

SAM 转换:跟 Java 接口对接

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

怎么选:按目的对号入座

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

三个真实的易错点

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

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

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

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

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

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

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

// ❌ 坑二:嵌套 let,里层 it 遮蔽外层,无警告
// a.let { b.let { it /* 这是 b,a 够不着了 */ } }
a.let { outer -> b.let { inner -> "$outer / $inner" } }   // ✅ 显式命名
作用域函数是 Kotlin 最容易被滥用的特性,没有之一。三个具体症状:①为省一个变量名而套——val x = compute().let { it * 2 } 不如 val x = compute() * 2;②嵌套两层以上——it/this 指代靠数括号,且遮蔽不产生任何警告,是 code review 的重灾区;③applyrun——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.firstCharpublic 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.isWeekendResponse.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 推导式。标准库给的是具体的组合子foldflatMap?.letrunCatching?. / ?: 当 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 classcopy() 也是浅拷贝新对象,嵌套结构里层层 copy 更新一个深层字段,写起来长、跑起来也不便宜——深层更新频繁的场景,这正是该退回可变模型的信号。
把 Kotlin 当「默认不可变、局部可变」的语言用,而不是当纯函数式语言用。具体三条:①默认写 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).javaClasstrue

擦除带来的三个「不能」

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

擦除不擦什么

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

记忆法 PECSProducer 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> 的子类型,但 SourceString 之间毫无关系——型变说的是泛型类之间的子类型关系怎么随类型实参变化,不是类本身的继承。第二处:协变的 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 } 能写出来,全靠 forEachinline 的;
  • reified:函数体既然被搬到调用点,编译器就能把调用点那个具体类型填进去,于是 T::classx is Tas T 全部可用——这是绕过类型擦除(上上卡)的唯一办法。

四个修饰符一览

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

crossinline 为什么必须存在

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

error: 'return' is prohibited here.

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

什么时候不该 inline

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

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

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

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

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

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

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

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

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

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

约定名一览

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

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

解构按位置,不按名字

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

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

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

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

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

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

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

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

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

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

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

// 也就是说,把 data class 的字段顺序一改:
//   val (id, age) = user
// 所有解构点的含义静默反转。同类型字段时编译器一声不吭。
// ✅ 安全写法:字段多于 2~3 个就别解构,老实写 user.id / user.age
解构是按位置的,这让 data class 的字段顺序变成了公开 API。把两个同类型字段调换位置——比如 (val id: Int, val age: Int) 改成 (val age: Int, val id: Int)——所有 val (id, age) = user 的地方会静默把两个值对调,编译通过、测试可能也通过(如果测试数据不巧对称),线上才发现年龄变成了用户 ID。两个字段顺序不同的 data class 确实给出相反的解构结果。三条防线:①字段超过 2~3 个就别解构,写 user.id 清楚又安全;②data class 的字段顺序当成不可随意改的契约;③给 data class 加字段一律加在末尾并带默认值。
运算符重载只在「这个符号在该领域有公认含义」时才用。Vector + VectorMoney * 2Duration - DurationMatrix[i, j]——好。User + OrderConfig / 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)、成功时装什么开放(用泛型)。注意 ErrResult<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 }compareByDescendingthenBy
用在sorted()< >maxOrNull()coerceIn()sortedWith(cmp)maxWithOrNull(cmp)TreeMap(cmp)
怎么选「这个类型只有一种天经地义的排法」才实现它(金额、日期、版本号)业务上有多种排法(按名/按时间/按热度)——一律用 Comparator,别硬塞进 compareTo

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

泛型函数还是泛型类

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

Any? 还是 *

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

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

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

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

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

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

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

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

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

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

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

协程与结构化并发

协程是 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 实现的库函数:delayDeferred.awaitJob.joinyieldChannel.send/receiveMutex.lockFlow.collect 等。普通的算术、循环、IO 调用都不是挂起点——一个只做 CPU 计算的 suspend 函数从头跑到尾,一次都不会让出线程。

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

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

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

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

编译后发生了什么:Continuation

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

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

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

import kotlinx.coroutines.*

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

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

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

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

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

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

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

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

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

三者对照

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

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

async 的 await 要放在最后

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

runBlocking 是一堵墙

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

import kotlinx.coroutines.*

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

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

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

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

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

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

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

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

CoroutineScope 是什么

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

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

coroutineScope vs supervisorScope:失败会不会连坐

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

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

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

GlobalScope 是反模式

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

四个内置调度器

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

DefaultIO 共享同一个线程池,只是各自有不同的并发上限,所以两者之间 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{...}.isSuccessfalse,也就是根本没执行成。要在清理时做挂起操作(关连接、写日志、回滚),必须包一层 withContext(NonCancellable) { }——包上之后清理正常完成。这层只用来裹住短小的清理动作,别拿它包业务逻辑,否则又变回不可取消的代码了。

launch 与 async 的异常路径不同

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

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

import kotlinx.coroutines.*

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

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

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

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

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

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

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

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

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

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

Flow 与响应式流

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 { }不能从别的协程 emitemit 必须在 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 { },它提供 trySendawaitClose { }——后者是注销监听器的地方,忘了写就是资源泄漏。

Flow 的操作符分两类:中间操作返回新 Flow、不触发任何执行;终端操作是挂起函数、才真正开跑。这条分界和集合上的 Sequence 完全一致。

常用操作符

类别操作符说明
转换map · filter · transformtransform 最通用:一个输入可 emit 零到多个输出
限流/截断take · drop · distinctUntilChanged · debouncetake(n) 拿够就取消上游
副作用onEach · onStart · onCompletion都是中间操作,不会触发流
组合zip · combine · flatMapLatestzip 严格配对;combine 任一有新值就出
异常catch · retrycatch 只捕上游异常
终端collect · toList · first · fold · launchIn挂起函数,到这里才开始执行

flowOf(1..5).filter{奇数}.map{×10}.toList() 得到 [10, 30, 50]transform 里 emit 两次可以把 [1,2] 变成 [<1, 1>, <2, 2>]distinctUntilChanged()[1,1,2,2,2,3,1] 变成 [1,2,3,1]——它只去相邻重复,末尾那个 1 保留了。

上下文保持:flow { } 里不许换线程

Flow 有一条硬性规则叫上下文保持(context preservation):emit 必须发生在 collect 所在的那个协程上下文里。所以在 flow { } 内部写 withContext(Dispatchers.Default) { emit(x) },运行时会抛 IllegalStateException,报错原文:

  • Flow invariant is violated:
  • Flow was collected in [BlockingCoroutine{Active}@…, BlockingEventLoop@…],
  • but emission happened in [DispatchedCoroutine{Active}@…, Dispatchers.Default].
  • Please refer to 'flow' documentation or use 'flowOn' instead

这条规则存在的意义是:collector 永远知道自己的回调跑在哪个线程上。否则一个 collect { updateUI(it) } 就可能被上游偷偷丢到别的线程执行,UI 框架当场炸。

flowOn 才是换线程的正确姿势

flowOn(dispatcher) 只影响它上游的操作符,下游(包括 collect)不受影响。一条 flow { 打印线程 }.flowOn(Dispatchers.Default).collect { 打印线程 }:上游打出 DefaultDispatcher-worker-1,collector 打出 main——一条链上两个线程,各管各的。位置很重要:flowOn 写在链尾就影响整条链,写在中间就只影响它前面那一段。

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

// ① 中间操作 vs 终端操作
val f = flowOf(1, 2, 3, 4, 5)

f.filter { it % 2 == 1 }.map { it * 10 }.toList()   // [10, 30, 50]
f.take(2).transform<Int, String> { emit("<$it"); emit("$it>") }.toList()
// [<1, 1>, <2, 2>]  —— transform 可以一进多出
flowOf(1,1,2,2,2,3,1).distinctUntilChanged().toList()
// [1, 2, 3, 1]  —— 只去「相邻」重复

// ✗ 忘了终端操作:一行代码,零个副作用,不报错
f.onEach { save(it) }
// ✓
f.onEach { save(it) }.launchIn(scope)      // 返回 Job,可取消

// ② ✗ flow{} 里 withContext 换线程 → 运行时抛
val bad = flow { withContext(Dispatchers.Default) { emit(1) } }
bad.collect { }
// java.lang.IllegalStateException: Flow invariant is violated:
//   Flow was collected in [BlockingCoroutine{Active}@..., BlockingEventLoop@...],
//   but emission happened in [DispatchedCoroutine{Active}@..., Dispatchers.Default].
//   Please refer to 'flow' documentation or use 'flowOn' instead

// ✓ flowOn:只影响上游,下游不动
flow {
    println("upstream: " + Thread.currentThread().name)  // DefaultDispatcher-worker-1
    emit(heavyCompute())
}.flowOn(Dispatchers.Default)
 .collect {
    println("collector: " + Thread.currentThread().name) // main
 }

// ③ catch 只捕上游
pagedItems()
    .map { parse(it) }
    .retry(2) { it is java.io.IOException }
    .catch { e -> emit(Item.EMPTY) }   // 捕获上游异常,还能补发兜底值
    .collect { render(it) }             // ← 这里抛的异常 catch 接不住

只写中间操作、忘了终端操作,是 Flow 最安静的 bug:flow.onEach { save(it) } 单独一行,什么都不会发生,不报错、不警告(Kotlin 只会给出一个「返回值未使用」提示,很容易被忽略)。必须以 collect() / launchIn(scope) 结尾。

catch 的作用域也常被误解:它捕不到下游的异常。flow.catch{}.collect { 这里抛异常 } 里 collector 抛的异常不会被 catch 接住,会直接扔给调用方。要保护 collector 自身,用普通的 try/catch 包住整个 collect 调用。

onEach { }.launchIn(scope) 是「在某个作用域里后台消费一条流」的惯用写法,等价于 scope.launch { flow.onEach{}.collect() } 但更短,而且返回 Job 方便取消。catch 要放在链的末端附近——它只能捕获自己上游的异常,放在最前面等于什么都没保护。

当生产者比消费者快,怎么办?Flow 的默认答案是反压到生产者——emit 会挂起、等消费者处理完。想改变这个默认,只有三个开关,语义各不相同。

默认:串行,一个都不丢

不加任何操作符时,emitcollect同一个协程里交替执行——生产一个、消费一个、再生产下一个。所以总耗时是两者之和,但零丢失。这是最安全的默认,绝大多数场景不需要改。

三种策略的对比

同一条流(依次产出 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();确认「处理过程本身可以被打断且无副作用」才用 collectLatestcollectLatest 还有对应的中间操作 mapLatest / flatMapLatest,后者是搜索框「输入变化就取消上一次请求」的标准写法。

冷流是「配方」,每个订阅者各跑一份;热流是「广播」,数据独立于订阅者存在、所有人共享同一份。UI 状态、事件总线、传感器读数天然是热的。

StateFlow:永远有一个当前值,且会去重

MutableStateFlow(初始值) 必须有初始值,随时可以读 .value,新订阅者立刻收到当前值(先 value = "latest" 再订阅,第一个收到的就是 "latest")。

它有两个内建行为,都会「少发」值,必须知道:

  • 去重(相当于自带 distinctUntilChanged,用 equals 比较)——慢速依次设置 1,1,1,2,2,3,订阅者收到的是 [0, 1, 2, 3],重复值一次都没重复发;
  • 合并(conflated)——消费者慢时,中间值会被跳过。快速依次设置 1,2,3,4,5 而 collector 处理很慢,收到的是 [0, 5],中间的 2、3、4 整个消失

所以 StateFlow 表达的是「最新状态是什么」,绝不能拿它传「发生了什么事件」——事件会丢。

SharedFlow:可配置的广播

MutableSharedFlow(replay, extraBufferCapacity, onBufferOverflow) 没有初始值、不去重、默认 replay = 0。三条:

  • 没有订阅者时 emit 的值直接丢弃(先 emit 99 再订阅,订阅者只收到之后的 1, 2);
  • replay = 2 时,先 emit 1,2,3 再订阅,新订阅者立刻收到 [2, 3](最后两个);
  • 连续 emit 三个相同的 7replayCache[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原地修改了它内部的可变字段再赋回 .valueequals 判定相等,更新会被整个丢弃、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.ListList。唯一翻译不了的是可空性——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 / @NotNullorg.jetbrains.annotations,以及 JSR-305、androidx、jakarta 等一批常见注解包)会被 Kotlin 直接采信:标了 @Nullable 的方法,返回类型在 Kotlin 侧就是货真价实的 String?,不写安全调用编译不过;标了 @NotNull 的就是 String。中 annotatedNullable() 必须用 String? 接,annotatedNotNull().length 可以直接点出来。所以给要被 Kotlin 调用的 Java 代码补注解,是投入产出比最高的互操作改造——一行注解换掉一整类运行期 NPE。

集合是「映射」,不是「包装」

Kotlin 没有自己的集合实现,List / Map 这些名字在 JVM 上映射到的就是 java.util 里那套。所以跨语言传集合不复制、不包装、零成本。Java 侧 new ArrayList<>() 返回给 Kotlin,l.javaClass.name 打出来就是 java.util.ArrayList

Java 类型Kotlin 看到的
java.util.List<E>(Mutable)List<E>!——可读可写都行
java.util.Map<K,V>(Mutable)Map<K,V>!
ObjectAny!
int / int[]Int / IntArray
getX() / setX() 成对出现属性 x(只有 getter 就是只读属性)
static 成员用类名点出来,JavaSide.TAG
int... xsvararg xs: Int,传数组要 *arr 展开

(Mutable)List! 这个奇怪的写法意思是:可空性和可变性都不表态。把它声明成 List<String> 之后依然能 add——因为底下就是那个 ArrayList。「只读 ≠ 不可变」这件事在 07 章讲过,跨语言边界是它最容易咬人的地方。

SAM 转换只对 Java 接口自动生效

「单抽象方法接口可以传 lambda」是 Java 互操作的专属福利,Kotlin 自己的接口默认不享受。三种情况下来是这样:

被调方能传 lambda 吗
Java 的 interface Handler { String handle(String s); }JavaSide.run { s -> s + "!" } 直接过
Kotlin 的 fun interface KFunHandler { ... }fun 修饰符就是显式申请 SAM 转换
Kotlin 的普通 interface KHandler { ... }不能,只能写 object : KHandler { ... }

第三种的报错原文:error: argument type mismatch: actual type is '(??? (Unknown lambda parameter type)) -> ??? (Unknown lambda return type)', but 'KHandler' was expected.

为什么 Kotlin 要这么设计?因为它本来就有函数类型——想要一个「接收 String 返回 String 的东西」,直接写 (String) -> String 就够了,不需要先造一个接口。fun interface 是为了「这个接口要给 Java 用 / 要有名字和文档」时的补充。所以纯 Kotlin 的新 API 优先用函数类型,别下意识地照搬 Java 的 Listener 接口习惯。

// ── Java 侧(JavaSide.java)────────────────────────────
// public class JavaSide {
//     private String name = "bob";
//     public String getName()          { return name; }
//     public void   setName(String n)  { this.name = n; }
//     public static final String TAG = "javaside";
//     public static String maybeNull(boolean b) { return b ? "x" : null; }
//     @Nullable public static String annotatedNullable() { return null; }
//     public static List<String> makeList()  { ... }   // ["a","b"]
//     public static int sum(int... xs)       { ... }
//     public interface Handler { String handle(String s); }
//     public static String run(Handler h) { return h.handle("in"); }
// }

// ── Kotlin 侧 ─────────────────────────────────────────
val j = JavaSide()
j.name = "alice"                   // getName/setName 自动变成属性 name
println(JavaSide.TAG)                // static 成员:类名直接点出来

// 平台类型:两种用法,两种炸法
val ok: String = JavaSide.maybeNull(true)     // 立刻收敛成非空
// 传 false 时炸在这一行:
// java.lang.NullPointerException: maybeNull(...) must not be null

val plat = JavaSide.maybeNull(false)         // 类型是 String!,不收敛
plat.length                                    // 炸在这里,且只是个普通 NPE:
// java.lang.NullPointerException: Cannot invoke "String.length()" because "plat" is null

val n: String? = JavaSide.annotatedNullable()  // 有 @Nullable:编译器知道它可空

// 集合:映射而非包装
val l = JavaSide.makeList()         // 类型 (Mutable)List<String>!
println(l.javaClass.name)          // java.util.ArrayList —— 没复制也没包装
l.add("c")                        // 平台类型不表态可变性,能 add

JavaSide.sum(1, 2, 3)                // int... 变成 vararg
val arr = intArrayOf(4, 5)
JavaSide.sum(*arr)                   // 数组要用 * 展开

// SAM:Java 接口可以直接收 lambda
JavaSide.run { s -> s + "!" }        // "in!"

// Kotlin 自己的普通接口不行 ——
interface KHandler { fun handle(input: String): String }
runK { s -> s + "#" }
// error: argument type mismatch: actual type is '(???) -> ???', but 'KHandler' was expected.

fun interface KFunHandler { fun handle(input: String): String }
runKFun { s -> s + "?" }             // fun interface 就可以
把平台类型原样往下传,是互操作里最难查的一类 bug。里那个 Cannot invoke "String.length()" because "plat" is null,栈顶是你的 Kotlin 代码,而真正的肇事者是几层之外那个返回 null 的 Java 方法——异常里完全没有它。更糟的是平台类型会顺着数据结构扩散:把 String! 放进 data class 的非空字段里,构造那一刻不一定报错,等到几个模块之后再炸。规则很简单:从 Java 拿到的值,别让它活过当前这个函数。
平台类型是一种你写不出来的类型。IDE 的悬浮提示里能看到 String!,但你没法把它写进源码——这是故意的:语言不想给你一个「合法地不做空检查」的声明方式,只想让你在边界上做一次选择。所以看到 ! 就当成一句提问:「这里到底会不会是 null?」答案要么来自 Java 那边的文档/注解,要么来自你自己的断言。

反方向要麻烦得多:Kotlin 有一堆 Java 没有的概念——顶层函数、伴生对象、属性、默认参数。它们在字节码里都得找个 Java 表达得出来的形状落地,而默认落地方式往往对 Java 调用方很不友好。一组 @Jvm* 注解就是用来调整这个形状的。想知道你的 API 在 Java 眼里长什么样,别猜,javap -p 一下就全看见了。

顶层函数 → 一个以文件名命名的类

KotlinApi.kt 里的顶层函数会被塞进一个自动合成的类 KotlinApiKt,全部是 public static final。Java 侧就得写 KotlinApiKt.greet("java")——这个多出来的 Kt 后缀相当难看。文件头加一行 @file:JvmName("KotlinApi") 就能改掉它。如果你在写要给 Java 用的库,这一行几乎是必备的。

伴生对象:默认要绕一层 Companion

companion object 在字节码里是一个真实的内部类实例,挂在外层类的 public static final ... Companion 字段上。所以不加注解时 Java 只能写 Config.Companion.plain()。加了 @JvmStatic,编译器会额外在外层类上生成一份静态方法——注意是「额外」,javap 里 viaJvmStaticConfigConfig$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())@JvmOverloadscopy 无效(它是编译器生成的)。要给 Java 用的可变体,老实提供 Builder 或者具名工厂方法。
// ── Kotlin 侧 ─────────────────────────────────────────
@file:JvmName("KotlinApi")      // 否则这个类叫 KotlinApiKt
package demo

fun greet(who: String): String = "hi " + who       // 顶层函数

class Config(val host: String, var port: Int) {
    @JvmField val region: String = "cn"            // 直接暴露成字段
    companion object {
        const val VERSION = "1.0"                   // 天然就是 static final
        fun plain(): Config = Config("p", 1)
        @JvmStatic fun viaJvmStatic(): Config = Config("s", 2)
    }
}

class Dialog(val title: String, val message: String = "",
             val cancelable: Boolean = true)
class DialogO @JvmOverloads constructor(
             val title: String, val message: String = "",
             val cancelable: Boolean = true)

// ── javap -p demo/Config.class(真实输出)──────────────
// public final class demo.Config {
//   public static final demo.Config$Companion Companion;
//   private final java.lang.String host;
//   private int port;
//   public final java.lang.String region;             ← @JvmField:public 字段
//   public static final java.lang.String VERSION;     ← const val
//   public final java.lang.String getHost();
//   public final int getPort();
//   public final void setPort(int);
//   public static final demo.Config viaJvmStatic();   ← @JvmStatic 额外生成的一份
// }

// ── javap 对比默认参数 ────────────────────────────────
// Dialog(不加):  (String, String, boolean)
//                   (String, String, boolean, int, DefaultConstructorMarker)
// DialogO(加了): 以上两个,外加
//                   (String, String)
//                   (String)

// ── Java 侧 ───────────────────────────────────────────
// KotlinApi.greet("java");        // 顶层函数 → 静态方法
// c.getHost();  c.setPort(9090);  // 属性 → getter/setter
// c.region;                       // @JvmField → 直接字段
// Config.VERSION;                 // const val
// Config.Companion.plain();       // 没加 @JvmStatic,要绕 Companion
// Config.viaJvmStatic();          // 加了 @JvmStatic
// new DialogO("only-title");      // 加了 @JvmOverloads 才有这个构造器
// u.copy(2L, u.getName(), u.getEmail());   // copy 只能全参调
Kotlin 侧的泛型擦除会制造 Java 看不见的签名冲突。fun sum(xs: List<Int>)fun sum(xs: List<String>) 在 Kotlin 看来是两个不同的重载,但擦除后 JVM 签名一模一样。报错:
error: platform declaration clash: The following declarations have the same JVM signature (sum(Ljava/util/List;)I):
    fun sum(xs: List<Int>): Int
    fun sum(xs: List<String>): Int
解法就是给其中一个加 @JvmName("sumStrings")——这是 @JvmName 除了改文件类名之外最常见的用途。注意加了 @JvmName 之后 Java 侧看到的是新名字,Kotlin 侧还是原名,两边不一致,记得在文档里写清楚。
javap -p 当成互操作的验收工具。写完一个准备给 Java 用的 Kotlin 模块,编译出来 javap -p 扫一眼,看到的就是 Java 调用方看到的全部——哪个方法名带了后缀、哪个构造器少了重载、哪个字段被 getter 包了起来,全在上面。这比在 Java 侧写测试代码试探快得多,也比查文档可靠。

Java 有受检异常(checked exception):方法签名里写了 throws IOException,调用方就必须catch 或继续声明,否则编译不过。Kotlin 把这套机制整个删掉了——它的所有异常都是不受检的。这个决定在跨语言边界上会直接表现为一个编译错误,而且报错信息乍看毫无道理。

Kotlin 为什么不要受检异常

不是偷懒,是二十年经验的结论:受检异常在大型代码库里的实际形态,往往是满屏的 catch (Exception e) { } 空吞和层层向上的 throws Exception 声明污染——它强制你「处理」,但没法强制你「处理得对」。更硬的技术原因是它和高阶函数合不来list.map { it.read() } 里那个 lambda 抛出的受检异常,得由 map 的签名声明出来,而 map 是通用的,它没法知道。Java 8 引入 lambda 之后也撞上了同一堵墙(这正是 Stream API 里处理受检异常特别别扭的原因)。

后果:Java 侧 catch 不了你声明的异常

Kotlin 函数就算实实在在抛 IOException,字节码签名里也没有 throws 子句。javac 于是认定「这个 try 块不可能抛出 IOException」,直接报错。

javap 里的签名Java 侧 catch (IOException e)
不加 @ThrowsString readNoThrows(String);编译报错
error: exception IOException is never thrown in body of corresponding try statement
@Throws(IOException::class)String readWithThrows(String) throws java.io.IOException;正常编译、正常捕获

关键在于理解这只是编译期的信息缺失,不是运行期行为差异:把 catch 换成 catch (Exception e),不加 @Throws 的那个函数照样抓得到 java.io.IOException : boom: x。异常一直都在,只是编译器不知道它会来。

@Throws 该加在哪

  • 只对会被 Java 调用的公开 API 有意义。纯 Kotlin 项目里加了也不会有任何效果——Kotlin 侧照样不强制 catch。
  • 覆写 Java 接口方法时要注意:Java 接口里声明了 throws 的方法,你的 Kotlin 实现如果要抛,也得自己加 @Throws,否则子类签名比父类窄,Java 调用方拿到接口引用时的 catch 又落空。
  • 不改变 Kotlin 侧的任何行为——纯粹是往字节码里补一条 throws 子句,给 javac 看的。
import java.io.IOException

fun readNoThrows(path: String): String = throw IOException("boom: " + path)

@Throws(IOException::class)
fun readWithThrows(path: String): String = throw IOException("boom: " + path)

// ── javap -p demo/ApiKt.class(真实输出)──────────────
// public static final java.lang.String readNoThrows(java.lang.String);
// public static final java.lang.String readWithThrows(java.lang.String)
//                                              throws java.io.IOException;
//                                              ↑ 只有 @Throws 的那个带 throws 子句

// ── Java 侧(真实编译/运行结果)───────────────────────
// try { ApiKt.readNoThrows("x"); }
// catch (IOException e) { ... }
// error: exception IOException is never thrown in body of
//        corresponding try statement

// try { ApiKt.readNoThrows("x"); }
// catch (Exception e) { ... }        // 这样能编过,而且运行时确实抓到:
// C1 java.io.IOException : boom: x   // ← 异常一直都在,只是编译器不知道

// try { ApiKt.readWithThrows("y"); }
// catch (IOException e) { ... }      // 有 @Throws:可以精确 catch
// C2 java.io.IOException : boom: y
那句报错的字面意思会把人带沟里。exception IOException is never thrown in body of corresponding try statement 读起来像是「你的代码不会抛这个异常」,于是很多人第一反应是去检查 Kotlin 那边的逻辑,甚至怀疑异常被吞了。实际上它抛得好好的——javac 只是在照着方法签名做静态判断,而签名里没有 throws看到这句报错,先去看 Kotlin 侧有没有 @Throws,别去看业务逻辑。
别把 @Throws 当成「文档注解」到处撒。它有实打实的约束力:一旦某个公开方法标了 @Throws(IOException::class),Java 调用方就会写上 catch;将来你想把这个异常换掉,就是破坏性 API 变更。所以只在「这个异常确实是契约的一部分、调用方本来就该处理」时才标——和你在 Java 里决定要不要用受检异常的判据是一样的。

平台类型的机制第一卡讲完了,这一卡讲工程纪律:从 Java 拿到的值,在哪一行、用什么方式变成一个 Kotlin 类型系统认识的类型。原则只有一句——让它在边界上就炸,别让它活着往下走。因为 null 引发的故障,唯一昂贵的部分是「从崩溃点回溯到源头」。

四种收敛姿势,按信息量排序

写法失败时你拿到什么用在哪
val x: String = j.getName()NullPointerException: getName() must not be null——带方法名默认选择。零成本、零噪音,报错已经指名道姓
requireNotNull(v) { "配置 db.url 缺失" }IllegalArgumentException: 配置 db.url 缺失需要业务语义的地方——配置加载、外部输入校验
checkNotNull(v) { "..." }IllegalStateException: ...「不该是 null,因为这个对象的状态本该保证它不是」
v!!NullPointerException没有任何消息几乎总有更好的选择
v ?: default不失败确实有合理兜底值时——这才是最好的结局

requireNotNull / checkNotNull 相对 !! 的额外好处是它们有返回值val v = requireNotNull(s) 之后 v 的类型已经是非空的 String,后面不需要再做任何智能转换的铺垫。这两个函数的完整语义见 13 章。

!! 什么时候还算合理

只有一种:你刚刚才亲手证明过它非空,而编译器由于智能转换的限制看不出来。典型是可变属性(var 可能被别的线程改)、跨 lambda 边界的局部变量。除此之外,写 !! 通常意味着你把一个「本该在类型里表达的事实」降级成了运行期赌博。更严重的是 a!!.b!!.c!! 这种连环写法——抛出来的 NPE 只有一个行号,三个 !! 到底是哪个炸的,得看字节码或者猜。真要这么写,拆成多行。

lateinit:给依赖注入准备的后门

Spring、Dagger、Koin 这类框架都是「先构造对象、再往里塞依赖」,而 Kotlin 的非空属性要求在构造函数结束前就有值——两边直接冲突。lateinit var 就是为此开的口子:跳过初始化检查,未赋值就访问时抛一个专门的异常,而不是含糊的 NPE。

kotlin.UninitializedPropertyAccessException: lateinit property repo has not been initialized
  • 只能用于 var,且类型必须非空、不能是原始类型(Int/Long 这些不行,因为它们没有「未初始化」这个状态可用)。
  • ::repo.isInitialized 可以查是否已赋值,但只能在声明它的类内部写。在外部调用报错:error: backing field of 'var repo: String' is not accessible at this point.
  • 什么时候该用 by lazy 而不是 lateinit值能自己算出来就用 lazy(还能保持 val),要等外部塞进来才用 lateinitDI 属于后者。
// ① 默认做法:立刻收敛。炸在这一行,报错自带 Java 方法名
val name: String = javaObj.getName()

// ② 需要业务语义时:requireNotNull
val url = requireNotNull(loader.load("db.url")) { "配置 db.url 缺失" }
// java.lang.IllegalArgumentException: 配置 db.url 缺失
// url 的类型已经是 String,后面直接用

// ③ !! —— 只留一个没消息的 NPE
val bad = loader.load("db.url")!!
// java.lang.NullPointerException      ← 什么都不告诉你

// ④ 有合理兜底就别抛
val port: Int = props.getPort() ?: 8080

// ⑤ lateinit:框架先造对象、再注入依赖
class Service {
    @Inject lateinit var repo: Repository     // @Inject 来自 jakarta.inject

    fun ready() = ::repo.isInitialized          // 只能在类内部写
    fun use() = repo.find(1)
}
// 没注入就用:
// kotlin.UninitializedPropertyAccessException:
//     lateinit property repo has not been initialized

// 在类外部查 isInitialized(报错):
// error: backing field of 'var repo: String' is not accessible at this point.

// ⑥ 值能自己算 → 用 lazy,还能保持 val
class Report {
    val heavy: Index by lazy { buildIndex() }
}
lateinit 会悄悄传染。它一旦出现在某个属性上,这个类就多了一个「构造完成但还不能用」的中间状态,而这个状态在类型上是看不见的——任何拿到实例的人都可能撞上 UninitializedPropertyAccessException。它在 DI 框架下是合理的(框架保证了注入先于使用),但很多人拿它当「我懒得想初始化顺序」的万能贴,最后一个类里五个 lateinit,谁先谁后全靠默契。判据:这个属性是不是由一个你信得过的框架/生命周期保证会被赋值?不是的话,改成构造函数参数或 by lazy
把「收敛」写成一个薄适配层,比在业务代码里到处判空划算得多。做法是给要用的 Java 类型写一批扩展函数或一个小 wrapper,在那里一次性把 String! 变成 StringString?、把 Optional<T> 变成 T?、把「空字符串代表没有」变成真正的 null。之后整个 Kotlin 侧就都活在一个诚实的类型系统里,空安全的价值才真正兑现——05 章讲的那套机制,只有在边界被守住之后才成立。

前面几卡讲的是语言层面的翻译规则。真正的时间消耗其实在另一处:Java 生态里大量框架依赖运行期反射,而它们的假设是「Java Bean」——无参构造器、可变属性、非 final 字段。而 Kotlin 最推荐的写法(data class + 全 val)在这三条上全不满足。这一卡讲这类摩擦怎么解。

data class + val 为什么会出错

看 javap 就清楚了:data class User(val id: Long, val name: String, val email: String? = null) 编出来是——

private final long id;
private final java.lang.String name;
public demo.User(long, java.lang.String, java.lang.String);
public demo.User(long, String, String, int, DefaultConstructorMarker);
public final long getId();
public final demo.User copy(long, java.lang.String, java.lang.String);

没有无参构造器,字段全是 final,没有 setter。老式的反射框架先 newInstance() 再逐个 setXxx(),第一步就失败。而且 Kotlin 的默认参数、可空性、value class 这些信息都存在 @Metadata 注解里,纯 Java 的反射根本读不到——所以「能不能反序列化」和「反序列化对不对」是两个问题:一个不认识 Kotlin 的 JSON 库通过 Unsafe 把 null 硬塞进非空字段,等你读它的时候才 NPE。

三类解法,按框架对号入座

框架它要什么Kotlin 侧怎么办
Jackson无参构造器 + setter,或构造器参数名jackson-module-kotlin 并注册它。这个模块读 @Metadata,认得主构造器、默认参数和可空性,非空字段收到 null 会直接报错而不是硬塞
JPA / Hibernate无参构造器 + 非 final 的实体类Gradle 上 kotlin("plugin.jpa")(底层是 no-arg 插件,给 @Entity 合成一个无参构造器)+ kotlin("plugin.allopen") 把实体类改成 open 供代理继承
Spring能被 CGLIB 代理的类(不能 final)kotlin("plugin.spring"),它是 all-open 的预设,自动给 @Component / @Configuration 等开 open
kotlinx.serialization什么都不要——编译期生成序列化器纯 Kotlin 项目的首选:没有反射、跨平台、data class + val 原样能用

这几个插件的共同点是:它们都是编译器插件,改的是字节码,不是你的源码。你的 Kotlin 代码看起来还是不可变的 data class,只有框架通过反射看得到那个多出来的无参构造器。

两个日常选型

  • Optional<T> vs T?:Kotlin 侧一律用 T?Optional 是 Java 在没有可空类型时的补丁——它是一个真实对象(要分配、要拆箱、不能进原始类型),而 T? 是编译期信息,运行时零成本。只在跨越 Java API 边界时才让 Optional 出现,进门就 .orElse(null) 转成 T?;出门(返回给 Java 调用方)时再包回去。
  • 静态工具类 vs 顶层函数:Java 里 StringUtils.isEmail(s) 这种「一个类当命名空间」的写法,在 Kotlin 里没有必要——顶层函数本来就在一个自动合成的类里。更进一步,能写成扩展函数就写成扩展函数:s.isEmail() 的调用点在 IDE 里可以自动补全出来,StringUtils.isEmail(s) 得先想起有这个类。但要给 Java 用的话,记得配 @file:JvmName,否则那边看到的是 StringExtKt
// Kotlin 侧照常写不可变的 data class
data class User(val id: Long, val name: String, val email: String? = null)

// javap -p demo/User.class(真实输出,节选)
// private final long id;                     ← final 字段
// public demo.User(long, java.lang.String, java.lang.String);
// public final long getId();                 ← 只有 getter
// public final demo.User copy(long, String, String);
// —— 没有无参构造器,没有 setter

// ── build.gradle.kts:让 Java 生态用得上它 ────────────
// plugins {
//     kotlin("jvm")            version "2.4.10"
//     kotlin("plugin.spring")  version "2.4.10"   // all-open 预设
//     kotlin("plugin.jpa")     version "2.4.10"   // no-arg:合成无参构造器
// }
// dependencies {
//     implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// }

// Jackson:注册模块之后,data class + val 直接可用
val mapper = jacksonObjectMapper()
val u: User = mapper.readValue(json)

// 纯 Kotlin 项目更省事:kotlinx.serialization 编译期生成,零反射
@Serializable
data class User2(val id: Long, val name: String)
val u2 = Json.decodeFromString<User2>(json)

// Optional 只活在边界上,进门立刻转成 T?
fun find(id: Long): User? = repo.findById(id).orElse(null)

// 工具类 → 顶层扩展函数
// Java 思维: class StringUtils { static boolean isEmail(String s) }
fun String.isEmail(): Boolean = contains("@")      // 调用处:s.isEmail()
no-arg / all-open 插件是「给框架开的后门」,不是「给你开的」。no-arg 合成的无参构造器是 synthetic 的,Kotlin 侧调不到,但它确实存在——意味着框架可以造出一个所有字段都是默认值(引用类型全是 null)的实例,哪怕你声明的是非空 val。JPA 的懒加载代理、Hibernate 的部分初始化实体,都可能让你在 val name: String 上读到 null。另外 data class 当 JPA 实体本身就是个坏主意equals/hashCode 由全部字段生成,而实体的身份应该只由主键决定,放进 Set 或做懒加载时行为会很奇怪。实体老实写普通 classdata class 留给 DTO。
选型顺序:先问「这个框架认不认 Kotlin」。认(jackson-module-kotlin、kotlinx.serialization、Exposed、Ktor)就什么都不用改,data class + val 原样上;不认(老式 JPA、老式 BeanUtils、部分 XML 绑定库)才上编译器插件。最坏的做法是为了迁就框架,把整个领域模型改成 var + 全可空 + 无参构造器——那等于把 Kotlin 的核心价值退掉,换来的只是少配一个插件。

前面五卡讲了怎么混编,这一卡讲要不要混。互操作能力强不代表混编没有成本——它有一个具体的、可以量化的成本:构建复杂度和认知负担。什么时候这笔账算得过来,值得先想清楚。

一个模块里两种语言,编译分两趟

这不是构建工具的实现细节,是必然的:Kotlin 要引用 Java 类,Java 也要引用 Kotlin 类,循环依赖只能靠分阶段打破。的顺序是——

  1. kotlinc 先跑,参数里同时给它 .kt.java。它会解析 .java 的源码来做符号解析,但不为它们产出字节码:跑完之后输出目录里只有 MainKt.class / KHandler.class 这些 Kotlin 的产物,没有 JavaSide.class
  2. javac 再跑,把上一步的输出目录放进 -cp,此时 Java 代码就能看到 Kotlin 编出来的类了。

Gradle 的 kotlin("jvm") 插件默认就是这个顺序,正常情况下你什么都不用配。但它有一个真实的限制:Kotlin 看得见你手写的 .java,却看不见 javac 阶段由注解处理器生成的 Java 源码——因为那时 Kotlin 已经编完了。这也是 KAPT(把注解处理接到 Kotlin 编译里)和后来 KSP 存在的原因之一。

渐进迁移:唯一靠谱的策略

  • 新代码写 Kotlin,旧代码原地不动。这是绝大多数团队实际走的路,也是 Kotlin 互操作设计的目标场景。旧 Java 代码不重写、不「顺手改改」,除非你本来就要动它。
  • 改动某个 Java 文件时才顺带转换它——因为你反正要读懂它、要重新测它,转换的边际成本最低。IDE 的「Convert Java File to Kotlin」能做机械翻译,但产出的是「能跑的 Kotlin」不是「好的 Kotlin」:满屏 !! 和平台类型残留,转完必须人工过一遍可空性。
  • 从叶子往上转,别从核心接口开始。工具类、DTO、纯函数最好转;被几十处引用的公开接口最后转——它一改,两边的调用点都得跟着动。
  • 先给要留着的 Java 代码补 @Nullable / @NotNull这是投入最小、收益最直接的一步:不用改一行逻辑,Kotlin 侧的平台类型立刻变成真类型。

什么时候该保持 Java

情况建议
代码稳定、没人动、测试覆盖好别碰。重写的唯一确定收益是引入新 bug
重度依赖注解处理器(老式 APT)先评估 KSP 有没有对应实现,没有就先留着
要给纯 Java 项目当依赖发布的库可以用 Kotlin 写,但每个公开 API 都得考虑 @Jvm*,成本不低;API 表面很大时 Java 更省事
团队里没人熟 Kotlin混编会让「读懂一个模块」需要两套知识——先培训,或者整模块切换而不是逐文件混
字节码/性能极敏感的热点代码差别通常可以忽略,但 inlinevalue class、协程的字节码形态和 Java 不同,真要抠就,别假设
# 一个模块里两种语言,编译必须分两趟(下面是验证过程)

# ① kotlinc 先跑,参数里同时给它 .kt 和 .java
$ kotlinc -d out src/Main.kt src/JavaSide.java
$ find out -name '*.class'
out/KHandler.class
out/KFunHandler.class
out/MainKt.class
# ← 没有 JavaSide.class:.java 只是被「读」来做符号解析,不产出字节码

# ② javac 再跑,把 Kotlin 的产物放进 classpath
$ javac -cp out -d out src/JavaSide.java

# Gradle 的 kotlin("jvm") 插件默认就是这个顺序,通常无需配置:
# plugins { kotlin("jvm") version "2.4.10" }
# src/main/java 和 src/main/kotlin 下的文件会被一起处理

# ── 迁移时最划算的第一步:给要留着的 Java 补注解 ──────
# // 改之前:Kotlin 侧看到 String!
# public String findName(long id) { ... }
#
# // 改之后:Kotlin 侧看到 String?,编译器开始帮你干活
# @Nullable public String findName(long id) { ... }
IDE 的「Convert Java File to Kotlin」转出来的代码不能直接提交。它做的是保守的机械翻译:Java 里所有可能为 null 的地方,它都翻译成可空类型然后到处补 ?.!!;Java 的 static 字段变成 companion object 里的属性,Java 调用方那边全部编译失败;for 循环、三元表达式会被逐字翻译成笨拙的 Kotlin。转换只是第一步,之后必须做三件事:① 逐个 !! 判断该收敛成什么;② 检查有没有 Java 调用方被这次转换打断(@JvmStatic / @JvmField / @JvmName 补上);③ 跑一遍测试。跳过这三步的「一键转换」,基本等于把编译期错误换成了运行期错误。
用「这段代码接下来六个月会不会被改」当迁移优先级的判据,比用「这段代码有多难看」靠谱得多。会被反复修改的代码,转成 Kotlin 的收益(空安全、更少样板、更好的表达力)会持续兑现;一个五年没动过的工具类转成 Kotlin,收益是零,风险却是实打实的。15 章讲的那套测试基线,是决定「敢不敢转」的前提——没有测试的模块,先补测试再谈迁移。

惯用法与 API 设计

语法学完了,接下来的差距在别处:同样的功能,有人写出来别人一眼能读懂、改起来不怕,有人写出来处处是坑。这一章讲 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 classinit 块是放校验的最佳位置。把「合法的邮箱」这个约束写进类型里之后,整个系统里只要拿到一个 Email 实例,就不需要再校验第二次——这就是所谓「让非法状态无法表示」。对比一下到处 if (!s.contains("@")) throw ... 的写法:那种做法里,你永远不知道手上这个 String 有没有被校验过。注意 init 块会在每次构造时执行,构造本身仍然是免费的(没有对象分配),但校验逻辑该多快还是得多快。

「这个参数不能是负数」「这时候连接必须已经建立」——这类前置条件人人都在写,但写成 if (x < 0) throw IllegalArgumentException("...") 又长又容易挑错异常类型。Kotlin 标准库把它们做成了四个内联函数,语义分工很清晰:谁的错,就抛谁的异常

四个函数,四种「谁错了」

函数语义抛出默认消息
require(cond) { msg }调用方给的参数不对java.lang.IllegalArgumentExceptionFailed requirement.
requireNotNull(v) { msg }同上,专管 null,返回非空值java.lang.IllegalArgumentExceptionRequired value was null.
check(cond) { msg }对象自己的状态不对java.lang.IllegalStateExceptionCheck failed.
checkNotNull(v) { msg }同上,专管 null,返回非空值java.lang.IllegalStateExceptionRequired value was null.
error(msg)无条件终止,返回类型是 Nothingjava.lang.IllegalStateException
assert(cond) { msg }开发期自检,默认不生效java.lang.AssertionError(仅 -ea

require 和 check 的分界线就是「责任在谁」require(amount > 0)——你传了个负数,是你的错,IllegalArgumentExceptioncheck(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 的类型就是 Stringv.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这意味着 OutOfMemoryErrorStackOverflowError 这些「JVM 已经不健康了」的信号也会被它悄悄变成一个 Result.failure,程序带着一个坏掉的运行时继续往下跑。

val r = runCatching { throw OutOfMemoryError("simulated OOM") }
r.exceptionOrNull()  →  java.lang.OutOfMemoryError : simulated OOM

② 在协程里它会吞掉 CancellationException协程的取消机制是靠抛 CancellationException 实现的(10 章讲过),而 runCatching 一视同仁地把它接住了。后果是:协程已经被取消,但你的代码毫不知情,继续往下执行。输出:

caught=kotlinx.coroutines.JobCancellationException
取消之后这行照样执行            ← job.isCancelled 已经是 true 了

解法:协程里要么老实用 try/catch (e: Exception),要么在 onFailure 里把 CancellationException 重新抛出去。这是协程代码里最常见的一类「取消不生效」bug 的根因。

Result 作为返回类型:现在没有限制了

历史上 Result 刚引入时(Kotlin 1.3)不允许当返回类型,要加 -Xallow-result-return-type 才行,网上大量老资料还停在这个说法。这个限制早已解除:在 2.4.10 上,fun parse(s: String): Result<Int>Result 当参数、当属性、放进 List、写成 Result<Int>?,全部无需任何 opt-in,编译零警告。

真正还剩的性质是:Result 本身是个 value class,所以它遵守上一卡那套擦除规则——fun takes(r: Result<Int>) 的 JVM 签名是 takes(java.lang.Object),而放进 List<Result<Int>> 或写成可空时会装箱成真正的 kotlin.Result 对象。这在绝大多数场景下都无所谓。

// ① 异常:失败 = bug 或环境问题,让它冒上去
fun loadConfig(path: String): Config {
    val text = File(path).readText()      // 文件不存在就该炸
    return parse(text)
}

// ② T?:失败只有一种原因,一望而知
fun findUser(id: Long): User? = cache[id]

// ③ Result:要带原因,但调用方不分种类处理
fun parsePort(s: String): Result<Int> = runCatching { s.toInt() }
parsePort("x").getOrElse { 8080 }
// Failure(java.lang.NumberFormatException: For input string: "x")

// ④ sealed:失败分几种,调用方要分别处理
sealed interface LoginResult {
    data class Ok(val token: String) : LoginResult
    data object WrongPassword       : LoginResult
    data class Locked(val until: Instant) : LoginResult
    data class Failed(val cause: Throwable) : LoginResult
}
when (result) {                      // 编译器逼你处理每一种
    is LoginResult.Ok            -> goHome(result.token)
    LoginResult.WrongPassword    -> showError("密码错误")
    is LoginResult.Locked        -> showLocked(result.until)
    is LoginResult.Failed        -> showRetry()
}

// ── runCatching 陷阱 ①:它 catch 的是 Throwable ────────
val r = runCatching { throw OutOfMemoryError("simulated OOM") }
println(r.exceptionOrNull())
// java.lang.OutOfMemoryError : simulated OOM   ← 连 Error 都吞

// ── runCatching 陷阱 ②:在协程里吞掉取消 ───────────────
launch {
    val out = runCatching { delay(10_000) }
    println("caught=" + out.exceptionOrNull()?.javaClass?.name)
    println("取消之后这行照样执行")
}
// 输出:
// caught=kotlinx.coroutines.JobCancellationException
// 取消之后这行照样执行            ← 此时 job.isCancelled 已经是 true

// 协程里的安全写法:把取消放回去
val safe = runCatching { risky() }
    .onFailure { if (it is CancellationException) throw it }
runCatching { }.getOrNull() 是最容易写出的、也最糟的一种错误处理。它把三件事一起做了:捕获所有 Throwable(含 Error 和协程取消)、丢掉全部失败信息、返回一个和「正常的 null」无法区分的 null。排查线上问题时,这行代码的位置会是一片彻底的空白——没有日志、没有栈、没有痕迹。如果你只想要「失败就给个默认值」,至少写成 runCatching { ... }.onFailure { log.warn("...", it) }.getOrElse { default },让失败留下痕迹。在协程里还要额外把 CancellationException 抛回去。
公开 API 用 sealed,内部实现用 Result,这条分界很好使。公开 API 的失败种类是契约的一部分——写进 sealed interface 之后,调用方的 when 会在你新增一种失败时编译报错,逼他去处理,这正是你想要的;而内部实现里失败往往就是「出错了,记个日志重试一下」,Result 的组合子(map / recover / getOrElse)能省掉大量样板。别在公开 API 上返回 Result:调用方拿到一个 Throwable,只能靠 is SomeException 去猜你可能抛什么,等于把契约藏进了实现。

这一卡是一组「知道了就再也不想用别的写法」的标准库函数,外加一条同样重要的反向建议:作用域函数用过头,代码会变得比它替代掉的那版更难读。

buildList / buildMap / buildString:命令式地攒,函数式地交

有些集合天生是「一条条攒出来的」——有条件地加、循环里加、从几个来源合并。传统写法是 val list = mutableListOf<String>() ... 一堆 add ... 最后 return list,问题是那个 MutableList 变量泄漏了出去,返回类型写 List 也只是障眼法(07 章讲过,强转回 MutableList 照样能改)。

buildList { } 把构建期和使用期切开:块里是 MutableList,块外拿到的是一个真正不可变List。这不是说说而已:

val l = buildList { add(1); add(2) }
l.javaClass.name  →  kotlin.collections.builders.ListBuilder
(l as MutableList<Int>).add(99)
→  java.lang.UnsupportedOperationException

它和「先 mutableListOf 再声明成 List」不同——那一种强转回去会改成功;而 listOfbuildList 一样会抛(底层是 Arrays$ArrayList,见 07 章)。buildList 是目前构造只读集合最安全的方式。buildMap / buildSet / buildString 同理(buildString 底下是 StringBuilder,是拼字符串的默认选择)。

四个高频小函数

函数做什么
listOfNotNull(a, b, c)过滤掉 null,结果类型是 List<T> 而不是 List<T?>listOfNotNull("a", null, "b")[a, b]
associateWith { }元素当 key,lambda 算 valuelistOf("aa","bbb").associateWith { it.length }{aa=2, bbb=3}
associateBy { }元素当 value,lambda 算 keylistOf("aa","bbb").associateBy { it.length }{2=aa, 3=bbb}
takeIf { } / takeUnless { }满足条件返回自己,否则返回 null5.takeIf { it > 3 }55.takeUnless { it > 3 }null

associateWithassociateBy 谁是 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 指向什么,而拆成三行带名字的局部变量,每一行都自解释。
  • 嵌套的 letit 会打架。外层的 it 被内层遮蔽,写出来的代码能编过但意思和你想的不一样。真要嵌套,给参数起名user.let { u -> ... }
  • ?.let { } 不是万能的判空写法。只在「非空时才做一件事」时用;如果两边都有分支(?.let { } ?: run { }),老实写 if (x != null) 更清楚——而且 if 之后有智能转换,不需要 it
  • apply 只用于配置对象(连续设置属性然后返回它自己),also 只用于旁路(打日志、加校验,不改变链上的值)。用 apply 去做计算、用 also 去改值,都是在制造只有作者读得懂的代码。
// buildList:块里可变,块外拿到真正只读的 List
val routes = buildList {
    add("/health")
    if (devMode) add("/debug")
    addAll(plugins.map { it.path })
}
println(routes.javaClass.name)
// kotlin.collections.builders.ListBuilder

(routes as MutableList<String>).add("/x")
// java.lang.UnsupportedOperationException   ← 真的改不了

val headers = buildMap {
    put("Accept", "application/json")
    if (token != null) put("Authorization", "Bearer " + token)
}

val report = buildString {          // 底下就是 StringBuilder
    appendLine("== 报告 ==")
    items.forEach { appendLine("- " + it) }
}

// 四个高频小函数(输出在注释里)
listOfNotNull("a", null, "b")                        // [a, b],类型是 List<String>
listOf("aa", "bbb").associateWith { it.length }   // {aa=2, bbb=3}
listOf("aa", "bbb").associateBy   { it.length }   // {2=aa, 3=bbb}

val n = 5
n.takeIf { it > 3 }        // 5
n.takeUnless { it > 3 }    // null

// takeIf 最好用的地方:把一个 if 塞进可空链
val shown = name?.takeIf { it.isNotBlank() } ?: "(空)"

// ── 作用域函数:好的用法与失控的用法 ──────────────────
// 好:apply 配置对象,also 旁路日志
val conn = Connection().apply {
    timeout = 5_000
    retries = 3
}.also { logger.info("建连: " + it) }

// 坏:一条链三个作用域函数,三个 it 各指一处
// val x = a.let { ... }.also { ... }.run { ... }

// 坏:两边都有分支时硬用 let
// user?.let { save(it) } ?: run { logMissing() }
// 好:老实写 if,之后还有智能转换
if (user != null) save(user) else logMissing()
buildList 的那个 receiver 千万别泄漏出去。块里那个 this 是个 MutableList,如果你在块里把它存到外面(赋给外部变量、传给别的对象保存),构建结束之后再通过那个引用去 add,行为是未定义的——标准库明确说了 builder 在 build 之后不该再被使用。这个坑不常见,但一旦踩到会表现为「集合内容莫名其妙变了」或者 UnsupportedOperationException,非常难查。规则:buildList 的块里只做添加,不做保存。
需要「攒一个集合」时,先问能不能用 map / filter / flatMap 直接表达。buildList 很好用,但它终究是命令式的;如果逻辑本质上是「对每个元素做个变换」,那 items.map { ... } 更短也更难写错。buildList 的主场是真正需要条件分支和多来源合并的构建——比如上面那个「基础路由 + 开发模式路由 + 插件路由」,用函数式写法反而要拼三段。

「哪些东西是给外面用的」这个问题,在写库时决定了你以后能改什么、不能改什么;在写应用时决定了模块之间会不会长出意料之外的依赖。Kotlin 比 Java 多了一个 internal,而它在 JVM 上的实现方式有个必须知道的漏洞

四个可见性修饰符

修饰符范围注意
public所有人Kotlin 的默认值(Java 的默认是包私有,这是个常见误判)
internal同一个模块「模块」= 一次编译的单元(一个 Gradle 子项目 / 一个 Maven 模块),不是包
protected本类 + 子类不包括同包,比 Java 的 protected 窄
private本类内;顶层声明则是本文件内顶层 private 是「文件私有」,这是 Kotlin 特有的好东西

internal 的真相:Java 侧其实看得见

JVM 字节码里根本没有「模块」这个访问级别,所以 internal 只能靠改名来实现:编译器把成员名后面加上「$ + 模块名」,让别的模块的 Kotlin 代码找不到它。javap

class Service {
    internal fun internalMember(): String = "im"
    internal val internalProp: Int = 1
}
→ public final java.lang.String internalMember$main();
  public final int getInternalProp$main();

注意那个 public——它对 JVM 而言是完全公开的。Java 代码只要照着改编后的名字写,就能直接调用,s.internalMember$main() 返回 "im",一点障碍都没有。

而且改名不是全都改,下来是这样:

声明javap 里的样子
internal fun(类成员)internalMember$main()——改名
internal val(类成员)访问器 getInternalProp$main()——改名
internal fun(顶层)topLevelInternal()——不改名,Java 直接能调
internal classpublic final class demo.InternalClass——不改名,Java 直接能 new

所以 internal 是一个「对 Kotlin 编译器有效、对 JVM 无效」的约束。它足以防止团队内部误用,但不能当安全边界——真要防止被外部访问,靠的是不把这个 artifact 发出去,或者用 JPMS 的模块系统。

@PublishedApi 与显式 API 模式

  • @PublishedApipublic inline 函数的函数体会被复制到调用方,所以它里面用到的东西必须在调用方那里也可见。直接用 internal 的东西,报错:error: public-API inline function cannot access non-public-API function. 给那个 internal 声明标上 @PublishedApi 就能编过——含义是「它在字节码层面是公开的,但请把它当内部实现,我随时可能改」。
  • explicitApi():写库时在 build.gradle.ktskotlin { } 块里加这一行(等价编译器参数 -Xexplicit-api=strict,还有个 warning 档),编译器就会强制你给每个公开声明写出可见性和返回类型。报错原文:
    error: visibility must be specified in explicit API mode.
    error: return type must be specified in explicit API mode.

为什么 public API 必须显式写返回类型?因为类型推断是根据实现推出来的——fun parse(s: String) = s.split(",") 推出 List<String>,哪天你把实现改成 s.split(",").toSet(),返回类型悄悄变成 Set<String>所有调用方的二进制兼容性就断了,而你的源码 diff 里看不出任何 API 变化。显式写出返回类型,等于把这件事变成一个必须主动修改的声明——review 时一眼可见。

// ── internal 在 JVM 上靠改名实现 ──────────────────────
class Service {
    internal fun internalMember(): String = "im"
    internal val internalProp: Int = 1
}
internal class InternalClass { fun member() = "ic" }
internal fun topLevelInternal() = "tli"

// javap -p demo/Service.class(真实输出)
// public final java.lang.String internalMember$main();   ← 改名了,但还是 public
// public final int getInternalProp$main();
//
// javap demo/InternalClass.class
// public final class demo.InternalClass { ... }          ← 完全没改
//
// javap -p demo/VisKt.class
// public static final java.lang.String topLevelInternal();  ← 顶层的也没改

// Java 侧照着改编后的名字,确实叫得动(跑通):
// s.internalMember$main()        →  "im"
// new InternalClass().member()   →  "ic"
// VisKt.topLevelInternal()       →  "tli"

// ── @PublishedApi:public inline 要用 internal 就得标 ──
internal fun helper() = "h"
inline fun useIt() = helper()
// error: public-API inline function cannot access non-public-API function.

@PublishedApi internal fun helper2() = "h"
inline fun useIt2() = helper2()        // 这样就能编过

// ── 显式 API 模式(写库必开)─────────────────────────
// build.gradle.kts
// kotlin { explicitApi() }        // 等价 -Xexplicit-api=strict
// kotlin { explicitApiWarning() } // 迁移期先用 warning 档

class Widget(val name: String) { fun render() = "<" + name + ">" }
// error: visibility must be specified in explicit API mode.
// error: return type must be specified in explicit API mode.

// 改成:
public class Widget2(public val name: String) {
    public fun render(): String = "<" + name + ">"
}
别把 internal 当安全或隔离手段。三个具体后果:① Java 代码照着 $模块名 后缀就能调进来(跑通);② 反射完全不受影响,任何基于反射的框架都看得见 internal 成员;③ 改名本身还会制造兼容性问题——internal 成员的 JVM 名字里带着模块名,改一下 Gradle 子项目名,字节码签名就变了,已编译的下游会 NoSuchMethodError。所以 internal 的正确定位是「给同事的信号」而不是「给编译器的墙」:它表达「这是实现细节,别依赖」,不表达「你碰不到」。
写库的默认姿势:explicitApi() 打开,然后把「不确定要不要公开的」一律先写成 internal把一个 internal 改成 public 永远是安全的(没人依赖它),反过来把 public 收回去就是破坏性变更。API 表面越小,你以后能改动的自由度越大——这是设计库时最重要的一条纪律,比任何具体的命名或结构选择都重要。配套工具是 binary-compatibility-validator:它把公开 API 的签名 dump 成一个文本文件提交进仓库,任何 API 变动都会体现为这个文件的 diff,review 时藏不住。

这一卡不是风格指南的罗列,只挑官方编码约定里真正影响可读性、而且有明确理由的几条讲——每条都回答「为什么」,而不是「规定如此」。

工厂函数可以用类名大写开头

Kotlin 的函数命名规则是小驼峰,但有一个官方认可的例外:返回某个类型的工厂函数,可以和那个类型同名fun Money(amount: Long, currency: String): Money = MoneyImpl(...) 在调用点读起来和构造器一模一样。

为什么值得这么做?因为它把「怎么造出来」和「是什么」解耦了:调用方写的是 Money(100, "CNY"),你可以在背后返回缓存的实例、返回子类、做参数归一化,甚至哪天把 Money 从 class 改成 interface——调用方一行都不用改。真构造器做不到这些(constructor 必须返回这个类的新实例)。标准库自己大量这么干:listOf / mutableListOf / Regex(...) / Comparator { ... },你从来没有 new ArrayList() 过。

扩展函数放在哪

判据是「谁拥有这个语义」,而不是「被扩展的是谁」:

  • 领域相关的扩展fun Order.totalPrice(): Money)放在领域类型旁边——它属于订单这个概念,只是恰好写成了扩展。
  • 对第三方/标准库类型的通用扩展fun String.isEmail())单独开文件,按被扩展的类型命名(StringExt.kt)。别搞一个 Extensions.kt 收容一切——半年后那个文件会有八百行,谁也不敢删里面任何一个函数。
  • 扩展函数是静态分发的(08 章讲过):它不参与多态,父类型引用上调扩展函数,调到的是父类型那个版本。所以需要多态的行为不能写成扩展,这是个硬约束不是风格问题。

表达式体的边界在哪

fun area(r: Double) = PI * r * r 这种写法很省事,但它有个隐性代价:返回类型被推断出来,读代码的人得自己在脑子里算。几条实用界线:

  • 一眼能读完就用表达式体,需要中间变量、需要早退、超过两三行,就用块体。
  • 公开 API 一律显式写返回类型,理由上一卡讲过(推断出来的类型会随实现悄悄改变)。
  • when / if 作为表达式体是合适的——它们本身就是表达式,形状和意图一致。
  • 返回 Unit 的函数别写成表达式体fun log(s: String) = println(s) 能编过,但它把「这是个副作用」伪装成了「这是个求值」。

什么时候该用命名参数

命名参数的价值全在调用点的可读性,所以判据也在调用点:

  • 布尔字面量必须命名。createUser("bob", true, false) ——这两个值是什么?读的人得跳到定义去看。createUser("bob", isAdmin = true, sendEmail = false) 自解释。这是最有价值的一条。
  • 连续多个同类型参数要命名。rect(0, 0, 100, 200) 到底是 (x, y, w, h) 还是 (left, top, right, bottom)?——顺便说,这种情况更好的解法是上一卡的 value class,让编译器来抓。
  • 跳过默认参数时必须命名(这是语法要求,不是风格)。
  • 反过来:参数少、类型各异、名字已经很清楚时,硬加命名参数只是噪音。max(a, b) 不需要写成 max(a = a, b = b)

还有一条 API 设计上的连带影响:参数名是公开契约的一部分。只要有人用命名参数调你的函数,你改参数名就是破坏性变更——和改函数名一样严重,但很多人意识不到。

// ① 工厂函数用类名大写开头 —— 调用点读起来像构造器
interface Money { val amount: Long; val currency: String }

fun Money(amount: Long, currency: String): Money =
    if (amount == 0L) ZeroMoney else MoneyImpl(amount, currency)
// 调用点:Money(100, "CNY")  —— 实现随时可以换,调用方无感
// 标准库同款:listOf / mutableListOf / Regex(...) / Comparator { ... }

// ② 扩展函数:领域相关的放领域旁边,通用的单开文件
// Order.kt      → fun Order.totalPrice(): Money
// StringExt.kt  → fun String.isEmail(): Boolean
// 不要: Extensions.kt 收容一切

// ③ apply 用于配置(返回接收者本身)
val conn = Connection().apply {
    timeout = 5_000
    retries = 3
}

// ④ 表达式体的边界
fun area(r: Double) = PI * r * r                  // 好:一眼读完

fun grade(score: Int): String = when {              // 好:本来就是表达式
    score >= 90 -> "A"
    score >= 60 -> "B"
    else        -> "F"
}

fun log(s: String) = println(s)                   // 坏:Unit 函数伪装成求值
fun log2(s: String) { println(s) }                 // 好

// 公开 API 显式写返回类型 —— 否则改实现会悄悄改签名
public fun parse(s: String): List<String> = s.split(",")

// ⑤ 命名参数:布尔字面量和同类型连排
createUser("bob", true, false)                     // 这两个是什么?
createUser("bob", isAdmin = true, sendEmail = false)  // 自解释

rect(0, 0, 100, 200)                              // (x,y,w,h)?(l,t,r,b)?
rect(x = 0, y = 0, width = 100, height = 200)      // 或者干脆用 value class
参数名是公开契约,改名 = 破坏性变更。这条特别容易被忽略:函数签名的类型没变、行为没变、单元测试全绿,但只要有任何调用方写了 f(oldName = x),你把参数改成 newName 他就编译不过。data class 尤其危险——它的 copy() 几乎总是用命名参数调的(u.copy(name = "new")),改一个属性名会同时打断构造器、copy 和解构(component1() 的顺序)三处。所以给 data class 的属性起名要一次到位,和给函数起名一样慎重。
「有几个参数才该考虑 Builder / 参数对象」——Kotlin 的答案比 Java 大得多。因为有默认参数 + 命名参数,一个八参数的构造器在 Kotlin 里调起来完全可读(Config(host = "x", port = 8080)),根本不需要 Builder 模式。Java 里那套 new Builder().setA().setB().build() 在 Kotlin 里几乎总是多余的——除非这个 API 要给 Java 用,那时 12 章讲的默认参数不可见问题就来了,Builder 反而成了最省事的方案。

DSL 构建

带接收者的 lambda 让 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 就是 Menum.block(),像调成员函数

正因为第二种把接收者变成了隐式的 this,嵌套一层就得到 head { title("…") } 这种读起来像标记语言的写法。DSL 的全部秘密就在这里,剩下的都是收尾工作。

标准库里早就在用了

  • apply 的签名就是 fun <T> T.apply(block: T.() -> Unit): T——你每次写 obj.apply { … },用的就是这个机制(08 章讲过作用域函数的选择)。
  • buildString { append("x="); append(1) } 里的接收者是 StringBuilderbuildListbuildMap 同理。它们就是标准库自带的小 DSL。

输出

右侧代码原样编译运行(Kotlin 2.4.10),输出为:

  • A 咖啡, 茶 B 咖啡, 茶——两种写法结果完全一样,差别只在书写;
  • C 可乐——apply 走的是同一条路;D x=1——buildString 同理;
  • E this is Menu——在 lambda 里打印 this::class.java.name,确实是 Menu
// 一个十几行、真能跑的最小 DSL
class Menu {
    private val items = mutableListOf<String>()
    fun item(name: String) { items.add(name) }
    override fun toString() = items.joinToString(", ")
}

fun menu(block: Menu.() -> Unit): Menu {
    val m = Menu()
    m.block()          // 把 m 当接收者调用
    return m
}

fun menu2(block: (Menu) -> Unit): Menu {   // 对照:普通参数版
    val m = Menu()
    block(m)
    return m
}

fun main() {
    val a = menu {
        item("咖啡")    // this 就是 Menu,直接写
        item("茶")
    }
    println("A $a")                // A 咖啡, 茶

    val b = menu2 { m ->
        m.item("咖啡")  // 必须写 m.
        m.item("茶")
    }
    println("B $b")                // B 咖啡, 茶

    // apply / buildString 是标准库里的同一机制
    println("C " + Menu().apply { item("可乐") })   // C 可乐
    println("D " + buildString { append("x="); append(1) })  // D x=1

    menu { println("E this is " + this::class.java.name) } // E this is Menu
}
接收者 lambda 里 this 被换掉了,外层的 this 就被遮住。在类的成员函数里写 menu { … },块内的 this 是 Menu 而不是那个类——要访问外层实例得写限定形式 this@MyClass。这也是下一步 @DslMarker 要解决的问题的另一面。
接收者 lambda 常配 inline:DSL 的 builder 函数标 inline 后 lambda 会被内联进调用处,既省掉每次构建的函数对象分配,也允许 lambda 里写 return。标准库的 applybuildString 都是 inline 的(代价见 16 章)。

把上一张卡的机制套两层,就是「类型安全构建器」:每种标签一个类,每个类只暴露它合法的子标签,于是写错结构在编译期就被拦住。

三个零件

  • 基类 Tag 持有子节点与属性,并提供受保护的 add(child, block):先用 child 当接收者跑 block,再把 child 挂到自己名下——所有嵌套都走这一个函数。
  • 子类限定词汇表Html 只有 headbodyHead 只有 titleBody 才有 h1pdivhead { h1("…") } 直接编译不过——这就是「类型安全」三个字的含义。
  • 入口函数 fun html(block: Html.() -> Unit): Html = Html().apply(block),一行。

输出(Kotlin 2.4.10 真跑)

右侧程序原样运行,标准输出是:

输出
1–2<html>  <head>
3–5    <title>      我的页面    </title>
7–  <body> 下依次是 <h1><p>,然后是带属性的 <div class="box">,其内再嵌一层 <p>

缩进由 render(sb, indent) 递归时把 indent 加两个空格产生,不需要任何额外状态。

为什么 add 要泛型

protected fun <T : Tag> add(child: T, block: T.() -> Unit): T 里的 T 让 block 的接收者精确到具体子类:传 Head() 进去,block 里就只能调 Head 的方法。写成 Tag 就退化成「什么标签都能往哪儿塞」,类型安全立刻丢失(09 章讲过这类型参数传播)。

open class Tag(private val name: String) {
    private val children = mutableListOf<Any>()
    val attrs = mutableMapOf<String, String>()

    protected fun <T : Tag> add(child: T, block: T.() -> Unit): T {
        child.block(); children.add(child); return child
    }
    fun text(s: String) { children.add(s) }

    fun render(sb: StringBuilder, indent: String) {
        val q = '"'
        val a = attrs.entries.joinToString("") { " " + it.key + "=" + q + it.value + q }
        sb.appendLine("$indent<$name$a>")
        for (c in children) when (c) {
            is Tag -> c.render(sb, "$indent  ")
            else   -> sb.appendLine("$indent  $c")
        }
        sb.appendLine("$indent</$name>")
    }
    override fun toString() = StringBuilder().also { render(it, "") }.toString()
}

// 每个类只暴露它合法的子标签 —— 这就是「类型安全」
class Html : Tag("html") {
    fun head(block: Head.() -> Unit) = add(Head(), block)
    fun body(block: Body.() -> Unit) = add(Body("body"), block)
}
class Head : Tag("head") {
    fun title(s: String) = add(Tag("title")) { text(s) }
}
open class Body(name: String) : Tag(name) {
    fun h1(s: String) = add(Tag("h1")) { text(s) }
    fun p(s: String)  = add(Tag("p"))  { text(s) }
    fun div(block: Div.() -> Unit) = add(Div(), block)
}
class Div : Body("div")

fun html(block: Html.() -> Unit): Html = Html().apply(block)

fun main() {
    print(html {
        head { title("我的页面") }
        body {
            h1("Hello DSL")
            p("类型安全构建器")
            div {
                attrs["class"] = "box"
                p("嵌套内容")
            }
        }
    })
}
/* 输出:
<html>
  <head>
    <title>
      我的页面
    </title>
  </head>
  <body>
    <h1>
      Hello DSL
    </h1>
    <p>
      类型安全构建器
    </p>
    <div class="box">
      <p>
        嵌套内容
      </p>
    </div>
  </body>
</html>                                    */
add 里的顺序不能反:必须先跑 child.block() 再把 child 加进 children?其实两种顺序都能出正确结果,真正会出错的是忘了 children.add(child)——DSL 照常编译、照常运行、一个错都不报,只是渲染出来少了一整棵子树。构建器类的 bug 大多是这种「静默丢内容」,写完第一件事是跑一遍看输出,别只看编译过没过。
真实项目别自己造:kotlinx.html 就是这套结构的完整实现(含全部 HTML 标签与转义),Ktor 服务端可以直接 call.respondHtml { … }。自己写一遍的价值在于——之后看任何 Kotlin DSL 的源码,你都知道它在干什么。

嵌套 DSL 有个隐蔽 bug:内层 lambda 里,外层接收者的方法照样能调。因为两个接收者都在作用域里,编译器按最近的找,找不到就往外找——于是手滑写错一层,代码照常编译、照常运行,只是内容跑到了错误的地方。

不加注解时的 bug(真跑出来的)

下面 OuterouterOnlyInnerinnerOnly。在 inner { … } 内部误写了 outerOnly("oops")

  • 编译通过,运行也不报错
  • 输出是 LOG [OUTER:a, INNER:b, OUTER:oops]——那条本该属于内层的内容,被隐式接收者悄悄记到了外层账上。

放到 HTML DSL 里就是:在 body { p { … } }p 里写了 h1("…"),标题跑到了段落外面,页面结构错了但没人报错。

加上 @DslMarker 之后

自定义一个注解并标上 @DslMarker,再把它打在所有 DSL 类上。规则是:同一个 marker 标记的多个隐式接收者,只有最内层的那个可用。同样的代码在 Kotlin 2.4.10 下编译直接失败,原文是:

  • error: 'fun outerOnly(s: String): Unit' cannot be called in this context with an implicit receiver. Use an explicit receiver if necessary.

报错还提示了逃生门:确实要访问外层,就写显式接收者——this@build.outerOnly("…")(用标签限定 this)。DslMarker 挡的是「隐式」,不是「不许访问」。

用法要点

  • marker 注解自身要能标在类上,惯例写 @DslMarker annotation class MyDsl
  • 打在每一个构建器类上(漏一个,那个类就还会串味);也可以打在它们共同的基类上由子类继承;
  • marker 是按注解身份区分的:两套不相干的 DSL 各用各的 marker,混用时互不干扰。
// ① 不加 marker:内层能调到外层的方法,编译运行都不报错
class Outer {
    val log = mutableListOf<String>()
    fun outerOnly(s: String) { log.add("OUTER:$s") }
    fun inner(block: Inner.() -> Unit) { Inner(this).block() }
}
class Inner(val parent: Outer) {
    fun innerOnly(s: String) { parent.log.add("INNER:$s") }
}
fun build(block: Outer.() -> Unit): Outer = Outer().apply(block)

build {
    outerOnly("a")
    inner {
        innerOnly("b")
        outerOnly("oops")   // 手滑写错层 —— 没人拦你
    }
}
// 输出: LOG [OUTER:a, INNER:b, OUTER:oops]

// ② 加 marker:同样的代码,编译期就被拦下
@DslMarker
annotation class MyDsl

@MyDsl class Outer { /* 同上 */ }
@MyDsl class Inner(val parent: Outer) { /* 同上 */ }

/* kotlinc 2.4.10 报错原文:
   error: 'fun outerOnly(s: String): Unit' cannot be called in this
   context with an implicit receiver. Use an explicit receiver if necessary.
           outerOnly("oops")
           ^^^^^^^^^                                                  */

// ③ 确实要访问外层:用带标签的 this 显式限定
build {
    inner { this@build.outerOnly("故意的") }   // OK
}
@DslMarker 只管隐式接收者,不管作用域里的普通变量和顶层函数:内层 lambda 里照样能调到外层的局部变量和同名的顶层函数,它拦不住。另外注解漏标是最常见的失效原因——加了注解却发现还能串味时,先检查是不是有个构建器类忘了标。
这是 DSL 里最重要也最少被讲的一环——写构建器时把 @DslMarker 当成必做项,成本是三行代码,换来的是一整类「静默写错结构」的 bug 由编译器代劳。kotlinx.html、Ktor、Gradle Kotlin DSL 全都这么做。

DSL 让调用方好看,代价全落在别处。写之前先问一句:这个 API 会被写多少次?如果答案是「几次」,那就别写。

三笔要算清的账

  • IDE 补全变差:光标在 { } 里时,补全列表是当前接收者的全部成员——层数一多,用户很难从列表看出「这一层到底能写什么」,而普通函数的参数列表是直接显示的。
  • 报错难懂:DSL 出错时编译器说的是构建器类型和 lambda 推断的话,不是领域的话。上一张卡那句 cannot be called in this context with an implicit receiver 已经算友好的了。
  • 新人上手成本:DSL 是一门你自己发明的小语言,没有文档就没人会用,而且它的规则不能靠读函数签名推出来。

判据

情形建议理由
配置类(大量可选项、有默认值)值得命名参数一多就难读,DSL 分组更清楚
树状/嵌套结构(HTML、UI、路由表)值得嵌套是 DSL 唯一无可替代的强项
领域概念稳定、会被写很多次值得一次投入摊到成千上万个调用点
一次性逻辑、只在一两处用别写普通函数就够,DSL 的学习成本收不回
参数少(三五个)且都必填别写命名参数 + data class 更清晰、补全更好
只是想「看起来高级」别写这是最常见的真实动机,也是最差的理由

先试试更便宜的方案

Kotlin 里 DSL 的最大竞争对手是命名参数 + 默认值 + data classConfig(host = "localhost", port = 8080, retries = 3) 一样自解释,还免费获得 IDE 参数提示、copy()、解构和相等性。只有当嵌套层级出现、或可选项多到一屏放不下时,DSL 才真正开始占优。

真实案例:都符合上面的判据

  • Gradle Kotlin DSLbuild.gradle.kts):配置 + 嵌套 + 领域极稳定,还借 DSL 拿到了 Groovy 版没有的类型检查与补全(15 章细讲)。
  • kotlinx.html:HTML 本身就是树,语言结构与领域结构一一对应。
  • Ktor 的路由与安装块routing { get("/") { … } },同样是稳定领域上的嵌套配置。
  • Jetpack Compose 的 UI 描述也建立在接收者 lambda 之上(本页以纯语言与 JVM 为主线,Compose 只在此作为例子提及,不展开)。
// 场景:一个有 3 个参数的配置 —— 别写 DSL
data class Retry(
    val times: Int = 3,
    val delayMs: Long = 500,
    val backoff: Double = 2.0,
)
val r = Retry(times = 5, delayMs = 200)
// 自解释、IDE 有参数提示、白送 copy()/解构/相等性 —— DSL 一样都没有

// 同样的东西写成 DSL:多了 30 行实现、少了参数提示、还得写文档
class RetryBuilder {
    var times = 3
    var delayMs = 500L
    var backoff = 2.0
    fun build() = Retry(times, delayMs, backoff)
}
fun retry(block: RetryBuilder.() -> Unit) = RetryBuilder().apply(block).build()
val r2 = retry { times = 5; delayMs = 200 }   // 收益≈0

// 值得写 DSL 的形状:嵌套 + 可选项多 + 领域稳定
server {
    port(8080)
    routing {
        get("/health") { ok() }
        route("/api") {
            get("/users") { listUsers() }   // 嵌套才是 DSL 无可替代的地方
        }
    }
}

// 折中:核心是普通函数和 data class,只在最外层包一层薄 DSL
// —— 内部逻辑好测好复用,出问题也容易分清是 DSL 层还是逻辑层
别把校验写进 DSL 的调用顺序里。构建器天然允许用户以任意顺序、任意次数调用那些方法(title("a"); title("b") 会怎样?漏调 body 又会怎样?)——这些在类型系统里表达不出来,必须在 build() 那一步集中检查并抛出可读的异常(requireIllegalArgumentException,见 13 章)。很多自制 DSL 的坑就是「少写一句,静默出空结果」。
折中方案常常最好:核心用普通函数和 data class,只在最外层包一层薄薄的 DSL。这样内部逻辑好测试、好复用,DSL 只负责组装——出问题时也容易判断是 DSL 层还是逻辑层的锅。

测试与工具链

语言写得对,不等于工程立得住。这一章讲把 Kotlin 代码变成可交付工程的四件事:测试框架、协程的虚拟时间测试、Gradle 工程化配置与依赖版本管理、以及把「写得糙」在合入前挡下来的静态检查。

Kotlin 的测试栈有两层:kotlin.test 提供一套平台无关的断言与注解,JUnit 5 在 JVM 上真正执行它们。搞清这层关系,你就知道该 import 哪个。

kotlin.test 到底是什么

它是标准库配套的门面:你写 kotlin.test.TestassertEquals,构建时选一个引擎实现(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

  • StandardTestDispatcherrunTest 默认):新协程排队,不抢占当前协程——需要你显式 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 章之前,先把项目搭起来。

什么时候该从单文件毕业

  • 标准库里有什么:listOfStringprintln、集合与序列(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.ktsdependencies { } 里加一行 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"),IDE 会提示「Load Gradle Changes」,点它(或跑一次构建)就会自动下载;
  • ③ 跑./gradlew run(Windows 上 gradlew.bat run)。注意用项目自带的 gradlew 而不是全局的 gradle——前者会自动下载并锁定项目声明的那个 Gradle 版本,保证换台机器结果一致,也省掉单独安装 Gradle;
  • 版本基线:Kotlin 插件 kotlin("jvm") version "2.4.10"、coroutines 1.10.2、serialization-json 1.9.0

本页后续的约定

本页后面凡是 importkotlinx.* 的代码,都默认你已经加好了对应依赖。看到 runBlocking / launch / Flow 就是 coroutines,看到 @Serializable / Json.encodeToString 就是 serialization(后者还需要额外的 kotlin("plugin.serialization") 编译器插件,光加依赖不够)。这些片段大多省略了 fun main 外壳,想动手跑就塞进 fun main() { ... };用到 suspend 的则塞进 fun main() = runBlocking { ... }

// build.gradle.kts —— 一个够用的最小 JVM 项目
plugins {
    kotlin("jvm") version "2.4.10"
    application              // 没有它就没有 ./gradlew run 这个任务
}

repositories { mavenCentral() }   // 去哪里下依赖

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    // 需要 @Serializable 时再加(另需 plugins 里的 plugin.serialization):
    // implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
    testImplementation(kotlin("test"))
}

application {
    mainClass.set("MainKt")   // src/main/kotlin/Main.kt 里的 fun main
}

// ---- 目录结构 ----
// my-app/
//   build.gradle.kts
//   gradlew / gradlew.bat      ← 用它,别用全局的 gradle
//   src/main/kotlin/Main.kt
//   src/test/kotlin/MainTest.kt

// ---- 常用命令 ----
//   ./gradlew run     编译并运行(Windows: gradlew.bat run)
//   ./gradlew build   编译 + 测试 + 打包
//   ./gradlew test    只跑测试

// ---- 加完依赖后,这段就能编译运行了 ----
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    println("hi")
}
// 同一份源码,classpath 上没有 coroutines 时报
// error: unresolved reference 'kotlinx'.
三个高频卡点。其一,plugins只写 kotlin("jvm") 是没有 run 任务的——必须再加 application 并设好 mainClass,否则 ./gradlew run 报「找不到 task 'run'」。其二,mainClass 要写编译后的类名Main.kt"MainKt",带包名则是 "com.example.MainKt"),写成 "Main" 会在运行时才失败。其三,kotlinx.serialization 光加依赖不够,它靠编译器插件生成序列化代码,plugins 里漏了 kotlin("plugin.serialization") 时最坑的地方在于:编译期完全沉默,一直到运行到序列化那一行才炸。所幸消息写得很清楚——报错原文是 kotlinx.serialization.SerializationException: Serializer for class 'User' is not found. Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.,末句点名了插件。看到「compiler plugin is applied」就去查 plugins,别在注解上找原因。
不想为了验证一段协程代码专门建项目时,还有个折中办法:手动把 jar 塞进 classpath——从 Maven Central 下 kotlinx-coroutines-core-jvm-1.10.2.jar(它还依赖 atomicfu-jvm),编译和运行时都用 -cp 带上即可,源码一个字都不用改。这也直观说明了 Gradle 到底在替你做什么:它就是个自动下 jar、自动拼 classpath 的机器

01 章解决的是「怎么让第一个项目跑起来」;这一张讲的是项目长大以后的事:依赖版本怎么集中管、用哪个 JDK 编译、多模块怎么不重复配置。

版本目录:把版本号从构建脚本里赶出去

gradle/libs.versions.toml 是 Gradle 内建的版本目录,四个表:[versions] 定版本号、[libraries] 定坐标、[plugins] 定插件、[bundles] 打包一组常一起用的依赖。构建脚本里改写成 implementation(libs.coroutines.core),得到类型安全的引用与 IDE 补全;升级时只改 toml 一处,所有模块同步。多模块项目里这是必做项——否则版本迟早在模块间漂移,运行期蹦出 NoSuchMethodError

注意命名映射:toml 里的 coroutines-core 在脚本里写作 libs.coroutines.core(连字符变点)。

本页的版本基线

组件版本说明
Kotlin2.4.10本页所有代码在此版本;K2 编译器自 2.0 起就是默认
kotlinx-coroutines-core1.10.2kotlinx-coroutines-test 用同一版本号
kotlinx-serialization-json1.9.0还需要 plugin.serialization 编译器插件,版本跟 Kotlin 走
MockK / Turbine / Ktor查 Maven Central 取最新发布节奏与 Kotlin 无关,本页不写死具体号

jvmToolchain:别让「本机装了什么 JDK」决定产物

kotlin { jvmToolchain(21) } 声明用 JDK 21 来编译;Gradle 会在本机已装的 JDK 里定位对应版本——没装就直接构建失败,报 Cannot find a Java installation on your machine … Toolchain download repositories have not been configured.。想让它自动下载,得先在 settings.gradle.kts 里配 toolchain 解析仓库(foojay resolver)。定好之后,编译产物就与你用哪个 JDK,与运行 Gradle 本身用的是哪个 JDK 解耦。这样每个人和 CI 编出的字节码一致。没有它,字节码目标版本随开发者机器变,典型症状是「我这能跑,CI 上 UnsupportedClassVersionError」。选 LTS(17/21)最稳妥。

常用任务

命令做什么
./gradlew build编译 + 测试 + 打包,提交前跑的就是它
./gradlew test只跑测试;--tests "*CalculatorTest" 可筛选
./gradlew runapplication 插件提供,需设 mainClass
./gradlew jar打普通 jar(不含依赖,直接 java -jar 会缺类;要可执行胖包用 Shadow 插件或 applicationinstallDist
./gradlew --scan生成构建报告,排查「为什么这么慢/为什么重跑了」
// gradle/libs.versions.toml —— 版本集中在这一个文件
// [versions]
// kotlin        = "2.4.10"
// coroutines    = "1.10.2"
// serialization = "1.9.0"
//
// [libraries]
// coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
// coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
// serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
//
// [plugins]
// kotlin-jvm    = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
// kotlin-serial = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

// build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.kotlin.serial)
    application
}

repositories { mavenCentral() }        // 漏了这行会报 no repositories are defined

kotlin {
    jvmToolchain(21)              // 用 JDK 21 编译;本机没装 JDK 21 会直接构建失败
    compilerOptions {
        allWarningsAsErrors.set(true)   // 警告当错误,见下一卡
    }
}

dependencies {
    implementation(libs.coroutines.core)       // toml 里的 coroutines-core
    implementation(libs.serialization.json)
    testImplementation(kotlin("test"))         // 版本跟 Kotlin 插件走
    testImplementation(libs.coroutines.test)
}

application { mainClass.set("com.example.MainKt") }

tasks.test { useJUnitPlatform() }        // JUnit 5 必须显式开启

// ./gradlew build          编译 + 测试 + 打包
// ./gradlew test --tests "*CalculatorTest"
// ./gradlew run            需要 application 插件
// ./gradlew installDist    出带启动脚本的可分发目录(jar 任务不含依赖)
两个高频坑:① ./gradlew jar 打出来的 jar 不含依赖java -jar app.jar 立刻 NoClassDefFoundError(连 kotlin-stdlib 都没有)——要分发得打 fat jar 或用 installDist 出带启动脚本的目录。② 直接在 dependencies 里手写版本号,几个模块各写一份,升级时漏改一个——运行期才以 NoSuchMethodError 形式爆出来。用版本目录从根上避免。
一律用项目自带的 ./gradlew(Gradle Wrapper)而不是本机装的 gradle:wrapper 把 Gradle 版本钉进仓库,任何人克隆下来构建行为都一致。gradle/wrapper/ 目录要提交进版本库,这是它起作用的前提。

kotlinx.serialization 是 Kotlin 官方方案,靠编译器插件在编译期为 @Serializable 类生成序列化器——不用反射,因此能跨 JVM/JS/Native 使用,也不怕代码混淆裁剪。

它是三件套,缺一不可

  1. 编译器插件kotlin("plugin.serialization"),版本跟 Kotlin 走;
  2. 运行时库kotlinx-serialization-json:1.9.0(格式各自独立:json / cbor / protobuf);
  3. 注解:类上标 @Serializable

缺插件和忘标注解都会在编译期失败,报的是「找不到该类型的序列化器」这一类错误——两种原因症状相近,所以先确认编译器插件是否真的应用上了(打开 build 脚本看 plugins 块),再回头查注解。这是本库最常见的第一道坎。

Json 配置:三个几乎总要开的开关

配置作用建议
ignoreUnknownKeys = trueJSON 里多出的字段直接忽略对接外部 API 必开,否则对方加个字段你就崩
encodeDefaults值等于默认值的属性要不要写进输出默认 false(省体积);对方要求字段必须存在时才开
isLenient放宽引号等语法要求只在对接不规范的老接口时开

@SerialName("user_age") 把 JSON 字段名映射到 Kotlin 属性名,让下划线风格的接口不污染你的代码风格。属性有默认值时该字段就是可选的——这比声明成可空类型再到处判空干净得多(05 章)。

Ktor client:协程原生的 HTTP 客户端

HttpClient(CIO) { install(ContentNegotiation) { json() } },之后 client.get(url).body<User>() 直接拿到对象。它是 suspend 函数,天然融进结构化并发(10 章)。客户端持有连接池,应当复用一个实例,用完 close();每次请求新建一个是常见的资源泄漏源。JVM 上 Retrofit 也很成熟,suspend fun 原生支持,选哪个主要看是否需要跨平台。

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class User(
    val name: String,
    @SerialName("user_age") val age: Int,   // JSON 字段名 ≠ 属性名
    val role: String = "guest",               // 有默认值 = 反序列化时可缺省
)

// 配好一次,复用
val json = Json {
    ignoreUnknownKeys = true    // 对方加字段不会让你崩 —— 几乎总要开
    encodeDefaults    = false   // 默认值不写进输出(默认行为)
}

val s = json.encodeToString(User("Tom", 30))
// encodeDefaults=false → {"name":"Tom","user_age":30}   role 被省略
// encodeDefaults=true  → {"name":"Tom","user_age":30,"role":"guest"}

val u = json.decodeFromString<User>("""{"name":"Tom","user_age":30,"extra":1}""")
// extra 被忽略;role 取默认值 "guest"

// Ktor client:一个实例复用,用完 close()
val client = HttpClient(CIO) {
    install(ContentNegotiation) { json(json) }
}

suspend fun loadUser(id: Int): User =
    client.get("https://api.example.com/user/$id").body()

// 忘了编译器插件、或忘了标 @Serializable:
//   编译期失败,报「找不到该类型的序列化器」——先查 plugins 块
混用两套序列化框架会撞车:Jackson/Gson 依赖无参构造器和运行时反射,而 Kotlin 的 data class 只有带参构造器、默认参数在字节码里是另一套机制,Jackson 拿到的常是全 null 或直接失败——必须加 jackson-module-kotlin 才能正确处理构造参数与默认值(这类 Java 生态与 Kotlin 的接缝问题见 12 章)。同一个项目里尽量只用一套。
encodeToString / decodeFromString 都是 Json 实例上的方法,所以把配置好的 Json { … } 存成单例复用——每次现建一个既浪费又容易让不同调用点配置不一致。val json = Json { ignoreUnknownKeys = true } 放在顶层或伴生对象里。

把「写得糙」在合入主干前挡下来,靠的是三道闸门:格式坏味道编译器自己的话。分工搞混会让工具互相打架。

ktlint 与 detekt 的分工

ktlintdetekt
管什么格式:缩进、空行、import 顺序、命名坏味道:函数过长、圈复杂度、空 catch、可疑写法
可自动修大部分能(--format基本不能,要人判断
可配置度刻意很低(按官方 code style 走)高,规则集可裁剪
类比PrettierESLint

两个一起用是常规做法:ktlint 在提交钩子里自动格式化,detekt 在 CI 里报告。别让两者都去管格式,否则会出现「A 改完 B 又改回去」。

先用好编译器自带的警告

装工具之前,Kotlin 编译器本身已经能报不少东西。开 allWarningsAsErrors(命令行是 -Werror)把警告升级成错误,警告就不会在日志里越积越多。(Kotlin 2.4.10):一段调用了 @Deprecated 函数的代码,默认只给一行 warning: 'fun oldApi(): Int' is deprecated. …;加上 -Werror 后编译直接失败,首行是 error: warnings found and -Werror specified

建议:新项目一开始就开;老项目先把存量警告清掉再开,否则寸步难行。

@Suppress 的正确用法

  • 范围尽量小:能标在语句/属性上就别标在函数上,能标函数就别标文件(@file:Suppress 是最差的选择,会把之后新写的同类问题一起静音)。
  • 带上理由:紧挨着写一行注释说明为什么必须抑制。没有理由的 @Suppress 半年后没人敢删。
  • 参数是诊断 ID 字符串,如 @Suppress("DEPRECATION")@Suppress("UNCHECKED_CAST")。给函数标 @Suppress("DEPRECATION") 后,同一份代码里那处调用的弃用警告消失,其余位置照报。

explicit API 模式:写库必开

库的公开 API 一旦发布就难改。开启 explicit API(Gradle 里 kotlin { explicitApi() },命令行 -Xexplicit-api=strict)后,编译器强制你为每个公开声明写明可见性和返回类型,杜绝「本想内部用、忘写 private 就成了公开 API」和「返回类型靠推断,改实现顺手改了公开签名」。把一段普通代码用 strict 模式编译,报错原文是:

  • error: visibility must be specified in explicit API mode.
  • error: return type must be specified in explicit API mode.

应用(非库)项目通常不开——那里公开成员多且无外部消费者,收益不抵噪音。相关的 API 设计取舍见 13 章。

// ① 编译器警告当错误
// build.gradle.kts
kotlin {
    compilerOptions { allWarningsAsErrors.set(true) }
    explicitApi()          // 写库时开;应用项目通常不开
}

// (kotlinc 2.4.10):
//   默认  → warning: 'fun oldApi(): Int' is deprecated. 用 newApi 代替.
//   -Werror → error: warnings found and -Werror specified   (编译失败)

// ② @Suppress:范围尽量小,并写明理由
@Deprecated("用 newApi 代替")
fun oldApi(): Int = 1

// 理由:v3 前需兼容旧客户端,2026Q4 随 oldApi 一起删
@Suppress("DEPRECATION")
fun legacyBridge(): Int = oldApi()      // 这一处静音,别处照报

// ③ explicit API 模式报错
class Service {                    // ← visibility must be specified
    fun compute() = 1 + 1          // ← visibility + return type must be specified
}
// 改成:
public class Service {
    public fun compute(): Int = 1 + 1
}

# ④ 工具:格式归 ktlint,坏味道归 detekt
# ./gradlew ktlintCheck    ./gradlew ktlintFormat
# ./gradlew detekt         # 报告写到 build/reports/detekt/
allWarningsAsErrors 有个现实副作用:升级 Kotlin 版本时,编译器新增的警告会让原本能构建的项目突然构建失败。这本身是好事(新警告往往对应真问题),但要有心理准备并预留时间——别在发版前一天升 Kotlin。另外 @Suppress("UNCHECKED_CAST") 是最危险的一个:它抑制的警告正对应运行期可能的 ClassCastException,09 章讲过类型擦除,标它之前先确认强转真的安全。
把这些放进 CI 而不是只靠自觉:./gradlew build 里挂上 ktlint 检查与 detekt,失败就挡合并。人工 code review 的注意力应该花在设计和边界条件上,格式与坏味道交给机器——这也是把 review 从「挑毛病」变成「聊方案」的关键一步。

高级特性与前沿

最后一组话题:运行时反射的能力与代价、跨平台的现状与边界、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=100E name: kotlin.StringF Tom,一切正常。

同一次还确认:User::class.qualifiedNameUser::class.java.name 这类轻量操作不需要 kotlin-reflect 也能跑(它们在 stdlib 里)。所以「用了 ::class 就得加依赖」是错的,界线在于有没有碰 kotlin.reflect.full 那一套。

KClass 与 Class

KClass<T>Class<T>
怎么拿User::classUser::class.javaobj.javaClass
知道什么Kotlin 视角:可空性、属性、data/sealed、默认参数JVM 视角:字段、方法、擦除后的泛型
互转kclass.javaclazz.kotlinUser::class.java === Class.forName("User")true——同一个 JVM 类对象

要传给 Java 库(Jackson、JDBC…)就交 Class;要读 Kotlin 特有的信息就用 KClass(并记得加 kotlin-reflect)。

写注解:两个必填项

  • @Target(AnnotationTarget.FUNCTION)——能标在哪:类/函数/属性/参数/表达式…不写就是几乎哪儿都能标,容易被误用。
  • @Retention(...)——留到什么时候:SOURCE(只给编译器和 IDE 看)/BINARY(进 class 文件但运行时读不到)/RUNTIME(默认,反射能读)。想在运行时读,必须是 RUNTIME,这是「注解写了却读不到」的头号原因。

什么时候别用运行时反射

反射把「本该编译期确定的事」推迟到运行期:类型错误变成运行时异常、代码混淆/裁剪工具看不见这些调用会把它们删掉、启动时还要付出解析元数据的成本。凡是「扫一遍注解、生成一些样板代码」的需求,用 KSP(Kotlin Symbol Processing)在编译期做——产出真实源码,可读、可调试、可被编译器检查,也没有运行期开销。KSP 是 KAPT 的替代者(KAPT 需要先把 Kotlin 变成 Java 存根,慢且在 K2 下只以兼容模式运行)。运行时反射真正合理的场景很窄:通用序列化框架、依赖注入容器、测试框架这类无法预知使用方类型的基础设施。

import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.findAnnotation

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)   // 运行时要读,必须 RUNTIME
annotation class Validated(val maxLength: Int)

@Validated(maxLength = 100)
fun saveName(name: String) { }

data class User(val name: String, val age: Int)

fun main() {
    // —— 这几行不需要 kotlin-reflect ——
    println("A kclass=" + User::class.qualifiedName)   // A kclass=User
    println("B javaClass=" + User::class.java.name)   // B javaClass=User
    println(User::class.java === Class.forName("User"))  // true 同一个类对象

    // —— 以下需要 kotlin-reflect 在 classpath 上 ——
    println(::saveName.findAnnotation<Validated>()?.maxLength) // 100
    for (p in User::class.memberProperties)
        println("E ${p.name}: ${p.returnType}")      // E age: kotlin.Int …
    println(User::name.get(User("Tom", 3)))            // Tom
}

/* classpath 里没有 kotlin-reflect 时的输出(编译毫无问题):
Exception in thread "main" kotlin.jvm.KotlinReflectionNotSupportedError:
  Kotlin reflection implementation is not found at runtime.
  Make sure you have kotlin-reflect.jar in the classpath
    at kotlin.jvm.internal.CallableReference.getReflected(CallableReference.java:100)
                                                                              */

// 要用就显式声明,别指望传递依赖:
//   implementation(kotlin("reflect"))
KotlinReflectionNotSupportedError 是个Error 不是 Exception,而且只在执行到那一行时才抛——功能测试没覆盖到的分支会一路带着这个雷上线。Gradle 里 kotlin-reflect 常常是被某个库传递带进来的,你本地能跑,换个依赖组合就没了。要用就显式声明 implementation(kotlin("reflect")),别赖传递依赖。
写自定义注解前先确认它要被谁读:给 IDE/编译器看的用 SOURCE;给 KSP 处理器看的 SOURCE 就够(处理器读的是源码结构,不是 class 文件);只有真要运行时反射读才用 RUNTIME。默认就是 RUNTIME,很多人从没想过这件事。

KMP 的主张不是「一次编写到处运行」,而是共享该共享的,各写各的平台层。这条界线画在哪里,决定了 KMP 项目是省事还是添乱。

源集结构:编译到哪个平台,就用哪一套源码

  • commonMain——只能用 Kotlin 标准库和跨平台库,不能碰任何平台 API
  • jvmMain / androidMain / iosMain / jsMain——各平台专属实现,这里才能用 java.ioUIKit、DOM;
  • 测试同构:commonTestkotlin.test 写一次,各平台各跑一遍(15 章讲过 kotlin.test 为什么是个门面)。

编译某个平台时,commonMain + 该平台源集一起参与,所以「共享代码」并不是运行期动态选择,而是编译期就分好了

expect / actual

commonMainexpect fun currentTimeMillis(): Long 声明「我需要这个东西」,每个平台源集用 actual 给出实现。编译器强制每个目标平台都必须有 actual,漏一个就编译失败——这比运行时 if-else 判平台可靠得多。

expect/actual 不是唯一手段,很多时候更好的做法是在 common 里定义接口,平台侧提供实现并从入口注入。接口方式更好测(可以塞 fake),也不受 expect/actual 一些结构限制的约束。

共享什么,不共享什么

共享?说明
数据模型、校验规则共享收益最大、风险最小,通常从这里起步
网络与序列化共享Ktor client + kotlinx.serialization 都是跨平台的
业务逻辑、状态机共享KMP 的主要卖点
本地存储视情况SQLDelight 等库可共享,直接用平台 API 则不行
UI通常不共享各平台交互习惯不同;Compose Multiplatform 提供了共享 UI 的选项,但要不要用是个产品决策,不是技术默认值

生态成熟度:保守一点说

  • 共享业务逻辑这条路已被不少团队用于生产,主流跨平台库(Ktor、kotlinx.serialization、kotlinx.coroutines、SQLDelight)都支持多目标。
  • 但代价真实存在:构建配置比单平台复杂、iOS 侧调试要在 Kotlin 与 Xcode 之间来回、库的平台支持矩阵需要逐个核对、团队得同时懂两端。
  • 共享 UI、以及 Kotlin/Wasm 这类较新的目标,成熟度与 JVM/Android 不在同一档次,演进也快——决策前请以官方文档当时的状态为准,不要照搬任何教程(包括本页)里的现状描述
// 源集结构(编译期就分好,不是运行期判断)
// src/commonMain/kotlin/   ← 只能用跨平台 API
// src/jvmMain/kotlin/      ← 可用 java.*
// src/iosMain/kotlin/      ← 可用 platform.Foundation.*
// src/commonTest/kotlin/   ← 一份测试,各平台各跑一遍

// —— commonMain:声明期望 ——
expect fun platformName(): String

data class Order(val items: List<Int>) {
    val total: Int get() = items.sum()      // 纯逻辑,天然可共享
}

// —— jvmMain:实际实现 ——
actual fun platformName(): String = "JVM " + System.getProperty("java.version")

// —— iosMain:实际实现 ——
// actual fun platformName(): String = UIDevice.currentDevice.systemName

// 少一个平台的 actual → 编译失败,不会拖到运行期

// 常常更好:common 里定接口,平台侧实现并注入(更好测)
interface Clock { fun nowMillis(): Long }

class OrderService(private val clock: Clock) {   // 测试可传 FakeClock
    fun isExpired(deadline: Long) = clock.nowMillis() > deadline
}

// build.gradle.kts(多目标声明的骨架)
kotlin {
    jvm()
    iosArm64(); iosSimulatorArm64()
    sourceSets {
        commonMain.dependencies { implementation(libs.serialization.json) }
        commonTest.dependencies { implementation(kotlin("test")) }
    }
}
最常见的错误是在 commonMain 里习惯性用了 JVM 的东西——java.timeString.formatThread、正则的某些行为差异——本地跑 JVM 目标一切正常,一编 iOS 目标才报错。多目标项目要把所有目标都挂进 CI,不能只构建 JVM。另外 expect class 在不同平台上的成员必须严格对上,改一处忘了改另一处是家常便饭。
起步别贪:先把一个不依赖平台的模块(比如「订单金额计算 + 校验规则」)抽成 KMP 模块,两端各自接进去跑通一次。这一步能真实暴露构建、发布、调试上的成本,再决定要不要继续往上搬——比先搭一整套架构再发现走不通划算得多。

Kotlin 2.x 最大的一件事是编译器换代,语言层面的变化则是小步快跑。这里只写在 Kotlin 2.4.10 上确认过的,其余一律注明状态。

K2 编译器

Kotlin 2.0 起 K2 是默认前端,重写了类型推断与解析。对使用者的直接影响是:类型推断更准(少写显式类型也不报错)、诊断信息更清楚,以及——一些以前能编过的模糊写法现在会报错。升级 Kotlin 大版本时遇到「没改代码却编不过了」,多半就是这类收紧,通常是原来的写法本就有歧义。

when 的 guard conditions

is String if x.isEmpty() -> … 这种「分支类型 + 附加条件」的写法,在 Kotlin 2.4.10 上不需要任何 opt-in 参数,直接编译通过并正确运行classify("")→空串、classify("abc")→串:3、classify(5)→正数、classify(-1)→其他)。它已经是可以放心用的语言特性。

价值在于:以前要么在分支体里再嵌一层 if(破坏 when 的表达式形态),要么把条件拆成两个分支。有了 guard,「同一个类型按条件分流」终于能写平。

data object

data object Home 相比普通 object 多了有意义的 toString()——打印 Route.Home 得到 Home 而不是 Route$Home@1b6d3586 这样的默认形式。用 sealed 层级建模状态时(LoadingEmpty 这类无数据分支),日志和调试信息可读性差别很大。它的 equalshashCode 仍按单例的同一性来——单例本来就只有一个实例。

context receivers → context parameters:仍在演进中

旧的 context receivers 实验特性已被context parameters 的设计取代。这类处在预览/实验阶段的特性,开启方式、语法细节和稳定时间都可能在小版本间变化——本页不给具体开关和语法,要用请查当时的官方文档并确认它在你的 Kotlin 版本上的状态。判断原则很简单:实验特性别进生产代码的核心路径,它们的迁移成本由你承担。

怎么跟进新特性(比记住清单更有用)

  • 语言变化的权威来源是 kotlinlang.org 的 What's newYouTrack 上的 KEEP 提案;博客文章的版本号经常过期。
  • 看到一个特性,先确认三件事:在哪个版本稳定要不要 opt-in能不能编过——最后一件自己写五行代码编一次最快,比读十篇文章可靠。
// —— 以下全部在 Kotlin 2.4.10 上编译并运行通过 ——

// ① when 的 guard conditions:无需任何 opt-in
fun classify(x: Any): String = when (x) {
    is String if x.isEmpty() -> "空串"
    is String                -> "串:${x.length}"
    is Int if x > 0       -> "正数"
    else                      -> "其他"
}
// classify("")=空串  classify("abc")=串:3  classify(5)=正数  classify(-1)=其他

// ② data object:有意义的 toString
sealed interface Route {
    data object Home : Route              // println(Route.Home) → Home
    data class Detail(val id: Int) : Route
}

// ③ sealed + when 穷举:编译器确认分支齐全,不需要 else
fun title(r: Route): String = when (r) {
    is Route.Home   -> "首页"
    is Route.Detail -> "详情 ${r.id}"
}
// 以后给 Route 加一个子类型,这里会立刻编译失败 —— 正是想要的效果

// ④ 想知道某特性在你的版本上能不能用?写五行,编一次。
//    编过     → 可用
//    要 opt-in → 实验特性,别进核心路径
//    语法错   → 版本不够,或语法已变
网上大量 Kotlin 文章(包括看起来很新的)标注的版本号是写作当时的状态:某个特性当时要 opt-in,现在早已稳定;或者当时的语法后来改了。context receivers 就是活生生的例子——照着旧文章写,轻则编不过,重则用上一个已被废弃的设计。凡涉及版本,以官方文档 + 你手上编译器的实际反应为准
「这个特性我这个版本能用吗」不要靠搜索,写五行代码编一次:编过就能用,报 opt-in 就是实验特性,报语法错就是版本不够。本卡里的结论都是这么来的,你也可以两分钟复现。

GoF 的很多模式,是在缺少某个语言特性的前提下发明的变通办法。Kotlin 把那些特性直接做进了语言,于是模式就退化成一个关键字——认出这一点,能省掉大量样板。

对照表

模式Java 里的样子Kotlin 里
单例私有构造 + 静态实例 + 双检锁object Counter { … },线程安全的延迟初始化由语言保证
建造者Builder 类 + 一串 withXxx() + build()默认参数 + 命名参数;嵌套结构才升级到 DSL(14 章)
策略接口 + 每个策略一个类函数类型val discount: (Int) -> Int,传 lambda 即可
装饰器实现同接口 + 手写转发全部方法类委托 class Logged(inner: Repo) : Repo by inner,只重写想改的
模板方法抽象类 + 抽象钩子方法高阶函数把钩子作为参数传进去
观察者监听器接口 + 增删列表Flow / StateFlow(11 章)
访问者双分派 + accept/visit 一大套sealed 层级 + when 穷举,编译器保证不漏分支
空对象专门造一个「什么都不做」的实现可空类型 + ?.(05 章),不需要造对象

类委托 by:最被低估的一个

class LoggingRepo(private val inner: Repo) : Repo by inner { override fun find(id: Int) = … }——编译器把 Repo所有方法自动转发给 inner,你只重写关心的那一个。接口以后加方法,装饰器不用改。手写转发的版本则每加一个方法就要补一行,漏了就编译失败或行为不一致。这一条让「组合优于继承」从口号变成了比继承还省事的默认选择。

哪些模式没被吃掉

模式消失的是实现样板,不是设计意图。仓储(Repository)、适配器、外观、依赖注入这些讲的是模块怎么划分,与语言特性无关,Kotlin 里照样需要——只是写起来更短。区分方法很简单:如果一个模式的主要内容是「为了绕过语言限制而多造几个类」,Kotlin 大概率有直接写法;如果它讲的是「谁该知道谁」,那它依然成立。

// 单例:一个关键字
object AppConfig {
    val version = "1.0"          // 线程安全的延迟初始化由语言保证
}

// 建造者 → 默认参数 + 命名参数
data class ServerConfig(
    val host: String = "localhost",
    val port: Int = 8080,
    val timeoutMs: Long = 5000,
)
val cfg = ServerConfig(port = 9090)   // 没有 Builder,没有 build()

// 策略 → 函数类型
fun checkout(amount: Int, discount: (Int) -> Int) = discount(amount)
checkout(100) { it - 10 }
checkout(100) { it * 9 / 10 }

// 装饰器 → 类委托 by:编译器自动转发所有未重写的方法
interface Repo {
    fun find(id: Int): String?
    fun save(id: Int, v: String)
    fun delete(id: Int)
}

class LoggingRepo(private val inner: Repo) : Repo by inner {
    override fun find(id: Int): String? {      // 只重写关心的这一个
        println("find($id)")
        return inner.find(id)
    }
    // save / delete 自动转发;以后 Repo 加方法,这个类不用改
}

// 访问者 → sealed + when 穷举
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape

fun area(s: Shape): Double = when (s) {   // 无需 else,加子类型会编译失败
    is Circle -> 3.14159 * s.r * s.r
    is Rect   -> s.w * s.h
}
别把「模式没了」当成「设计没了」。最常见的过头做法是什么都用函数类型代替接口:策略只有一个方法时 (Int) -> Int 确实更轻,但一旦需要两个以上相关操作、或者需要名字来表达意图,具名接口(哪怕只有一个实现)比一串裸 lambda 好读得多——参数类型相同的多个函数参数还极易在调用点传错顺序。另外错误处理该用异常还是 Result、失败信息怎么建模,属于 API 设计范畴,见 13 章,本卡只谈模式层面。
看到自己在写 Builder 类、单例双检锁、或一长串手写转发方法时,停一下——Kotlin 多半有一行的写法。反过来也成立:如果一个模式在 Kotlin 里还是要写很多样板,先确认你是不是在用 Java 的思路解 Kotlin 的问题。

Kotlin 的很多「零成本」说法,用 javap 一看就清楚了——也能看清哪些不是零成本。这张卡教的不是优化技巧,是怎么自己去看

用 javap 看编译产物

javap -p -c -cp out MyClassKt-p 连私有成员一起显示、-c 反汇编字节码。不需要读懂每条指令,只看两件事就够用:有没有 new(分配了对象)调的是什么方法(签名长什么样)

inline 的代价:对比

同一段「循环里调 lambda」的逻辑,写成 inline fun 和普通 fun,字节码差别很直接(Kotlin 2.4.10):

非 inline 版本inline 版本
调用点做了什么new kotlin/jvm/internal/Ref$IntRefinvokedynamic … Function1、再 invokestatic notInline循环体直接展开在调用者里,只有 iloadiaddgoto
对象分配有:函数对象 + 捕获变量的包装
代价调用点代码体积变大,每个调用点复制一份

所以 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先把全部元素处理完并产出一个新 Listfilter 再遍历它。打印执行顺序: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 } === listfalse,中间结果确实是新对象。链条短、集合小时这点开销无关紧要;链条长或集合大时序列才有意义(07 章详述)。

什么时候别优化

  • 没测量之前:JVM 上直觉的命中率极低——JIT 会内联、逃逸分析可能消掉分配,你以为的热点常常不是。要下结论就用 profiler(JFR、async-profiler)和正经的基准框架(JMH),别靠读代码猜。
  • 不在热路径上:一段一次请求只跑一次的代码,怎么写都不影响整体。
  • 代价是可读性:把清晰的集合链改写成手写循环、到处塞 inline,换来的通常是维护成本而不是速度。

顺序永远是:先正确、再清晰、最后在有证据的地方优化

# 看编译产物:-p 含私有成员,-c 反汇编
# javap -p -c -cp out MyFileKt

// ① inline vs 非 inline(Kotlin 2.4.10)
inline fun measureLoop(n: Int, body: (Int) -> Unit) { for (i in 0 until n) body(i) }
fun notInline(n: Int, body: (Int) -> Unit)   { for (i in 0 until n) body(i) }

fun useInline()    : Int { var s = 0; measureLoop(3) { s += it }; return s }
fun useNotInline() : Int { var s = 0; notInline(3)   { s += it }; return s }

/* javap 
   useInline()    → 只有 iload / iadd / goto,循环被展开,零分配
   useNotInline() → new kotlin/jvm/internal/Ref$IntRef
                    invokedynamic … Function1
                    invokestatic  notInline                        */

// ② value class 的装箱时机
@JvmInline
value class UserId(val raw: String)

fun takesId(id: UserId): Int = id.raw.length
fun takesList(ids: List<UserId>): Int = ids.size

/* javap 
   takesId-Bu7z9Ig(java.lang.String)   ← 擦除成 String,名字带哈希后缀
   takesList(java.util.List<UserId>)   ← 进了泛型容器,装箱
   class UserId 里有: box-impl(String) / unbox-impl()               */

// ③ 集合链的中间对象(执行顺序)
val nums = (1..4).toList()
nums.map { println("map $it"); it * 2 }.filter { println("filter $it"); it > 4 }
// map 1, map 2, map 3, map 4, filter 2, filter 4, filter 6, filter 8

nums.asSequence().map { … }.filter { … }.toList()
// map 1, filter 2, map 2, filter 4, …  逐元素穿过,无中间集合

// 顺序永远是:先正确 → 再清晰 → 最后在有证据的地方优化
微基准在 JVM 上极难写对:JIT 预热、死代码消除、常量折叠都会让手写的「循环一千万次计时」得出毫无意义的数字(编译器可能把整段被测代码删掉)。要测就用 JMH,它处理了预热与防优化。另外别把在某台机器上测到的具体数字当结论——不同 JVM 版本、不同硬件、不同负载下结论可能相反,能带走的只有「谁比谁快一个数量级」这种粗结论。
养成一个习惯:对不确定的语言特性,编一次 + javap 看一眼。「这个会不会装箱」「委托是不是每次都查表」「这个 lambda 有没有分配对象」——猜一小时不如看一分钟,而且看过一次就永远记住了。

Kotlin 的界面:Compose 与桌面

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   出安装包
Compose 的版本管理容易出错:Kotlin 编译器版本、Compose 编译器插件版本、Compose 库版本三者互相绑定,随便升一个就可能报「This version of the Compose Compiler requires Kotlin version X」。Kotlin 2.0 起编译器插件已并入 Kotlin 本体(org.jetbrains.kotlin.plugin.compose),跟着 Kotlin 版本走即可——但网上大量教程还停在旧的对应表上,照抄必然对不上。
学 Compose 的顺序建议反着来:先在桌面上练./gradlew run 几秒就起来,不用装 Android SDK、不用等模拟器;等写法熟了再迁到 Android,API 是同一套。

从 XML 布局或 Swing 转过来,最大的转变不是语法,是你不再持有控件、也不再手动更新它们。Compose 里界面是状态的函数:状态变了,框架重新调用相关的函数,自己算出该改哪里。

命令式 vs 声明式,差在哪

  • 命令式(Swing / Android XML):你拿到一个 TextView 的引用,数据变了就 setText(...)。界面状态和数据状态是两份,同步不上就是 bug。
  • 声明式(Compose):你写一个 @Composable fun 描述「在当前状态下界面长什么样」。状态变了,框架重新调用这个函数,比对前后差异并只更新变化的部分。这个过程叫重组(recomposition)
  • 直接后果:状态只有一份,界面和数据不可能不同步——这类经典 bug 从根上消失了。

三个必须搞清的关键字

  • mutableStateOf(...):造一个可被观察的状态。用普通 var 存的值改了不会触发重组——这是新手第一个坑。
  • remember { ... }:让这个值跨重组存活。不写 remember,每次重组都会重新初始化,状态永远回到初值。
  • rememberSaveable:再进一步,跨配置变更(屏幕旋转)和进程重建也保住。
  • 组合起来的惯用写法就是 var c by remember { mutableStateOf(25f) }——by 是委托属性(08 章),让你像用普通变量一样读写它。

状态提升:Compose 的核心设计规矩

  • 惯例是让 composable 无状态:不自己持有状态,而是接收 valueonValueChange 两个参数——状态被「提升」到调用方那里。
  • 好处很实际:这样的组件可复用、可预览、可测试,因为它的输出完全由入参决定。
  • 判断依据:如果一个组件的状态会被别的组件读到或改到,就必须提升;纯粹自用的(比如展开/收起)可以自己留着。

重组的两条纪律

  • composable 函数可能被调用很多次、顺序不定、甚至被跳过。所以函数体里不能有副作用——别在里面发网络请求、写文件、改全局变量。要做这些用 LaunchedEffect / SideEffect 这类专门的入口。
  • 别在 composable 里做重计算。要算就 remember(key) { 重活() } 缓存起来,key 变了才重算。
import androidx.compose.runtime.*

// ❌ 有状态:不好复用,也不好测
@Composable
fun 温度控件坏版() {
    var c by remember { mutableStateOf(25f) }   // 状态藏在里面
    Slider(value = c, onValueChange = { c = it })
}

// ✅ 状态提升:组件无状态,输出完全由入参决定
@Composable
fun 温度控件(值: Float, 改变: (Float) -> Unit) {
    Slider(value = 值, onValueChange = 改变, valueRange = -40f..100f)
}

@Composable
fun 页面() {
    var c by remember { mutableStateOf(25f) }   // 状态在调用方
    Column {
        温度控件(值 = c, 改变 = { c = it })

        // 重计算要缓存:key 没变就不重算
        val 华氏 = remember(c) { c * 9 / 5 + 32 }
        Text("%.1f °F".format(华氏))
    }
}

// 副作用要走专门入口,不能直接写在函数体里
LaunchedEffect(用户id) {
    数据 = 拉取(用户id)        // 挂起函数,随组件生命周期取消
}
忘了 remember 是最高频的错:写成 var c by mutableStateOf(25f)(没有 remember),拖动滑块时值确实变了、也触发了重组,但重组时这一行又把它重新初始化回 25——现象是「控件回弹、怎么也拖不动」,而且不报任何错。看到这个症状先查 remember。
@Preview 是 Compose 最被低估的部分:给无状态组件写几个不同入参的预览,IDE 里直接看到各种状态下的样子,不用跑起整个应用。这也正是「状态提升」在实际开发里立刻兑现的好处。

Compose 不是唯一选项,有两种情况该看别处:你要接一份已有的 Java 桌面代码,或者这个工具根本不需要图形界面

Swing / JavaFX:Kotlin 用起来毫无障碍

  • Kotlin 跑在 JVM 上,Java 的界面库全部可以直接用——Swing 在 JDK 里(见 Java 页的界面章),JavaFX 单独引,写法和 Java 一样,只是更简洁:lambda、具名参数、apply 作用域函数(08 章)搭界面特别顺手。
  • 桌面 Compose 本身就跑在 AWT/Swing 之上,所以两者能混用ComposePanel 把 Compose 界面塞进 Swing 窗口,SwingPanel 把 Swing 控件嵌进 Compose 布局。
  • 这条路的价值在于渐进迁移:老的 Swing 程序不必推倒重来,可以一块一块换成 Compose。

终端:Clikt + Mordant(同一个作者)

  • Clikt(当前 5.1.0):命令行参数解析。子命令、参数校验、自动生成帮助、交互式提示——用 Kotlin 的属性委托表达,声明一个参数就是一行 val 名字 by option()
  • Mordant(当前 3.0.2):终端输出的排版层。真彩色、表格、进度条、Markdown 渲染,并且会自动检测终端能力——不支持颜色时降级,输出重定向到文件时自动去掉转义序列。
  • 两者同源,配合无缝:Clikt 负责收参数,Mordant 负责把结果打印得好看。对「一个人写的小工具」这个场景,这套组合的性价比高过任何 GUI。

怎么选

你的情况
新写的桌面或移动应用Compose Multiplatform
要接手 / 逐步改造已有 Swing 程序Swing + ComposePanel 渐进迁移
命令行工具,要参数解析和好看的输出Clikt + Mordant
只想给脚本加点颜色和进度条只用 Mordant
// Clikt:参数就是属性委托,一行一个
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.*

class 换算 : CliktCommand() {
    val 摄氏度 by option("-c", "--celsius", help = "输入温度")
        .float().default(25f)
    val 详细 by option("-v").flag()

    override fun run() {
        echo("%.1f °F".format(摄氏度 * 9 / 5 + 32))
    }
}

fun main(args: Array<String>) = 换算().main(args)
// --help 自动生成,参数写错自动报错并提示

// Mordant:表格与颜色,且会自动适配终端能力
import com.github.ajalt.mordant.rendering.*
import com.github.ajalt.mordant.table.table
import com.github.ajalt.mordant.terminal.Terminal

val t = Terminal()
t.println(table {
    header { row("°C", "°F") }
    body { (-40..100 step 20).forEach { row(it, it * 9 / 5 + 32) } }
})
Compose 和 Swing 混用时别忘了两边的线程规矩不同却又共用同一个线程:Swing 要求控件操作在 EDT 上,桌面 Compose 也跑在 AWT 事件线程上。在 SwingPanel 的回调里改 Compose 状态、或在 Compose 的副作用里碰 Swing 控件,都必须确认自己在 EDT 上——混用场景下的诡异卡顿和丢更新,多半出在这条边界。
Mordant 自动降级这一点在写工具时特别省心:同一份代码,交互式终端里出彩色表格,被 | 管道接走或重定向进文件时自动输出纯文本——不用你自己判断 isatty,也不会在日志文件里留一堆转义乱码。

从这里到精通:路线图

地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。

Kotlin 的精通路线有两条腿:语言本身(空安全、协程、DSL 能力)和一个主战场(Android 或服务端,KMP 是两者的汇合点)。按下面的顺序走,每一步都有明确产出物。

动手项目(难度递进)

  • ① 控制台工具:纯 Kotlin 写一个记账或单位换算 CLI——用 data class + sealed class 建模(06 章)、kotlinx.serialization 读写 JSON(15 章),交付一个 Gradle 可执行 jar——练语言本身,不碰框架。
  • ② Android 小应用(Compose):从计数器做到备忘录,带 ViewModel、状态提升与列表动画,交付一个可安装的 APK——练声明式 UI 与生命周期。
  • ③ Ktor 后端:REST API + 数据库(Exposed)+ 协程并发调用外部服务,带日志与异常处理,交付 Docker 镜像——练服务端 Kotlin 与结构化并发(10、11 章)。
  • ④ KMP 共享模块:把 ②③ 的业务逻辑(模型、校验、API 客户端)抽成 Multiplatform 模块,Android 与 iOS(或桌面)共用——练 expect/actual 与跨平台工程组织(16 章)。

书与资料(按阶段)

  • 入门:官方文档(kotlinlang.org)质量极高,配套的 Kotlin Koans 交互练习适合边读边做。
  • 系统进阶:《Kotlin in Action (2nd Edition)》——由 Kotlin 团队成员撰写、覆盖协程与 DSL,K2 时代最值得通读的一本。
  • Android 方向:Android 官方 Codelab 的 Compose 路线(developer.android.com/courses)手把手带完整个现代开发栈。
  • 跟进:Kotlin 官方博客与 KotlinConf 演讲,跟踪 KMP 与编译器演进。

怎么判断自己卡住了

  • 写出来的还是「加了糖的 Java」——满屏 !!、到处 if (x != null)、集合操作用 for 循环手写:回 05 章的惯用法卡和 07 章;
  • 协程只会 launchrunBlocking说不清作用域归谁、取消怎么传播:回 10 章的结构化并发卡——这是协程里唯一真正要理解的东西;
  • 作用域函数用得越来越花,自己回头读也要想一下 it 指谁:回 08 章那张对照表,把选择标准固定下来;
  • 和 Java 代码打交道时反复撞上空指针:回 12 章,问题几乎一定出在平台类型的边界上。
两个最常见的停滞方式。一是把语法学完就以为学会了:Kotlin 的难点不在语法(一周能读完),而在什么时候用哪个——五个作用域函数选哪个、错误用异常还是 Result 还是 sealed、协程作用域归谁管,这些只能靠写真实项目积累。二是只在 Android 或只在服务端里用它:两边的惯用法差异不小(生命周期 vs 请求作用域、Main dispatcher 的有无),只见过一边容易把局部经验当成语言规则。上面四个项目特意跨了这两个场景,别只挑一个做。
一条自测标准:给你一个 200 行的 Java 业务类,你能否把它重写成地道的 Kotlin——data class、空安全、扩展函数、when 表达式各就各位,行数减半而语义不丢——能做到,这一页就毕业了。