前置知识: Kotlin

Kotlin 2.x 新语言特性与 K2 编译器

8 min高级

接手一个升级到 2.x 的项目需要知道的一切:K2 换引擎带来什么、guard 守卫、非局部 break/continue、多美元插值与上下文参数(2.3 已稳定)各自解决什么痛点。

前置知识

学习目标

读完本文你将能够:

  1. 向团队解释 K2 编译器”换了什么、没换什么”,评估一次 1.9 到 2.x 的升级成本;
  2. 用 guard 把”类型判断 + 条件判断”压进一个 when 分支,不再写嵌套 if;
  3. 在 forEach 这类内联 Lambda 里直接 break/continue 外层循环,告别标签绕行;
  4. 用多美元插值写出不再和 $ 打架的模板、正则与 shell 字符串;
  5. 判断上下文参数适不适合你的项目,知道它与废弃的 context receivers 的关系。

预计 45 到 60 分钟,含 3 组动手实验与 3 道练习。

1. 问题引入:升级 2.x,你会得到什么

假设你接手一个 2023 年启动的 Kotlin 1.9 项目(比如 FANDEX 这类多模块仓库的服务端部分),第一件事是把 Kotlin 插件升到 2.x。值得升吗?先看 2.x 这条时间线:

版本发布关键动态
2.02024-05K2 编译器成为默认;enum entries 转稳定
2.12024-11guard、非局部 break/continue、多美元插值以实验性预览
2.22025-06上述三项全部转稳定;上下文参数(context parameters)实验性预览,取代 context receivers
2.32025 年末上下文参数转稳定;编辑器与构建体验持续改进

读法:2.0 是引擎换代,2.1-2.3 是特性恢复快跑。JetBrains 在 K1 时代冻结了大部分新语法(编译器前端重写优先),2.x 起节奏稳定为”实验预览 -> 下一到两个版本转稳”。所以升级 2.x 的真实收益分两层:马上兑现的编译速度,以及持续兑现的新语法。

2. K2 编译器:换引擎,不换语言

K2 是编译器前端的彻底重写(新的 FIR 前端 + 统一 IR 后端),四个可感知的变化:

  1. 更快:官方基准下全量编译最高约 2 倍提速,增量编译(日常热路径)收益更明显;
  2. 更聪明:智能转换在更多场景成立(K1 时代一堆”明明能推断却不给你转”的报错消失),泛型推断更准;
  3. 更友好:报错定位更准,少了”一个错带出一串级联误导”;
  4. 更统一:JVM/JS/Native/Wasm 共用同一前端,多平台项目行为一致。

对存量项目,迁移通常只是换版本号:

kotlin
plugins {
    kotlin("jvm") version "2.2.20"   // 2.0 起 K2 即默认,无开关
}

真正的迁移成本在工具链配套:KSP、Compose 编译器插件、序列化插件都要升到与 2.x 匹配的版本;个别 -X 实验参数被移除或改名,按编译器提示逐个处理。多模块仓库统一升级、别让 1.9 与 2.x 模块混编。要给存量代码留过渡期,可以把 languageVersion 钉在 1.9,先吃编译器红利、后开新语法。

实验一:给一个小项目分别用 1.9.x 与 2.2.x 各跑一次 ./gradlew clean build --profile,对比编译耗时。工程越大差距越明显,这是你说服团队升级的最短路径。

3. guard 守卫条件(2.2 稳定)

处理通知分发时最常见的形状:先按类型分派,再按内容细分。1.x 的写法要嵌套:

kotlin
// 1.x:when 里套 if,缩进指数增长
when (notification) {
    is Alert -> if (notification.severity == Severity.RED) {
        notifyOnCall(notification.message)
    } else {
        showBanner(notification.message)
    }
    is Reminder -> showBanner(notification.text)
}

2.x 的 guard 把条件挂进分支本身:

kotlin
sealed interface Notification
data class Alert(val message: String, val severity: Severity) : Notification
data class Reminder(val text: String) : Notification

fun handle(notification: Notification) = when (notification) {
    is Alert guard notification.severity == Severity.RED ->
        notifyOnCall(notification.message)
    is Alert ->
        showBanner(notification.message)
    is Reminder ->
        showBanner(notification.text)
}

语义要点只有一个:guard 不满足时不是报错也不是落进 else,而是继续尝试下一个分支——与”先 is 再嵌套 if”完全等价,但扁平、可读,并且 when 表达式的穷尽性检查原封不动(对 sealed 类型,编译器仍然逼你覆盖所有子类)。

坑点:别把 guard 当”过滤器”用。想”不满足条件就整体跳过这段处理”,应该在 when 之前先判断,guard 只负责”这条分支的特殊准入条件”。

4. 非局部 break / continue(2.2 稳定)

在网格(二维列表)里找第一个空格,1.x 的写法隔着 Lambda 边界就不能用 break,只能标签绕行或改用 run/firstNotNullOfOrNull。2.x 直接支持:

kotlin
fun findBlank(rows: List<List<Int>>): Boolean {
    for (row in rows) {
        row.forEach { cell ->
            if (cell == 0) return true   // return 一直可以穿透内联 lambda
            if (cell < 0) continue       // 2.x:continue 外层 for 的下一圈
            print("$cell ")
            if (cell > 9) break          // 2.x:直接退出外层 for
        }
    }
    return false
}

三条规则:

  1. break/continue 的目标是外围最近的循环,即使中间隔着 Lambda;
  2. 只在内联 Lambda(forEach、map、let 等)里可用——编译器把内联 Lambda 展开进外层函数,所以跳转合法;自定义高阶函数想享受同等待遇,记得标 inline;
  3. 老的标签语法 return@forEach 仍然有效,语义是”只跳过当前元素”,与新语义不同,改代码时别混淆。

5. 多美元插值(2.2 稳定)

写正则、shell 脚本、Makefile 或 JSON 模板时,字符串里的 $ 和插值 $var 打架,1.x 只能转义:

kotlin
// 1.x:一串 \$,读起来像密码
val cmd = "grep \"\\$\\{version\\}\" build.gradle.kts"

2.x 的多美元插值换个思路:用几个 $ 开头,就声明”连续几个 $ 才是插值”:

kotlin
fun main() {
    val name = "Kotlin"

    println($$"price: $99")               // $$ 开头:单个 $ 是字面量 -> price: $99
    println($$"hi $$name")                // 连续两个 $ 才插值 -> hi Kotlin
    println("plain $name")                // 普通串行为不变 -> plain Kotlin

    // 与三引号组合,JSON 模板里的 $ 不再需要转义
    val team = "FANDEX"
    val json = $$"""{"project": "$$team", "flag": "-Dver=$1"}"""
    println(json)                         // {"project": "FANDEX", "flag": "-Dver=$1"}
}

规则速记:前缀 $$ 之后,$$xx 是插值、$xx 是字面量;前缀 $$$ 则只有 $$$xx 插值,以此类推。只在”字符串里真的有很多 $”的场合用它——普通字符串用单 $,别为了新而新。

6. 上下文参数(2.3 稳定)

横切依赖(日志器、配置、事务句柄)的传递是个老问题:显式传参啰嗦,全局单例难测试。2.2 引入的上下文参数给出第三条路——声明”我需要什么环境”,调用方在作用域里提供:

kotlin
interface Logger { fun info(msg: String) }

// 声明:这个函数隐式依赖一个 Logger
context(logger: Logger)
fun audit(userId: String) {
    logger.info("用户 $userId 触发审计")     // 直接使用,不用传参
}

fun main() {
    val logger = object : Logger {
        override fun info(msg: String) = println("INFO: $msg")
    }
    with(logger) {
        audit("U-001")                        // 作用域内的 logger 自动供给
    }
}
// INFO: 用户 U-001 触发审计

时间线与注意事项:

  • 2.2 为实验性预览(需 -Xcontext-parameters),2.3 转稳定,不再需要编译器开关;
  • 它取代了更早的实验特性 context receivers(context(Logger) 无名形式,已废弃);两者语法相近但解析规则不同,网上老文章的写法要甄别;
  • Arrow 2.x 的 Raise<E> 错误处理、Ktor 的部分新 API 都建立在这类上下文机制上,生态在向它靠拢。

适用判断:横切、只读、相对稳定的环境对象(日志、追踪、配置、能力约束)适合;普通业务参数老老实实显式传——上下文的隐式性是把双刃剑,滥用会让”这个值从哪来”变成猜谜。

7. 其他值得顺手带走的 2.x 变化

  • enum entries(2.0 稳定):Severity.entries 替代 values(),返回复用的不可变列表,不再每次分配新数组;
  • 标准库跨平台原子类型 kotlin.concurrent.atomics(AtomicInt 等):多平台项目不再各写一套,详见 Kotlin 与原子操作;
  • Gradle DSL:kotlinOptions 逐步让位于类型安全的 compilerOptions { },详见 Kotlin 与 Gradle。

易错点与最佳实践

错误一:新语法配旧语言级别。 guard、非局部 break、$$ 各自有最低语言版本,多模块项目要统一 language/toolchain 版本,否则报”特性需要更高语言版本”。 错误二:把 guard 当过滤器。 guard 失败会继续匹配后续分支,不是提前返回。 错误三:在非内联 Lambda 里用非局部 break。 自定义高阶函数标 inline 才行;编译器报错信息会直接指出。 错误四:$$ 与 shell 变量混着数 $。 写模板前先决定前缀级数,逐个 $ 标注字面/插值,避免发布脚本里少打一个美元。 错误五:跨版本踩上下文参数。 2.2 的 -Xcontext-parameters 写法与 2.3 稳定版有差异,教程配图先看版本;依赖它的库要锁编译器版本并在 CI 验证。

本篇小结

  • K2(2.0 默认)换的是编译器引擎:更快、更聪明、报错更准、多平台统一;语言保持兼容,升级成本主要在工具链配套;
  • guard 给 when 分支加条件守卫,失败继续匹配下一分支,穷尽性检查不受影响;
  • 非局部 break/continue 穿透内联 Lambda 操作外层循环,标签绕行成为历史;
  • 多美元插值用”前缀级数”声明插值边界,模板与正则里的 $ 不再转义;
  • 上下文参数 2.3 已稳定,取代 context receivers,适合横切只读依赖;
  • 2.x 的特性节奏回归”预览 -> 下一两个版本转稳”,跟进成本可预期。

动手实践

  1. K2 提速实测:任选一个多文件项目,分别用 Kotlin 1.9.24 与 2.2.20 执行 ./gradlew clean build --profile 三次取平均,把提速比写进团队文档。
  2. 重构 when:找一个项目里”when + 嵌套 if”的分支(没有就写一个订单状态分发:is Order guard order.amount > 1000 走大额通道),用 guard 重写并保持穷尽性检查通过——故意删掉一个分支看编译器报错。
  3. 插值翻译:把一条带 $ 转义的正则或 shell 命令字符串改写成 $$ 版本,打印对比两种写法的输出是否一致。

下一步