首页 > 其他分享 >Kotlin DSL 学习

Kotlin DSL 学习

时间:2022-10-30 20:39:08浏览次数:85  
标签:函数 Int Kotlin 接收者 学习 DSL fun


之前在《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,你知道它吗?​


标签:函数,Int,Kotlin,接收者,学习,DSL,fun
From: https://blog.51cto.com/u_15719342/5807827

相关文章