之前在《Effective Kotlin》 一书中,有一条专门讲解 DSL 的:考虑为复杂的对象创建定义 DSL,让我对 DSL 有了一定的了解。
为了能够更熟悉掌握Kotlin上的DSL,用这篇 Blog 来学习记录下。
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。
对于应用层开发来说,我们会在一些时候使用编程语言内嵌的 DSL(如上面说的那些),这就已经满足日常开发的需求了。DSL 更深层的东西,反而是一些有关设计层、语言底层相关人士会去关注的,那为什么 Kotlin 这类语言会给应用层提供自定义 DSL 的能力呢?
这是因为 自定义 DSL 对应用层来说,具备一个也是唯一一个的优势:它能消除创建对象时的样板代码。良好的自定义 DSL,可以让代码更加简洁,这是符合 Kotlin 这类语言的特性的(简洁务实)。在下面几个对象创建场景中,是非常适合使用自定义 DSL 的:
除此之外的场景,使用 DSL 就会显得冗余,而且自定义 DSL 的过程比较复杂,实现成本较高,所以非必要不使用!
Kotlin 内置了 Gradle DSL,可以让我们用 DSL 风格去写 Gradle 脚本,这样做的好处是:我们不用去学习 Groovy
才能写 Gradle 脚本,而是可以直接用 Kotlin 来写,本篇最后也会简单的介绍一下用 Gradle DSL 在 Kotlin 上写脚本。
想要在 Kotlin 上使用 DSL,首先需要掌握 Kotlin 的几个特性:
@DslMarker
注解 (Optional)扩展函数,也就是元编程,方便我们在对象定义的范围之外,为它增添其它函数、属性,这是 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)
带接收者的函数类型,从定义上看较难理解,例如:fun Int.(other: Int) { ... }
咱们先从最基本的开始讲,也就是函数类型。要创建函数类型的实例,有下面几种方式:
例如下面函数:
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 }
它有三种直接使用方法:
// 使用 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
了,相当于我们可以不用 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
}
既然使用起来和扩展函数差不多,那带接收者的函数类型本身的意义是什么呢??
答案是:它改变了 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 " +
" ${this@Node.name}")
}
现在(在DSL的场景):它则是为了刻意隐藏接收者来使代码简洁。如下所示:
table {
tr { // 这里就不用 [email protected]() 了
td { +"Column 1" } // 这里就不用 [email protected]()
td { +"Column 2" }
}
tr {
td { +"Value 1" }
td { +"Value 2" }
}
}
@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" }
this@table.tr {
td { +"Value 1" }
td { +"Value 2" }
}
}
}
定义一个 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>
从 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 构建器就创好啦~
除了自定义 DSL, Kotlin 其实本身还定义了很多 DSL Api,我们平时或多或少都会使用,来看看把~
我们平时用的语法糖,例如 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()
}
虽然 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
接口的区别一样。
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")
}
)
Android Studio 是使用 Gradle 来编译项目的,传统的 Gradle 需要使用 groovy
语言,它是一个闭包DSL纯函数语言,有一定的学习成本。 而后来 Gradle 支持使用 Kotlin 语言来编写。只要你把构建脚本文件的后缀名从 .gradle
改成 .gradle.kts
就可以了,像 Android 中常用的几个脚本文件:
例如原来的 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 的好处是:
Kotlin 还有许多第三方库提供了 DSL,例如:
@DslMarker
注解等来支持使用 DSL。第35条:考虑为复杂的对象创建定义 DSL
第15条:考虑显式引用接收者
Gradle Kotlin DSL,你知道它吗?