最近在看Kotlin,看到DSL部分觉得比较有意思,记录一下自己的理解

最简单的DSL就是各种大括号嵌套,每一个括号提供了一个独立的上下文,在该括号内使用这个上下文提供的api来影响上下文或者产生一些副作用。

Kotlin 天生就对DSL写法有比较好的支持,典型的scoping function: run、with、let、also、apply 就让大括号嵌套在Kotlin中无所不在

编写本文是因为看到了一段几十行的实现DSL的代码,觉得实现很简单,用法很炫酷,值得记录。代码链接在此 https://github.com/nazmulidris/color-console/blob/4d4503e183ab18434a9c86afd5ebdc66744c3451/src/main/kotlin/color_console_log/ColorConsoleLogUtils.kt#L91-L125

最开始错误的认知

不得不开始说一下我最开始的错误认知

1
2
3
A {
xxx
}

由于从其他语言的常规角度来说,读代码一般会有代码从上往下执行的思维定势,而且一开始看run, with, let, map, filter这些的时候会默认为以上代码是A执行完成之后得到中间结果,再将中间结果传递给xxx处理, 就是一个很直观的从上到下的执行过程。

其实这个地方是完全错误的理解,正确的理解是xxx代码块是整个A函数的一部分,即xxx在A函数内部执行,并不是A执行完成之后再执行xxx。心里有这个想法十分重要,这是DSL得以实现的基石

其实这种套路刚好和python的装饰器@ 相反,装饰器是在函数的开始和结束之后执行,而kotlin的这个语法糖是在函数中间执行

另外一个和以往经验不同的是,其他语言所说的lambda基本是表示单个表达式,而kotlin中的lambda却表示多个表达式组合而成的代码块

先看看用法

看看那个几十行代码实现的DSL的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
colorConsole {//this: ColorConsoleContext
printLine {//this: MutableList<String>
span(Purple, "word1")
span("word2")
span(Blue, "word3")
}
printLine {//this: MutableList<String>
span(Green, "word1")
span(Purple, "word2")
}
println(
line {//this: MutableList<String>
add(Green("word1"))
add(Blue("word2"))
})
}

可以看到主要特色就是有很多的大括号,而且注释也说明了,在每个大括号里面提供了什么样的上下文

最开始作为一个使用者,我们应该精确的理解DSL如何应用,毕竟并不是任意的嵌套都是有效的,其实防止误用很简单

  1. 每一个方法都提供了一个上下文,在这个上下文中应用上下文提供的方法和值。比如printLine 提供了 MutableList<String>上下文。这个上下文提供了span,add等方法,我们应该使用它提供的方法对上下文造成影响,或者对环境造成副作用(比如打印,发送网络请求等)
  2. 第一步提供了上下文,然后你在{}内部对上下文造成了影响,最后就是处理上下文。比如printLine的处理就是打印输出上下文

因此,会知道以下嵌套虽然语法不报错,但是会和你预料的不同

1
2
3
4
5
6
7
8
9
10
colorConsole {//this: ColorConsoleContext
printLine {//this: MutableList<String>
span(Green, "word1")
span(Purple, "word2")
line {//this: MutableList<String>
add(Green("word1"))
add(Blue("word2"))
}
}
}

因为line不是MutableList<String> 提供的方法,没有影响printLine提供的上下文,虽然line产生了一个值,但是并没有输出处理

实现

应该也有复杂的,但是这个示例里面的实现是相当简单

首先单独看printLine, 从使用倒推实现,即它是一个方法,最后一个参数是MutableList<String>.() -> Unit. 在方法的前半部分生成一个``MutableList`对象。后半部分对该对象进行汇总处理

1
2
3
4
5
6
7
8
9
10
fun line(spanSeparator: String = ", ",
prefixWithTimestamp: Boolean = true,
block: MutableList<String>.() -> Unit
): String {
val messageFragments = mutableListOf<String>()
block(messageFragments)
val timeString = SimpleDateFormat("hh:mm:sa").format(Date())
val prefix = if (prefixWithTimestamp) "[$timeString] " else ""
return messageFragments.joinToString(spanSeparator, prefix)
}

后面提供了一些MutableList<String>的方法,以供在上下文中处理

1
2
3
4
fun MutableList<String>.span(color: Colors, text: String): MutableList<String> {
add(color.ansiCode + text + Colors.ANSI_RESET.ansiCode)
return this
}

入口处提供了一个静态方法配合apply,这样在整个上下文中就能使用printLineline

1
2
3
4
5
companion object {
fun colorConsole(block: ColorConsoleContext.() -> Unit) {
ColorConsoleContext().apply(block)
}
}

总结

为什么Kotlin能这样写DSL?

因为kotlin中如果方法的最后一个参数是函数,且该函数只有一个入参,调用的时候允许将最后一个参数放到后面,使用花括号

因为kotlin中lambda是代码块,而不只是单条表达式

以上两条造成的结果就是Kotlin 这样写没有什么违和感,其他语言不行