炉 III · 塑形CH 11进度 11/26

DSL、运算符与解构:把语言掰弯给业务用

你每天都在写 DSL,只是没意识到:build.gradle.kts 是 DSL、Compose 的每一个 Column { } 是 DSL、Ktor 的路由是 DSL、buildString { } 也是。它们全都建立在同一个语言特性上 —— 带接收者的 lambda(T.() -> Unit)。这是 Kotlin 相对 Java 最有结构性的一处优势:Java 的 lambda 只有参数,没有接收者,所以它写不出这种形态,永远写不出。这一章把这个机制拆开,教你造一个自己的 DSL,然后处理解构里那个「写反顺序编译器不吭声」的陷阱 —— 它在 2.3.20 终于被修了。

带接收者的 lambda@DslMarker运算符重载名字解构

三步看穿一个 DSL

下面这台机器分三步:先看 DSL 长什么样,再看它凭什么能长成这样,最后看忘了 @DslMarker 会出什么事。第二步是重点

◆ 锭 · 整个 DSL 机制只靠一件事

lambda 的接收者可以被指定。block: Builder.() -> Unit 的意思是「一个把 Builder 当作 this 的 lambda」。于是在块里,你可以直接访问 Builder 的成员,不用写 b. 前缀。

就这一件事,撑起了 Compose 的整棵 UI 树、Gradle 的构建脚本、Ktor 的路由、以及标准库里的 buildString / buildList / apply

Java 写不出来 —— 它的 lambda 永远只有参数,没有 this。这不是「Java 还没加」的问题,是它的 lambda 模型决定的。

什么时候该写 DSL(以及什么时候别写)

DSL 有真实的成本:它是给读者的一门新语言。学它要时间,IDE 的跳转和补全在它里面会变弱,报错信息会变得难懂。所以:

该写别写
构造嵌套结构(UI 树、JSON、路由表、配置)—— 缩进天然表达层级只是「设置几个属性」—— 那是 apply 的活,或者具名参数就够了
这个结构会被写很多遍(每个页面都要写一次布局)全项目只用三次 —— DSL 的学习成本收不回来
你想约束用户能写什么(只有在 route 里才能调 get)—— 类型安全的构建器你只是觉得「这样写很酷」

另外有个很好用的中间选项,别忘了:具名参数 + 默认值。它能解决八成「构建器模式」的需求,而且零学习成本:

// 不需要 Builder,也不需要 DSL
fun notification(
    title: String,
    body: String = "",
    priority: Priority = Priority.Default,
    actions: List<Action> = emptyList()
) = Notification(title, body, priority, actions)

notification(title = "已发货", priority = Priority.High)   // 清晰,且不用学任何新东西

Java 的建造者模式(Builder pattern)在 Kotlin 里大部分是多余的 —— 它存在的理由(Java 没有默认参数和具名参数)在 Kotlin 里不成立。看到别人从 Java 抄来一个二十行的 Builder 类,先问一句:具名参数不够用吗?

运算符重载:约定,而不是魔法

Kotlin 的运算符重载是按约定的名字工作的 —— 你实现叫 plusoperator fun+ 就能用:

你写编译器调用典型用途
a + ba.plus(b)Duration 相加、金额相加、向量相加
a[i]a.get(i)自定义容器
a[i] = va.set(i, v)同上
a()a.invoke()让一个对象「像函数一样被调用」(UseCase 类的常见写法)
a in bb.contains(a)区间、集合
a..ba.rangeTo(b)自定义区间(日期区间很好用)
a < ba.compareTo(b)实现 Comparable 就白送四个比较运算符
a += ba.plusAssign(b)a = a.plus(b)见下面的坑

一个真实好用的例子 —— invoke 让 UseCase 读起来像函数:

class GetUser(private val repo: UserRepo) {
    suspend operator fun invoke(id: UserId): User = repo.find(id)
}

val getUser = GetUser(repo)
val user = getUser(userId)      // 像调用函数一样,但它是个可注入、可 mock 的对象
⚠ 炉渣 · += 的双重身份

a += b 有两条可能的路径:如果类型有 plusAssign(原地修改),走它;如果只有 plus(返回新对象),编译成 a = a.plus(b)

两个都有的话 —— 编译错误(有歧义)。而如果 aval,只有 plusAssign 那条路走得通。这解释了一个常见的困惑:

val list = mutableListOf(1)
list += 2        // ✓ 走 plusAssign,原地加

var list2 = listOf(1)
list2 += 2       // ✓ 走 plus,创建了一个全新的 List —— 在循环里这么写就是 O(n²)

第二种写法在循环里累积列表是经典的性能陷阱:每次 += 都复制整个列表。想累积,用 buildList { } 或者 MutableList

infix:少两个点,多一点可读性

infix fun Int.upTo(other: Int) = this..other
val r = 1 upTo 10                      // 而不是 1.upTo(10)

// 标准库里你已经在用的:
val pair = "key" to "value"            // to 就是个 infix 函数,不是语法!
if (x in 1..10) { }                    // in、.. 全是约定

注意 to 那一行:你以为是语法的东西,其实是标准库里一个普通的 infix 扩展函数。这是 Kotlin 设计哲学的一个缩影 —— 能做成库的,就不做成语法(第 9 章的 usesynchronized 也是)。语言核心因此保持小巧,而扩展能力交给你。

但 infix 用多了会变成密码。判据:只在「读起来真的像自然语言」时用1 to 2x shl 2、Mockito 风格的 every { } returns x),其余情况老实打点。

解构:一个静默的陷阱

解构靠的是约定的 component1()component2()…(data class 白送):

data class User(val username: String, val email: String)

val (name, mail) = user      // 展开成 val name = user.component1(); val mail = user.component2()

坑来了 —— 它按位置匹配,不按名字

val (email, username) = user      // 写反了顺序

println(email)      // 打印的是 username!
println(username)   // 打印的是 email!
// 编译器一句话都不说,因为两个字段类型都是 String

这是 Kotlin 里最阴险的坑之一:类型相同的相邻字段,顺序写反,静默出错。而且它极难在 review 中发现 —— 因为代码看起来完全合理。(这和第 6 章 value class 要解决的是同一类问题:位置携带的信息,编译器帮不了你。)

✦ 新炉料 · 名字解构 2.3.20 实验
// 按名字匹配,顺序无关
val (mail = email, name = username) = user
println(name)    // alice     ✓
println(mail)    // alice@... ✓

// 完整模式下,位置解构改用方括号,语法上明确区分开:
val [username, email] = user     // 位置
val (email) = user               // 名字(只取 email 这一个)

开启:-Xname-based-destructuring=only-syntax(也有 name-mismatch / complete 两档更严格的模式)。实验中,语法可能还会调整

在它转正之前,实用的防御是:超过两个字段就别解构了,老实写 user.email。解构省下的那点字符,不值得赌一个静默 bug。

解构还有两个你可能没用过的地方:

// lambda 参数里解构(Map 遍历的标准写法)
map.forEach { (k, v) -> println("$k=$v") }

// 循环里解构
for ((index, item) in list.withIndex()) { }

// 忽略不要的位置:用下划线
val (_, mail) = user

回头看:这一炉的三块地基

塑形炉到这里,Kotlin 的「Java 抄不走」清单已经成型了。注意它们其实是互相咬合的:

  • inline(第 9 章) → 让高阶函数零开销,让非局部返回成为可能 → 于是标准库敢用函数替代语法(userepeatsynchronized)。
  • 带接收者的 lambda(本章) → 让 lambda 里的 this 可替换 → 于是有了作用域函数(第 10 章)和一切 DSL。
  • 两者相加你可以用普通函数「造」出新的语法结构,而它和内置语法享有同等的性能与行为。Compose 的 @Composable fun Column(content: @Composable ColumnScope.() -> Unit) 就是这两块地基的极致产物 —— 它看起来像语言特性,其实只是一个带接收者的 lambda 参数。
✚ 动手锻打

1. 找出项目里从 Java 抄来的 Builder 类setX().setY().build()),用具名参数 + 默认值重写一个。删掉的行数会让你开心。

2. 如果项目里有自己写的 DSL(或者你打算写一个):检查有没有 @DslMarker。没有的话现在加 —— 作用域泄漏是静默 bug,等出问题再加就晚了。

3. 搜项目里的解构 val (,逐个检查:被解构的类里有没有相邻的同类型字段?有的话,那一行就是一个等待触发的静默 bug。改成显式取字段,或者升级到 2.3.20 用名字解构。

4. 搜循环里的 list += item(且 list 是 var List)—— 每一次都在复制整个列表。换成 buildList { }

下一章:2.4 刚转正的 context parameters —— 终结「为了最底层那一行日志,把 logger 顺着五层函数签名往下传」。