文章开头我们就已经提到,DSL是领域特定语言的英文缩写。那到底什么是领域特定语言?我们最常使用的领域特定语言就是SQL以及正则表达式,SQL和正则表达式都只能解决它们特定领域内的问题,SQL用于数据库操作,而正则表达式则是用来处理文本字符串,它们也都有自己的语法,但是你无法使用它们在计算机上编写完整的程序;所以它们并不是我们常规意义上理解的“编程语言”,那些有能力在计算机上编写几乎任何程序的编程语言,诸如,Kotlin,Java,Python等等我们有一个专业术语来定义它们,叫做图灵完备语言,而上面介绍的那些DSL就不是图灵完备的。
我们如果要在我们编写的程序中使用DSL,需要把它们保存到一个文本字符串内,然后再通过调用源编程语言的函数(方法),将它们作为参数传入进去,然后它们才能得到执行,不妨回忆一下我们是如何在Java中使用SQL和正则表达式的;这样的使用方式最大的缺点就是内嵌在源编程语言中的DSL,无法在编写过程中获得IDE的错误提示,也无法在编译期获得语法错误检查,哪怕只是输错一个字母,都将导致运行时的执行失败,因此我们原先使用DSL的方式是不安全的。
既然将DSL作为文本字符串直接内嵌在源编程语言的代码中是不安全的,那有没有可能使用某种方式,让它们和源编程语言在一定程度上挂钩,这样既方便使用,又能让它们得到IDE的错误提示和编译期的语法检查?这也是一些编程语言社区内讨论过很多的概念——内部DSL;如果你是inteliJ IDEA的用户,其实也早就使用过内部DSL了,当你在编写Gradle的规则文件的时候,使用的就是Groovy语言的内部DSL(当然,目前Gradle文件已经支持使用Kotlin DSL来编写。)
使用内部DSL编写出来的代码,和它们的源编程语言是一样的,比如说Kotlin代码文件的扩展名是.kt,而使用Kotlin DSL编写出来的代码的文件也是.kt,因为它本质上就是Kotlin代码,因此它也可以存在于任何.kt文件中和普通Kotlin代码混编;那普通Kotlin代码和Kotlin DSL之间到底有什么明显的界限?实际上并没有(《Kotlin in Action》的作者在书中说:判断的标准应该主观到:“当我看见它的时候,就知道它是一个DSL”。),DSL看起来特殊的语法其实是由Kotlin中的两种语法特性——带接收者的lambda和invoke约定,来提供支持,关于这两者,后文会有具体讨论。
我们来举一个Kotlin DSL的例子,如果我们使用JetBrain构建Android UI的开源库Anko的话,我们可以用DSL重构一份XML代码;我们先来看看XML:
如果换成DSL来编写如下:
lineatLayout {
orientation = LinearLayout.VERTICAL
padding = dip(30)
editText {
hint = "Name"
textSize = 24f
}.lparam(wrapContent, wrapContent)
editText {
hint = "Password"
textSize = 24f
}.lparam(wrapContent, wrapContent)
button("Login") {
textSize = 26f
}.lparam(wrapContent, wrapContent)
}
即使你不是Android开发者,也可以轻易的看出两者的异同,XML中的元素:LinearLayout, EditText,Button等的层级嵌套关系和DSL中的完全一至,属性的赋值也是应有尽有;这说明,如果你想把当前的一些编写起来不那么方便的代码,迁移到基于Kotlin DSL的库,大多数情况下其实学习成本并不高,实际上变化的只是一些简单的语法规则。
我们在开始下一节之前,先来看看这一段DSL代码,做一个简单的分析,并提出几个问题。我可以首先先告诉大家一个结论,linearLayout {},editText {},button {},这些东西全部都是Kotlin高阶函数,而orientation和padding这些都是Kotlin中的属性;学习过Kotlin的高阶函数的你应该知道,linearLayout {}大括号的内部实际上是一个lambda表达式,它作为一个参数,被传递给了函数linearLayout,而在这个lambda表达式的外部,你是无法引用到orientation和padding属性的,同理,在editText {}的lambda表达式的外部,也是无法引用到hint和textSize属性的。因为orientation是LinearLayout类的属性,而hint和textSize是EditText类的属性;这也就说明在这些lambda表达式的内部,持有了一个对这些类型对象的引用;而这样的lambda表达式就是带接收者的lambda。
对象调用其对应的类内部的方法,是所有有面向对象编程经验的开发者都知道的原则,但这里要讲清楚带接收者的lambda,还是要从这里讲起。我们先来看下面的例子:
class A {
fun function1() {
function2()
}
fun function2() {
// do something...
}
}
// 扩展函数
fun A.function3() {
function1()
function2()
}
fun main(args: Array) {
val a = A()
a.function1()
a.function2()
a.function3()
}
代码很基础,function1和function2都是A的成员函数,在function1中可以直接调用function2,即在同一个类的方法中可以直接调用另一个方法,而在A的外面,我们则需要创建一个A的对象来调用function1和function2;因为在A的内部,所有的成员(变量/函数)都持有一个A类型对象的引用,而在A的外部,在调用这些成员的时候,我们需要知道调用它的到底是哪一个对象,这是最基本的类和对象之间的关系,我就不再多说了。但在Kotlin中唯一的例外就是扩展函数,在扩展函数中调用其接收者的成员函数(或属性)可以直接调用,这是因为在A的外部调用它的扩展函数,需要一个A的对象。学过高阶函数和lambda编程后我们都知道,函数和lambda在很多时候可以认为是同一种东西,都可以把它们看作是一种有类型的(类型由参数类型,数量,顺序以及返回值类型来确定)可被执行,且可以被保存在一个变量中的代码段;所以带接收者的lambda在某些时候可以认为和扩展函数是等价的(注意,只是某些时候,因为lambda和函数在被编译成.class字节码以后是不同的,这是另一个话题,这里不再展开了),假如我们要定义一个A类型作为接收者类型且一个Int类型作为参数,无返回值的带接收者的lambda,就可以像如下这样定义:
val receiver: A.(Int) -> Until = {
// do something...
}
如果我们要调用执行这个lambda:
val a = A()
a.receiver(3)
所以Part 1中介绍的那些诸如linearLayout {},editText {},button {}这些函数,都是以一个带接收者的lambda作为参数的普通内联函数,让我们以editText {}为例来看看它是如何定义的:
inline fun ViewManager.editText(init: (@AnkoViewDslMarker android.widget.EditText).() -> Unit): android.widget.EditText {
return ankoView(`$$Anko$Factories$Sdk25View`.EDIT_TEXT, theme = 0) { init() }
}
inline fun ViewManager.ankoView(factory: (ctx: Context) -> T, theme: Int, init: T.() -> Unit): T {
val ctx = AnkoInternals.wrapContextIfNeeded(AnkoInternals.getContext(this), theme)
val view = factory(ctx)
view.init()
AnkoInternals.addView(this, view)
return view
}
看起来有点复杂,启示拆开来看其实很简单,首先这是一个扩展函数,接收者是ViewManager,这样就限制了这个函数的调用范围,即只能在某个父布局中被调用,随后我们看到参数init就是一个标准的带接收者的lambda,而init在函数内部调用ankoView函数的时候又会在它的lambda参数中被调用,ankoView函数用来生成一个EditText对象,至于内部的原理,我们不去分析,而editText函数又会将这个EditText对象返回,便于函数的调用者获取这个对象的引用;最后我们看到,整个函数加了inline修饰符,即被声明成内联的,这样就保证了DSL API的执行效率,而执行init这个带接收者lambda的ankoView实际上也是ViewManager的扩展函数,而且它也是内联的,这里不再做过多的源码深入。我们简单的体验了一下如何声明一个DSL API,从Anko来看,实际上就是以下三点:
Kotlin的约定有很多种,而比如使用便捷的get操作,以及重载运算符等等,invoke约定也仅仅是一种约定而已;我们可以把lambda表达式或者函数直接保存在一个变量中,然后就像执行函数一样直接执行这个变量,这样的变量通常声明的时候都被我们赋值了已经直接定义好的lambda,或者通过成员引用而获取到的函数;但是别忘了,在面向对象编程中,一个对象在通常情况下都有自己对应的类,那我们能不能定义一个类,然后通过构造方法来产生一个对象,然后直接执行它呢?这正是invoke约定发挥作用的地方。
class A(val str: String) {
operator fun invoke() {
println(str)
}
}
fun main(args: Array) {
val a = A("Hello")
a()
}
输出:Hello
我们只需要在一个类中使用operator来修饰invoke函数,这样的类的对象就可以直接像一个保存lambda表达式的变量一样直接调用,而调用后执行的函数就是invoke函数。
我们还有另一种方式来实现可调用的对象,即让类继承自函数类型,然后重写invoke方法:
class A : (String) -> String {
override fun invoke(str: String): String {
println(str)
return str
}
}
fun main(args: Array) {
val a = A("Hello")
println(a())
}
输出:Hello
Hello
直接让一个类继承自函数类型,这样invoke的函数类型就和继承的类型一致了,我们也可以像上面那样直接调用A类的对象,最终会执行invoke函数。
使用invoke约定可以构建出什么样的DSL API呢?在Anko中好像还没有发现这样的例子,但是在Gradle的构建脚本中这样的例子就比较常见:
dependencies.compile("junit:junit:4.11")
dependiences {
compile("junit:junit:4.11")
}
dependiences实际上就是一个对象,它既可以直接调用compile方法,又能在它的lambda表达式参数内调用compile,可见dependiences也是一个使用了invoke约定的类的对象,而它接收的是一个带接收者的lambda表达式作为函数参数。
带接收者的lambda和invoke约定是支撑Kotlin DSL的两大语法特性,但实际上在Kotlin中众多的语法糖中,还有许多特性为你设计DSL的优雅语法提供了可能,这其中包括了:中辍调用,运算符重载,括号外的lambda等等等等;我们不妨充分发散自己的思维,让我们使用这些众多的优雅语法构建一个属于自己的DSL库,用来解决编程中某一类特定领域的棘手问题;Json数据格式也是一个讲究嵌套的数据格式,我们能否充分发挥我们的想象来编写一个基于DSL的库,来对Json做点什么呢?
下面介绍的Kotlin DSL开源库都是Kotlin的亲爹JetBrain开发的,这说明,就目前来看广大开发者应该还没有把DSL的潜力发挥到极致,如果您有其它优秀的的DSL库推荐,可以给文章留言。
欢迎入群交流学习,Android、Java开发技术交流群