之前在《Effective Kotlin》 一书中,有一条专门讲解 DSL 的:考虑为复杂的对象创建定义 DSL,让我对 DSL 有了一定的了解。
为了能够更熟悉掌握Kotlin上的DSL,用这篇 Blog 来学习记录下。
文章目录
- 1. 概述
- 1.1 DSL 是什么
- 1.2 Kotlin 自定义 DSL 的优缺点
- 1.3 Kotlin Gradle DSL
- 2. 准备阶段
- 2.1 扩展函数
- 2.2 带有接收者的函数类型
- 2.2.1 是什么?
- 2.2.2 怎么用?
- 2.2.3 带来的意义
- 2.3 `@DslMarker` 注解
- 3. 定义一个简单的HTML DSL
- 3.1 目标
- 3.2 实现
- 4. Kotlin 中的 DSL API
- 4.1 语法糖with 、apply等
- 4.2 协程库
- 4.3 compose
- 4.4 Gradle DSL
- 4.5 其他
- 5. 总结
- 参考
1. 概述
1.1 DSL 是什么
DSL 全称:领域特定语言(domain-specific language),这个翻译不够直观,它应该叫“特定领域的语言”。
这里的“领域”指的是编程场景,例如:
- 字符串匹配
- 网络请求
- 视图树构建
- 数据库操作…
这些都是一个 App 项目中随处出现的场景,而我们可以用一个语言来全部处理它们,例如 Java、C++、Python,这些语言被称为领域通用语言(General Purpose Language, GPL),它就是 DSL 的对立概念,也是我们每天都在使用的东西。
所以你马上就知道 DSL 是什么了: DSL 是专门处理特定编程场景的语言或范式。
例如:
-
SQL 语言
:专门处理数据库的语言 -
正则表达式
:专门处理字符串匹配的范式 -
HTML
:专门处理 Web 页 -
XML
:我们在 Android 开发时使用 XML 来布局视图信息
这几个玩意的特点是什么呢:它们表达式看着会蛮奇怪的,第一次看还会觉得不优雅美观, 但是它们能够高效处理这个领域的问题,而且熟悉之后,会发现其表达能力很强,例如 SQL 只用几个特定的条件,就能筛选你想要的数据, 正则表达式定制了一套业内通用的匹配字符串规则。它们自身提供的能力在解决这些问题领域上是非常强大的。
这也体现出了 DSL 的目标:提高特定场景下的编程效率。
我们可以在 GPL 中使用 DSL。例如 Java 内嵌了正则表达式(Regex
)。此时 Java 被称为执行 DSL 的宿主语言(Host Language)。
GPL和DSL没有很明显的界限,一些通用语言被设计成对使用 DSL 友好的。Kotlin 就是这样的语言,其扩展函数特性可以方便我们去写一些自定义的 DSL。
1.2 Kotlin 自定义 DSL 的优缺点
对于应用层开发来说,我们会在一些时候使用编程语言内嵌的 DSL(如上面说的那些),这就已经满足日常开发的需求了。DSL 更深层的东西,反而是一些有关设计层、语言底层相关人士会去关注的,那为什么 Kotlin 这类语言会给应用层提供自定义 DSL 的能力呢?
这是因为 自定义 DSL 对应用层来说,具备一个也是唯一一个的优势:它能消除创建对象时的样板代码。良好的自定义 DSL,可以让代码更加简洁,这是符合 Kotlin 这类语言的特性的(简洁务实)。在下面几个对象创建场景中,是非常适合使用自定义 DSL 的:
- 具备复杂的数据结构
- 具备多样的层次结构
- 具备海量数据
除此之外的场景,使用 DSL 就会显得冗余,而且自定义 DSL 的过程比较复杂,实现成本较高,所以非必要不使用!
1.3 Kotlin Gradle DSL
Kotlin 内置了 Gradle DSL,可以让我们用 DSL 风格去写 Gradle 脚本,这样做的好处是:我们不用去学习 Groovy
才能写 Gradle 脚本,而是可以直接用 Kotlin 来写,本篇最后也会简单的介绍一下用 Gradle DSL 在 Kotlin 上写脚本。
2. 准备阶段
想要在 Kotlin 上使用 DSL,首先需要掌握 Kotlin 的几个特性:
- Lmabda表达式(不赘述)
- 扩展函数(Must)
- 带有接收者的函数类型(Must)
-
@DslMarker
注解 (Optional)
2.1 扩展函数
扩展函数,也就是元编程,方便我们在对象定义的范围之外,为它增添其它函数、属性,这是 Kotlin 非常重要的特性,它可以让接口、类保持很高的整洁度(本身提供),同时具备大量功能(扩展函数提供),符合开放封闭原则。一个 Koltin 开发人员几乎会在每次编程中都会用到它。
使用方式是:
// ClassName 表示要扩展的类, functionName 表示扩展函数名,后面带上参数,和具体实现
fun <ClassName>.<functionName>(...) { }
我们可以给系统类扩展,例如给 String 扩展打印长度的功能:
/**
* 打印 String 的
*/
fun String.printlnLength() {
println(this.length)
}
// 使用
"AAA".printlnLength()
还可以给自定义类扩展:
class MyClass {
fun doA() {..}
}
/**
* 在 predicate 为 true 时,调用 doA
*/
fun MyClass.doAIfTrue(predicate: Boolean) {
if (predicate) doA() else Unit
}
// 使用
val mc = MyClass()
mc.doAIfTrue(false)
2.2 带有接收者的函数类型
带接收者的函数类型,从定义上看较难理解,例如:fun Int.(other: Int) { ... }
2.2.1 是什么?
咱们先从最基本的开始讲,也就是函数类型。要创建函数类型的实例,有下面几种方式:
- 使用 lambda 表达式
- 使用匿名函数
- 使用函数引用
例如下面函数:
fun plus(a: Int, b: Int) = a + b
如果要将该函数转化成实例,可以根据上面三种方式写出如下代码:
val plus1: (Int, Int)->Int = { a, b -> a + b }
val plus2: (Int, Int)->Int = fun(a, b) = a + b
val plus3: (Int, Int)->Int = ::plus
在上面的例子中,由于指定了实例的类型,因此 lambda 表达式和匿名函数中的参数类型可以被 Kotlin 推断出来 – a 是 Int,b是 Int,函数的结果需要一个 Int。
当然也可以反过来,我们指定里面参数的类型,Kotlin 就能反推实例的类型
val plus4 = { a: Int, b: Int -> a + b }
val plus5 = fun(a: Int, b: Int) = a + b
这样子看,匿名函数(plus5
)看起来像是普通函数一样,只是没有名字,lamdba 表达式(plus4
)是匿名函数的一种更简短的表示方法。
OK,我们现在已经有了能够表示普通函数类型的方法了,那么扩展函数呢? 我们也能用同样的方法去表示它们么?
假设现在有扩展函数:
fun Int.myPlus(other: Int) = this + other
上面提到过,我们以与普通函数相同的方式创建匿名函数,但是没有名称,因此匿名扩展函数的定义也是相同的:
val myPlus = fun Int.(other: Int) = this + other
此时 myPlus
是什么类型的?
答案是它是一种用来表示扩展函数的特殊类型,它被称为带有接收者的函数类型。它看起来类似于普通的函数类型,但它在参数之前额外指定了接收方类型,之间用点来分割,相当于是这样子的:
val myPlus: Int.(Int)->Int = fun Int.(other: Int) = this + other
这样的函数可以使用 lambda 表达式定义,特别是带有接收者的 lambda 表达式,因为在其作用域内 this 关键字引用的正是被扩展的接收者(在本例中是 Int
类型的实例):
// this 指向的是第一个Int,也就是接收者, it 指向的是第二个Int, 它们之和指向的是第三个Int
val myPlus: Int.(Int)->Int = { this + it }
好的,带接收者的参数类型就是这样子,如果你了解后,就会发现其顾名思义:一个类型作为接收者, 它接收一些参数,然后和这些参数相互作用,最后产出一个结果,所以就像这样子 :Receiver.(params) -> Result = { operation }
2.2.2 怎么用?
它有三种直接使用方法:
// 使用 invoke
myPlus.invoke(1, 2)
// 类似于普通函数
myPlus(1, 2)
// 普通的扩展函数
1.myPlus(2)
其次,它还可以作为参数进行传递,例如我有一个函数需要打印排序之后的数组:
/**
* 打印一个 整型数组 排序后的结果
*
* @param nums 入参 Int 数组, 后面就是要计算它的长度
* @param sortAlgo 排序算法,我管你是快排、冒泡、堆排,反正能排序就好
*/
fun printIntLength(nums: List<Int>, sortAlgo: MutableList<Int>.() -> List<Int>) {
val len = sortAlgo(nums.toMutableList())
println(len)
}
那么在使用这个函数时,可以自己实现任何 sortAlgo
:
// 搞了个冒泡排序
printIntLength(listOf(10, 2, 30)) {
val n = this.size
(1 until n).map {
val round = it
for (j in 0..n - 1 - round) {
if (this.get(j) > this.get(j + 1)) {
val max = this.get(j)
this[j] = this.get(j + 1)
this[j + 1] = max
}
}
}
this
}
可以看到, printIntLength
后面这个传递的函数就是一个带接收者(MutableList)的函数, 不过它好像有点冗余, 每次用到 接收者时,都要使用 this
,但是由于这个函数体已经隐式给我们提供了 this: MutableList<Int>
了,相当于我们可以不用 this
直接引用接收者的 size
、get
函数,所以我们可以隐藏 this
的调用!
printIntLength(listOf(10, 2, 30)) {
val n = size
(1 until n).forEach {
for (j in 0..n - 1 - it) {
if (get(j) > get(j + 1)) {
val max = get(j)
this[j] = get(j + 1)
this[j + 1] = max
}
}
}
this
}
2.2.3 带来的意义
既然使用起来和扩展函数差不多,那带接收者的函数类型本身的意义是什么呢??
答案是:它改变了 this 的使用含义。
原来:使用 this
是为了更好的区分谁是接收者,可以提高代码可读性,正如:第15条:考虑显式引用接收者所说的那样,例如,下面代码可能会含糊不清:
class Node(val name: String) {
fun makeChild(childName: String) =
create("$name.$childName")
.apply { print("Created ${name}") }
fun create(name: String): Node? = Node(name)
}
fun main() {
val node = Node("parent")
node.makeChild("child")
}
你可能希望打印: Created parent.child
, 但实际打印的是: Created parent
,这是因为 apply 函数的 name
没有指明引用者,它隐式指向的是当前的 Parent Node,为了打印子Node,我们需要显式引用接收者:
class Node(val name: String) {
fun makeChild(childName: String) =
create("$name.$childName")
.apply { print("Created ${this?.name}") } // 通过可空来判断类型
fun create(name: String): Node? = Node(name)
}
// 或者更加直观的:
class Node(val name: String) {
fun makeChild(childName: String) =
create("$name.$childName").apply {
print("Created ${this?.name} in " +
" ${[email protected]}")
}
现在(在DSL的场景):它则是为了刻意隐藏接收者来使代码简洁。如下所示:
table {
tr { // 这里就不用 [email protected]() 了
td { +"Column 1" } // 这里就不用 [email protected]()
td { +"Column 2" }
}
tr {
td { +"Value 1" }
td { +"Value 2" }
}
}
2.3 @DslMarker 注解
该注解主要是为了防止滥用上面所述的机制。例如下面这段代码:
table {
tr { // 1
td { +"Column 1" }
td { +"Column 2" }
tr { // 2 这里实际上调用了 table.tr 方法, 而 table 作为接收者在这个地方是隐式的
td { +"Value 1" }
td { +"Value 2" }
}
}
}
注解1处的 tr
里面还裹了tr
(注解2),可是注解2处的函数,并非是 TrBuilder
的成员函数(像 td
那样),所以它其实是 TableBuilder
的 tr
函数。
这样的写法虽然能够编译通过,但是语法上很容易产生歧义,大家可能会想到: tr
难道是 TrBuilder
的成员函数?它不应该只能是 TableBuidler
的么?
对于注释2出的 tr
的来说,它其实是隐式引用了 table
的接收者,即外部接收者。
所以为了防止这样的用法,Kotlin DSL搞了个 @DslMarker
的注解,它是一个元注解,限制了隐式调用外部接收者。我们可以用它来修饰一个注解, 这个注解修饰的类则无法隐式引用外部接收者,如下面代码所示:
@DslMaker
annotation class HtmlDsl
fun table(f: TableDsl.() -> Unit) { /**..**/ }
@HtmlDsl
class TableDsl { /**..**/ }
有了它,就可以禁止使用外部接收者了:
table {
tr {
td { +"Column 1" }
td { +"Column 2" }
tr { // 编译报错!
td { +"Value 1" }
td { +"Value 2" }
}
}
}
当需要使用外部的接收者时,就必须要显式的引用:
table {
tr {
td { +"Column 1" }
td { +"Column 2" }
[email protected] {
td { +"Value 1" }
td { +"Value 2" }
}
}
}
3. 定义一个简单的HTML DSL
3.1 目标
定义一个 HTML DSL,可以像 HTML 那样,用 Kotlin 代码去写 tr、td等布局,如下所示:
// 创建一个 Table, 父布局是 table, 里面包裹了一个 tr,一个tr里面包裹两个td
fun createTable(): TableBuilder = table {
tr {
for (i in 1..2) {
td {
+"This is column $i"
}
}
}
}
在 html 中,上面的语句就是去创建 HTML 表格,它在 HTML 的代码是这样写的:
<table>
<tr>
<td>This is column 1</td>
<td>This is column </td>
</tr>
</table>
3.2 实现
从 DSL 的开头开始,可以看到一个函数 table
,它处于最顶层,没有任何接收器,所以它需要是一个顶级函数。其次,它的参数是一个带接收者的函数类型,这样就可以直接写一个 Lambda,在 Lambda 表达式中去设置/调用接收者的属性或方法。例如上面 DSL 所展示的,可以直接在 Lambda 表达式中调用 tr
函数,那么 tr
函数就是接收者的成员函数,如下所示:
fun table(init: TableBuilder.()->Unit): TableBuilder {
//...
}
/**
* Table 接收者,它有一个 tr 成员函数,
*/
class TableBuilder {
fun tr() { /*...*/ }
}
同样的,tr
内部会调用 td
函数,所以 td
同样是其接收者的一个成员函数:
class TableBuilder {
fun tr(init: TrBuilder.() -> Unit) { /*...*/ }
}
/**
* Tr 接收者,它有一个 td 成员函数
*/
class TrBuilder {
fun td(init: TdBuilder.()->Unit) { /*...*/ }
}
class TdBuilder
那么如何处理这段代码呢?
+"This is row $i"
这不过只是 String 上的 unaryPlus
操作符而已,因为它是在 td
的函数中使用,所以它需要在 TdBuilder 中进行定义:
class TdBuilder {
var text = ""
operator fun String.unaryPlus() {
text += this
}
}
unaryPlus
操作符介绍可以看: 官方文档:操作符重载
现在我们的 DSL 已经定义好了,在每一步中,都创建一个构建器,并使用一个来自参数的函数(示例中的 init
)对其进行初始化,之后,构造器将包含 init
函数中指定的所有数据。这就是我们构造一个Class所需要的数据,因此,我们可以返回该构建器,也可以生成另一个保存该数据的对象,在本例中,我们将只返回 builder。 下面是 table
函数的定义方式:
fun table(init: TableBuilder.()->Unit): TableBuilder {
val tableBuilder = TableBuilder()
init.invoke(tableBuilder)
return tableBuilder
}
// 使用 apply 来简化函数:
fun table(init: TableBuilder.()->Unit) =
TableBuilder().apply(init)
类似的,我们可以在 DSL 的其他部分使用它来更简洁:
class TableBuilder {
var trs = listOf<TrBuilder>()
fun tr(init: TrBuilder.()->Unit) {
trs = trs + TrBuilder().apply(init)
}
}
class TrBuilder {
var tds = listOf<TdBuilder>()
fun td(init: TdBuilder.()->Unit) {
tds = tds + TdBuilder().apply(init)
}
}
这样,一个简单的 DSL HTML 构建器就创好啦~
4. Kotlin 中的 DSL API
除了自定义 DSL, Kotlin 其实本身还定义了很多 DSL Api,我们平时或多或少都会使用,来看看把~
4.1 语法糖with 、apply等
我们平时用的语法糖,例如 apply
、 with
,都用到了带接收者的函数类型,我们可以看下其实现:
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}
@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return receiver.block()
}
4.2 协程库
虽然 Kotlin 内置的底层协程库是没有任何风格的,但是我们平时一般都会引入 kotlin.coroutines
库,它为我们提供了一套标准的协程编写风格,这些 api 基本都是方便我们去写一些协程的,例如:
-
launch{ .. }
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
-
async { .. }
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T>
-
withContext
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T
我们在使用时可以通过这些 api 来构造/切换协程作用域,而非直接使用 Kotlin 内置提供的协程工具(如果你用那些,会觉得特别难用)。 这就跟我们使用 OkHttp
库和直接使用 HttpClient
/HttpUrlConnection
接口的区别一样。
4.3 compose
compose
是 Jetpack 提供的在 Android 上使用的声明式 UI 框架, 我们可以使用 Kotlin 直接写出一个 UI 界面,就像 XML 那样,甚至要比 XML 更加简洁:
// 构造一个 TextView
Text(
text = "Hello, Android!",
color = Color.Unspecified,
fontSize = TextUnit.Unspecified,
letterSpacing = TextUnit.Unspecified,
overflow = TextOverflow.Clip
)
// 构造一个竖直方向的容器,里面放三个 TextView
Column(
modifier = Modifier.padding(16.dp),
content = {
Text("Some text")
Text("Some more text")
Text("Last text")
}
)
4.4 Gradle DSL
Android Studio 是使用 Gradle 来编译项目的,传统的 Gradle 需要使用 groovy
语言,它是一个闭包DSL纯函数语言,有一定的学习成本。 而后来 Gradle 支持使用 Kotlin 语言来编写。只要你把构建脚本文件的后缀名从 .gradle
改成 .gradle.kts
就可以了,像 Android 中常用的几个脚本文件:
- settings.gradle
- project/build.gradle
- app/build.gradle
例如原来的 Plguin 插件导入是这样写的:
apply plugin : 'com.android.application'
apply plugin : 'kotlin-android'
apply plugin : 'kotlin-android-extensions'
换成 kts 后,则是这样写的:
plugins {
id("com.android.application")
kotlin("kotlin-android")
kotlin("kotlin-android-extensions")
}
在例如:
defaultConfig {
applicationId "com.liuguilin.kotlindsl"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
在 kts 中直接用 Kotlin 来写:
defaultConfig {
applicationId = "com.liuguilin.kotlindsl"
minSdkVersion(21)
targetSdkVersion(29)
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
相比于原来的 .gradle,换成 .kts 的好处是:
- 自动补全代码
- 代码引用可跳转
- 和项目语言一致,减少学习 groovy 的成本
4.5 其他
Kotlin 还有许多第三方库提供了 DSL,例如:
5. 总结
- DSL 是什么?
DSL 是一种针对特殊编程场景的语言或范式,它处理效率更高,且表达式更为专业。
例如 SQL、HTML、正则表达式等。 - Kotlin 如何支持 DSL
通过 扩展函数、带接收者的函数类型、 @DslMarker
注解等来支持使用 DSL。 - Kotlin 自定义 DSL 的优势
提供一套编程风格,可以简化构建一些复杂对象的代码,提高简洁程度的同时,具备很高的可读性。 - Kotlin 自定义 DSL 的缺点
构造代码较为复杂,有一定上手难度,非必要不使用。
参考
第35条:考虑为复杂的对象创建定义 DSL第15条:考虑显式引用接收者Gradle Kotlin DSL,你知道它吗?