Kotlin DSL深度解析:从Gradle脚本到构建你自己的DSL

本文译自「Kotlin DSL Deep Dive: From Gradle Scripts to Building Your Own」,原文链接proandroiddev.com/kotlin-dsl-...,由Ioannis Anifantakis发布于2026年7月2日。

引言

你一定到处都见过"Kotlin DSL"这个词。它存在于你的 Gradle 文件中,存在于 Jetpack Compose 的讨论中,存在于每一篇 Ktor 教程中。如果你曾经问过"好吧,但它到底是什么?",你很可能得到的答案只有两种:要么是一个字典定义("领域特定语言!"),但什么也没解释;要么是一大段代码,假设你已经知道。

本文将弥补这方面的空白。 读完本文,你将彻底理解 Kotlin DSL 是什么,那些 .kts 文件是什么,以及 Gradle 为什么使用它们;为什么像 dependencies { implementation("...") } 这样的代码实际上是披着 Kotlin 外衣的普通 Kotlin 代码;以及如何一行一行地构建你自己的 DSL(一个可用的 HTML 构建器)。

开始之前再介绍一个缩写。 如果你之前从事 Android 开发,你肯定也接触过 KTXcore-ktxfragment-ktx......)。 KTX 不是 Kotlin DSL,也与 .kts 文件没有任何关系,尽管它们都以字母开头。KTX 库是扩展函数,用于优化 Android 的单个调用;而 DSL 则构建了一整套嵌套词汇表。两者本质相同,但本质不同。第二部分将详细阐述这两者。

无需任何 DSL 知识。让我们一劳永逸地解决这个问题。

相关 GitHub 仓库:

https://github.com/ioannisa/HtmlBuilder-Kotlin-DSL-Demo

重要提示:
本文中的所有代码都位于上面链接的配套项目中。
这是一个 Android 应用,它会在实时 WebView 中渲染 DSL 的输出,并提供一个交互式标签页,你可以在其中添加产品并查看第 5 部分中的循环重新渲染。
DSL 本身位于一个纯 Kotlin 文件中,没有任何 Android 导入,并且单元测试会逐字逐句地验证下面打印的每个输出。

第 1 部分:"DSL"的真正含义

领域特定语言 是一种专为特定任务而设计的语言,与......相对。一种适用于任何工作的通用语言。

你每天都在使用领域特定语言 (DSL):

  • SQL 是一种用于查询数据的 DSL。你不能用它构建移动应用,但对于"查找所有 30 岁以上的用户"这样的查询,它无可匹敌。

  • 正则表达式 是一种用于文本模式匹配的 DSL。

  • CSS 是一种用于描述视觉样式的 DSL。

  • HTML 是一种用于描述文档结构的 DSL。

这些语言都无法像 Kotlin 或 Java 那样高效。这正是关键所在。DSL 通过放弃通用性,可以在其领域内展现出更强大的表达能力。 SELECT * FROM users WHERE age > 30 几乎可以媲美英语。而通用语言中对应的语句则需要循环、条件语句和累加器列表。功能更强大,但也更繁琐。

外部 DSL 与内部 DSL

本文的所有内容都围绕着这个区别展开。

外部 DSL 是独立的语言。SQL、正则表达式和 CSS 都有各自的语法、解析器和规则。有人专门为它们编写了编译器或解释器。构建一个外部 DSL 是一项艰巨的工程。

内部 DSL(也称为嵌入式 DSL)则走了一条捷径:它不是发明一种新语言,而是对现有的通用语言进行改造,直到用该语言编写的_有效代码_看起来像专用语言编写的代码。不需要新的解析器,也不需要新的编译器。每一行代码都是普通代码------只是看起来不一样。

当人们说"Kotlin DSL"时,他们指的是用 Kotlin 编写的内部 DSL。仅此一点便可解开大部分谜团:

Kotlin DSL 并非一种新语言。 它本质上是普通的 Kotlin 代码,只是使用了一些语言特性,使其读起来像是一种配置格式。

这就引出了一个显而易见的问题:这些特性究竟是什么?

第 2 部分:使 Kotlin 成为 DSL 机器的五个功能

有些语言是内部 DSL 的良好宿主;有些很糟糕。 Kotlin 恰好是最好的之一,这并非偶然 - JetBrains 在设计这些功能时明确考虑了 DSL。他们一共有五个,第五个是承担重任的人。

功能 1:尾随 lambda 语法

在 Kotlin 中,如果函数的最后一个参数是 lambda,你可以将其移到括号之外:

kotlin 复制代码
// These are the same call:
repeat(3, { 
  println("Hi") 
})
repeat(3) { 
  println("Hi") 
}

如果 lambda 是 only 参数,则可以完全删除括号:

go 复制代码
// These are the same call:
run({ 
  println("Hi") 
})
run { 
  println("Hi") 
}

暂停一下第二个例子。 run { ... } 看起来不像函数调用。它看起来像一个语言关键字。就像带有块的"if"或"while"。这种错觉是每个 Kotlin DSL 的基础。当你在 Gradle 文件中看到以下内容时:

kotlin 复制代码
dependencies {
    // ...
}

...你正在查看一个名为"dependency"的函数,该函数使用单个 lambda 参数进行调用。就是这样。 Gradle 语法之谜已经解开了一半。

特性2:扩展功能

Kotlin 允许你向你不拥有的类型添加函数:

kotlin 复制代码
fun String.shout() = this.uppercase() + "!!!"
"hello".shout()  // "HELLO!!!"

"String"是标准库中的最终类,但我们只是给了它一个新方法。 (在底层,它编译为一个以字符串作为参数的静态函数,因此实际上没有修改任何类。)对于 DSL,这意味着你可以将域词汇附加到任何类型,包括来自你不控制的库的类型。

如果你是一名 Android 开发人员,那么你已经拥有了一大堆这些库:KTX 库core-ktxfragment-ktx 等)本质上是此功能的产品化;将 Java Android API 封装在惯用的 Kotlin 中的扩展函数,因此你可以编写 view.isVisible = true 而不是 view.visibility = View.VISIBLE

KTX 和 DSL 是同一个工具箱中的近亲,但有着不同的目标:

  • KTX 使_单次通话_变得更好;
  • DSL 使用接收器(功能 5,即将推出)来构建_嵌套结构_。

这条线在一些地方变得模糊------来自 core-ktx 的"sharedPreferences.edit { putString("key", value) }"是一个带有接收器的 lambda,一个单块迷你 DSL。

为了消除混淆,本文旨在解决:KTX 与 **.kts** 无关 --- 一个是库命名约定,另一个是 Kotlin 脚本的文件扩展名,我们将在第 3 部分中介绍。

特性 3:中缀函数

可以在没有点和括号的情况下调用标记为"infix"的函数:

kotlin 复制代码
infix fun Int.times(str: String) = str.repeat(this)
3 times "Hey "  // "Hey Hey Hey "

你已经使用了它而没有注意到:mapOf("a" to 1) 中的to 不是语法。它是泛型类型的中缀扩展函数,返回"Pair"。

测试框架非常依赖于此。 Kotest 的"结果应该是 42"是一个中缀函数调用。

特性 4:运算符重载

Kotlin 允许你通过带有"operator"关键字的函数定义"+"、"-"、"\[\]"、"()"和友元对你的类型的含义:

kotlin 复制代码
operator fun File.plus(other: File): File = File(this, other.name)

Gradle 的 Kotlin DSL 在 sourceSets["main"] 等地方使用了它。该括号访问是对类似集合的容器的"operator fun get(name: String)"调用。

特征 5:带有接收器的 Lambda 函数------大接收器

这就是功能。如果你理解了本节,你就理解了 Kotlin DSL。如果需要,请阅读两遍;之后的一切都取决于它。

带有参数的普通 lambda 看起来像这样:

kotlin 复制代码
val greet: (StringBuilder) -> Unit = { sb ->
    sb.append("Hello")
    sb.append(", world")
}

在 lambda 内部,我们必须通过参数 sb 来执行任何操作_(或者__it_如果你没有为参数指定名称)。现在看看带有receiver_版本的_lambda:

kotlin 复制代码
val greet: StringBuilder.() -> Unit = {
    append("Hello")
    append(", world")
}

有两件事发生了变化:

  • 类型从 (StringBuilder) -> Unit 变为 StringBuilder.() -> Unit。将其理解为"StringBuilder 上的函数"而不是"采用 StringBuilder 的函数"。匿名扩展函数。
  • 在 lambda 内部,参数消失了。我们直接调用 append(...) 而不是 sb.append(...)

为什么这样有效?因为在带有接收器的 lambda 中,**this** 是接收器对象。 lambda 的主体就像"StringBuilder"的方法一样执行。就像你不在类自己的方法中编写 this.append() 一样,你也不会在这里编写它。接收者是隐式的。

现在将其与尾随 lambda 语法结合起来。

这是一个微小但完整的 DSL 风格的函数。这是标准库的"buildString"使用的模式:

kotlin 复制代码
// DSL Definition
fun buildString(builderAction: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.builderAction()   // run the lambda with sb as the receiver
    return sb.toString()
}
// Call site
val message = buildString {
    append("Hello")
    append(", world")
}

查看调用站点。 一个块,你可以在其中"just say " append(...) 并且它可以工作。视线中没有物体,没有点,没有样板。感觉就像是一种用于字符串构建的特殊迷你语言。就是普通Kotlin的三行。

这就是整个技巧。_ 你见过的每一个 Kotlin DSL (Gradle、Ktor 的路由、kotlinx.html)是接受带有接收器的 lambda 表达式的函数的某种排列,因此每个 _{ }_ block 会改变 _this_ 是什么,从而改变你可以"直接说"的函数。

Jetpack Compose 层位于顶部 *。一个编译器插件,重组,*_@Composable_machinery。但它的作用域块,比如 _RowScope_决定 _weight()_合法,正是这个接收器技巧。

并且你一直在没有 DSL 标签的情况下使用它:

标准库的作用域函数("apply"、"run"、"with")是建立在其他任何东西之上的微型 DSL。 person.apply { name = "Ioannis" } 只是一个以 person 作为接收者的 lambda。

嵌套块只是嵌套接收器:

kotlin 复制代码
html {            // 'this' is an Html object  → you can call body()
    body {        // 'this' is a Body object   → you can call p()
        p {       // 'this' is a P object      → you can call text fns
            +"Hello"
        }
    }
}

每一层嵌套都会交换一个新的隐式接收器,从而交换一个新的词汇表。 这就是 DSL 获得结构的方式:类型系统本身决定在哪里允许什么。

Write _body { body { } }_ 它不会编译。 _Body_ 没有 _body()_ 函数。你的"语言"有语法规则,由编译器强制执行,免费。

我们将在第 5 部分中准确构建这个 HTML DSL。但首先,让我们利用我们对带你来到这里的文件的新知识。

第 3 部分:".kts"文件实际上是什么

是时候揭开文件扩展名的神秘面纱了。

**.kt** 是 Kotlin 源文件。它包含声明(类、函数、属性),并被编译为模块的一部分:应用程序、库、测试套件、Gradle 插件。

A **.kt** 文件永远不会自行运行;它成为所构建的东西的一部分,如果该东西是可执行程序,那么它需要一个像"main()"这样的入口点。

**.kts** 是一个 Kotlin 脚本。 "s"代表 script 脚本文件可以包含顶级可执行语句。仅从上到下_运行_的代码,不需要"main()":

kotlin 复制代码
// hello.kts --- this whole file is valid as-is
val name = "Ioannis"
println("Hello, $name")
println("Today is ${java.time.LocalDate.now()}")

如果安装了Kotlin编译器,可以直接运行:

kotlin 复制代码
kotlinc -script hello.kts

想想 **_.kts_** 作为 Kotlin 对 shell 脚本或 Python 文件的回答: 你执行的文件,而不是你构建到某个东西中的文件。

Kotlin 脚本还允许 主机应用程序 嵌入并运行脚本,控制这些脚本可以看到和执行的操作......这将我们带到了 Gradle。

那么什么是build.gradle.kts

Gradle 是一个构建工具,你的构建_配置_需要具有表现力:条件、循环、变量、逻辑。

因此,Gradle 没有使用 JSON 或 XML 等静态格式,而是将其配置文件设为_可执行脚本_。多年来,这些都是用 Groovy 编写的(build.gradle);从 Gradle 5.0 开始,你可以用 Kotlin 编写它们,而不是 build.gradle.kts,Gradle 在配置你的构建时编译并执行的 Kotlin 脚本。

这是几乎没有教程大声说出来的部分:

_build.gradle.kts_ 是一个 Kotlin 脚本,它执行 使用 Gradle **_Project_** _object 作为其隐式接收器 __ .

整个文件的行为就像一个带有接收器的巨型 lambda;与第 2 部分中的机制完全相同,应用于文件规模。在"build.gradle.kts"内部,"this"(实际上)是一个"Project"。这意味着该文件中的每个"神奇关键字"只是"Project"的成员或扩展:

kotlin 复制代码
plugins {                          // a function on Project, taking a lambda
    kotlin("android")              // a function returning a plugin spec
}
android {                          // extension function added by the Android plugin
    compileSdk = 36                // property on the AndroidExtension receiver
    defaultConfig {                // another nested lambda with receiver
        minSdk = 24
    }
}
dependencies {                     // Project.dependencies(config: DependencyHandlerScope.() -> Unit)
    implementation("io.ktor:ktor-client-core:3.0.0")
}

用你的新眼睛浏览 **依赖关系** **:**这是一个带有尾随 lambda 的函数调用。

lambda 的接收者是"DependencyHandlerScope"。 implementation(...) 是该接收器上可用的函数。第 2 部分中的三个功能_(尾随 lambda、扩展函数、带有接收器的 lambda)_,整个文件不再那么神奇。

这也解释了人们对 Gradle Kotlin DSL 感到困惑的事情:

  • 为什么自动完成功能在 **.kts** 中起作用,但在 Groovy **build.gradle** ** 中不起作用?** 因为 Kotlin 脚本是静态类型的。 IDE 知道"this"是一个"Project",知道"android { }"公开什么,并且可以在编辑时提供补全并捕获拼写错误。 Groovy 在运行时动态解决大部分问题,因此 IDE 主要是猜测。
  • 为什么在构建运行之前拼写错误就会失败? 同样的原因 - 脚本已_编译_。拼写错误的属性是编译错误,而不是运行时意外。
  • 什么是 **settings.gradle.kts** 然后? 不同接收器的相同想法:Settings 对象而不是 Project。不同的接收者,不同的词汇------这就是为什么 include(":app") 可以工作,但 dependency { } 却不行。接收者就是语言。

一个诚实的警告:Kotlin DSL 的第一个构建可能比 Groovy 慢,因为脚本是编译的,并且大量使用"buildSrc"可能会损害增量构建。对于大多数项目来说,IDE 支持是值得的,自 2023 年以来,它一直是 Gradle 对于新项目的默认支持。

谁定义了词汇? Gradle 与插件(以及 AGP 适合的地方)

一个常见的困惑点,尤其是对于 Android 开发者来说:

  • 你有一个 Gradle 版本(在 gradle-wrapper.properties 中),
  • AGP 版本(在 libs.versions.toml 中),
  • 并且它们是不同的数字。那么谁负责 DSL 呢?

答案是明确的分工:

  • Gradle 本身附带了 Kotlin DSL: 编译 .kts 脚本的机制、隐式的 Project 接收器和核心词汇:plugins { }dependency { }repositories { }tasks { }。这都与你的 Gradle 版本有关。
  • 插件用新词汇扩展了 DSL。 Android Gradle 插件没有定义 Kotlin DSL;它会说话。当你应用"com.android.application"时,AGP 在你的"Project"上注册一个扩展对象,而该注册正是使"android { }"块存在的原因。其中的所有内容(compileSdkdefaultConfig { }buildTypes { })都是 AGP 扩展类的 API,它成为该块的接收者。删除插件,android { } 就会出现编译错误,因为该函数实际上已经不存在了。 Kotlin Gradle 插件以同样的方式添加 kotlin { }
  • **libs.versions.toml** 两者都没有定义。 版本目录是一个普通的 TOML 文件_(静态格式,而不是 DSL)_,其作用只是确定加载的插件和库的版本。 (然后,Gradle 生成你在脚本中使用的类型化"libs.versions.agp"样式访问器。)

在本文中: Gradle 提供语言,插件提供词汇,目录固定版本。

这也是为什么 IDE 仅在与应用的插件同步后自动完成 _android { }_ ;接收器类型来自 AGP 的 jar,而不是来自 Gradle._

这就是为什么两个版本号独立但不是任意移动的原因:每个 AGP 版本都需要最低 Gradle 版本(Google 文档中的兼容性矩阵)。

如果说有什么不同的话,那就是 Kotlin DSL 的设计得到了回报:Gradle 的构建语言可以由第三方扩展,而无需 Gradle 对 Android 有任何了解。任何插件作者都可以使用第 2 部分中的接收器技巧将类型化、自动完成的块添加到你的构建脚本中,以及你在第 5 部分中为自己的域执行的操作。

模式,无处不在

一旦你看到"函数 + 尾随 lambda + 接收者",你就无法取消它。

Ktor 服务器设置:

kotlin 复制代码
embeddedServer(Netty, port = 8080) {
    routing {                       // receiver: Routing
        get("/hello") {             // receiver: RoutingContext
            call.respondText("Hi!")
        }
    }
}.start(wait = true)

协程作用域(launch { }withContext { })、kotlinx.serialization 配置、Koin 的 module { }、Compose 的布局块。不同的领域,相同的机器。你现在可以流利地阅读所有内容。

第 4 部分:在构建 DSL 之前对 DSL 进行剖析

构建 DSL 是一个设计练习,然后才是编码练习。食谱:

  1. 决定调用站点应该是什么样子。 首先编写梦想代码,就好像 DSL 已经存在一样。
  2. 识别接收者。 你梦想代码中的每个"{ }"块都需要一个类。该类的成员定义了块内的合法内容。
  3. 编写构建器函数来创建接收者对象,将用户的 lambda 应用于它,并向父级注册结果。
  4. 添加护栏@DslMarker - 即将推出),以便编译器强制执行你的语法。

我们梦想的代码:我们承诺的 HTML 构建器:

kotlin 复制代码
val page = html {
    head {
        title { +"My Page" }
    }
    body {
        h1 { +"Welcome" }
        p {
            +"This entire page is "
            b { +"plain Kotlin" }
            +"."
        }
    }
}
println(page.render())

我们需要发明三件事:

  • 标签类别(接收者),
  • 构建器函数(htmlbodyp...)、
  • 以及那个奇怪的 +"text" 语法。

我们开始吧。

第 5 部分:逐步构建 HTML DSL

第 1 步:对域建模

HTML 文档中的所有内容都是一个节点:文本节点或元素(包含其他节点的标签)。经典复合图案:

kotlin 复制代码
interface Node {
    fun render(indent: String = ""): String
}
class TextNode(private val text: String) : Node {
    override fun render(indent: String) = "$indent$text\n"
}
open class Tag(private val name: String) : Node {
    private val children = mutableListOf<Node>()
    fun addChild(node: Node) {
        children.add(node)
    }
    override fun render(indent: String): String = buildString {
        append("$indent<$name>\n")
        children.forEach { append(it.render("$indent  ")) }
        append("$indent</$name>\n")
    }
}

请注意这个小小的测试时刻:"render"使用"buildString";我们在第 2 部分中剖析了 lambda-with-receiver 函数。

还没有 DSL。直接使用它正是我们想要转义的样板:

kotlin 复制代码
val page = Tag("html")
val body = Tag("body")
val p = Tag("p")
p.addChild(TextNode("Hello"))
body.addChild(p)
page.addChild(body)

功能性强,但页面的_结构_(里面有什么)_是不可见的。你必须跟踪变量名称才能重建树。 DSL 的工作是使代码的形状与文档的形状相匹配。

第 2 步:核心构建器技巧

这是每个构建器 DSL 的核心,一个通用助手:

kotlin 复制代码
class Html : Tag("html")
class Head : Tag("head")
class Body : Tag("body")
private fun <T : Tag> Tag.initTag(tag: T, init: T.() -> Unit): T {
    tag.init()      // run the user's block with the new tag as receiver
    addChild(tag)   // attach it to the parent (this)
    return tag
}
fun html(init: Html.() -> Unit): Html {
    val html = Html()
    html.init()
    return html
}
fun Html.head(init: Head.() -> Unit) = initTag(Head(), init)
fun Html.body(init: Body.() -> Unit) = initTag(Body(), init)

慢慢读"initTag"。它是三行,它是整个引擎:

  • init: T.() -> Unit:用户的 { } 块,类型为 lambda,其接收者是新标签。在该块内,"this"将是新创建的标签,因此块内的调用适用于 it
  • tag.init(): 在新标签上执行用户的块。该块创建的任何子代都会添加到"tag"中。
  • addChild(tag): 将完成的标签挂接到父标签中。由于 initTagTag 的扩展,因此父级是 current 接收器------无论我们在词法上位于哪个块。

最后一点值得一提:嵌套之所以有效,是因为在 DSL 中的任何点,"父代"只是封闭块的接收者。 lambda 的嵌套就是文档的嵌套。无需管理堆栈,也无需全局"当前节点"------Kotlin 的作用域负责记账。

另请注意"head"和"body"是什么:"Html"上的扩展函数。这是一个语法规则。你只能调用"body { }",其中"this"是"Html",即直接在"html { }"块内。尝试将"body"放入"p"内,但编译器拒绝。我们将 HTML 规则编码到类型系统中。

步骤 3:内容标签和 + 技巧

kotlin 复制代码
class Title : Tag("title")
class H1 : Tag("h1")
class P : Tag("p")
class B : Tag("b")
fun Head.title(init: Title.() -> Unit) = initTag(Title(), init)
fun Body.h1(init: H1.() -> Unit) = initTag(H1(), init)
fun Body.p(init: P.() -> Unit) = initTag(P(), init)
fun P.b(init: B.() -> Unit) = initTag(B(), init)

现在,发短信。我们想在标签内写入 +"Welcome" 并将其变成文本节点。这就是运算符重载(特性 4)。我们在 String 上定义一元加号,作为 Tag 的成员:

kotlin 复制代码
open class Tag(private val name: String) : Node {
    // ...everything from before...
    operator fun String.unaryPlus() {
        addChild(TextNode(this))
    }
}

这个声明值得拆包,因为它使用了一个微妙的 Kotlin 功能:它是在"Tag"内部声明的"String"的扩展函数,因此它有两个 接收器。 this 是字符串;封闭的"Tag"也在范围内(如"this@Tag",这就是裸露的"addChild"解析的内容)。

效果:+"Welcome" 仅在标签块内进行编译,并将文本添加到该标签中。在 DSL 之外,+"some string" 仍然是它应该是的编译错误。

为什么 **+** 根本没有? 因为像"Welcome"这样的裸字符串表达式在其自己的行上是有效的 Kotlin,不执行任何操作。它评估并被丢弃,编译器不会抱怨。 DSL 需要 some 标记来表示"该字符串是内容,请添加它"。 Unary plus 是可用的最轻量级标记。 (kotlinx.html,此设计镜像的 JetBrains 库,做出了相同的选择。)

第 4 步:有效

kotlin 复制代码
fun main() {
    val page = html {
        head {
            title { +"My Page" }
        }
        body {
            h1 { +"Welcome" }
            p {
                +"This entire page is "
                b { +"plain Kotlin" }
                +"."
            }
        }
    }
    print(page.render())
}

输出:

kotlin 复制代码
<html>
  <head>
    <title>
      My Page
    </title>
  </head>
  <body>
    <h1>
      Welcome
    </h1>
    <p>
      This entire page is 
      <b>
        plain Kotlin
      </b>
      .
    </p>
  </body>
</html>

大约 60 行普通 Kotlin,我们就有了一种用于编写 HTML 的类型化、IDE 完成、编译器检查的语言。标签拼写错误:编译错误。将标签放在不属于的地方:编译错误。任何块内的自动完成功能准确地显示_(且仅)_那里的合法内容。

而且因为 DSL 就是 Kotlin,所以_所有 Kotlin 都在其中运行_。这是 XML 和 JSON 等外部格式永远无法拥有的超能力:

kotlin 复制代码
val products = listOf("Keyboard", "Mouse", "Monitor")
val page = html {
    body {
        h1 { +"Products" }
        for (product in products) {
            p { +product }
        }
    }
}

标记内部的循环,不需要模板引擎。条件、函数、提前回报------一切都是免费的,因为没有边界可以跨越。 Kotlin 从来都不是。

步骤 5:护栏 --- @DslMarker

仍然存在一个缺陷。在嵌套块内,外部接收器仍在范围内。这意味着这可以编译:

kotlin 复制代码
html {
    body {
        p {
            body { }   // ouch --- resolves against the outer Html receiver
        }
    }
}

p { } 内部,Kotlin 无法在 P 上找到 body(),因此它向外遍历封闭的接收器,在链上找到 Html,并帮助解析那里的调用。结果是"body"两次静静地嵌套在"html"中。我们构建的语法有漏洞。

Kotlin 的修复是专门为 DSL 构建的:

kotlin 复制代码
@DslMarker
annotation class HtmlDsl
@HtmlDsl
open class Tag(private val name: String) : Node { /* ... */ }

定义一个注释,用"@DslMarker"标记它,将其应用到你的接收器类_(注释基类覆盖所有子类)_。它强制执行的规则:当两个隐式接收器携带相同的 DSL 标记时,只有最里面的接收器可以访问p { } 内的 body { } 调用现在无法编译,并出现明显错误。泄漏处被密封。

如果你曾经想知道为什么 Compose 有 @ComposableTargetMarker 样式的范围,或者为什么 kotlinx.html 不允许你在 span 的文本中写入 div,这个注释或这个想法就是原因。一行注释,你的 DSL 语法就无懈可击。

第 6 部分:你应该构建 DSL 吗?

现在你可以了,更难的问题是:你应该吗?

当你描述_结构化、嵌套、声明性_事物时,请使用 DSL:配置、UI 树、标记、路由表、测试场景、查询构建器。当代码的形状可以反映数据的形状时,当类型系统可以强制执行"什么去哪里"时,以及当许多人经常阅读或编写这些描述时,该模式就会发光。 这正是 Gradle、Compose 和 Ktor 驻扎在这里的原因。

逻辑是顺序的而不是结构化的(简单的函数更清晰)时,当只有一个调用站点时(抽象成本超过它节省的成本),或者当你纯粹受到美学的诱惑时,请跳过 DSL。 DSL 是一个带有意见的 API------每一个意见都是下一个开发人员必须学习的东西。问题"这个函数在哪里定义的?"以及"现在'这个'是什么?"在深度嵌套的接收器范围内变得更难回答;好的 DSL 可以保持嵌套浅和词汇量小。与任何聪明的 API 一样,如果没有设计规范,DSL 的增长就会变成迷宫:调用站点的魔法意味着它下面的间接性。调试也继承了这种间接性------当 DSL 内部出现故障时,堆栈跟踪通常指向构建器内部而不是导致问题的调用站点,因此尽早投资于良好的错误消息。

一个合理的经验法则是:DSL 必须由很多人多次编写,或者阅读的次数远多于其实现 --- 否则会编写出无聊的代码。

TL;DR------整篇文章六行

  1. DSL 是一种针对一个域的迷你语言; Kotlin DSL 是一个_内部_ 的 --- 普通的 Kotlin 风格,读起来像配置格式。没有新的解析器,没有新的语言。
  2. 这个错觉是由五个功能构建的:尾随 lambda扩展函数中缀函数运算符重载 ,以及最重要的带有接收器的 lambda - 每个 { } 块都会交换一个新的隐式 this,后者会交换一个新的词汇表。
  3. **.kts** = Kotlin 脚本 :从上到下执行的文件,不需要 main()
  4. **build.gradle.kts** 是一个 Kotlin 脚本,其隐式接收者是 Gradle Project --- 因此 dependency { } 只是一个带有尾随 lambda 的函数调用,这就是 IDE 可以自动完成它的原因。
  5. 构建自己的 :设计梦想的调用站点,为每个块提供一个接收器类,将它们与 init: T.() -> Unit 构建器函数连接,并使用 **@DslMarker** 密封范围泄漏。
  6. 当结构、重复性和可读性都证明合理时,构建一个。否则,请编写无聊的代码------无聊的代码被低估了。

下次当你的团队中有人嘀咕"这个 Gradle 语法是什么"时,你确切地知道该告诉他们什么:它始终都是 Kotlin。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
烽火聊员1 小时前
查看Android Studio错误日志
android·ide·android studio
星空语7 小时前
音频001_Android+Linux车载/手机音频全链路分层架构图
android·linux·音视频
CyL_Cly7 小时前
解决官网下载慢!Android Studio 最新版网盘下载
android·ide·android studio
智塑未来7 小时前
鸿蒙系统对比安卓、iOS,核心优势是什么?
android·ios·harmonyos
AI刀刀7 小时前
豆包智能体对话导出后,如何构建长期归档与高效检索体系?
android·人工智能·word·excel·ai导出鸭
初雪云9 小时前
iOS 上架入门:证书、描述文件、IPA 上传到底是什么关系?
android·ios·自动化·产品经理·iphone
玩机达人8812 小时前
一加12/Ace5/Ace3pro手机ColorOS16降级回锁BL熔断变黑砖怎么办
android·智能手机·电脑
Digitally12 小时前
使用 USB 数据线将文件从安卓设备传输到电脑
android·电脑
xieliyu.13 小时前
MySQL 六大基础约束详解:NOT NULL/DEFAULT/UNIQUE/ 主键 / 外键 / CHECK
android·数据库·sql·mysql