DSL(领域特定语言)是Kotlin所带来的强大语法特性之一,也是Java中所不存在的功能,JetBrain也基于DSL开发出了众多的开源库,Kotlin的开发者可以使用DSL来重构许多已有的代码,甚至有可能做到彻底抛弃HTML,XML,SQL等代码的地步。
现代编程语言已经越来越向自然语言靠拢,因此学习使用一个语法特性并非难事,所以本文将延续本专题的风格:“理论先行”,重点在于详细讲解DSL在Kotlin中的实现原理,以及如果我们使用DSL怎样去构建一套合理的API,而如何去使用已有的DSL库,这个根据不同的开发需求因人而异,本文会简单介绍,但不是重点;文章将会先解释到底什么是DSL,再来详细讲解DSL的两大原理——带接收者的lambda和invoke约定,最后简单介绍一些好用的基于DSL的开源库。
Part 1:到底什么是DSL
Part 2:深入理解带接收者的lambda
Part 3:函数式的对象——invoke约定
Part 4:那些优秀的DSL开源库
Part 1:到底什么是DSL
DSL的简单介绍
文章开头我们就已经提到,DSL是领域特定语言的英文缩写。那到底什么是领域特定语言?我们最常使用的领域特定语言就是SQL以及正则表达式,SQL和正则表达式都只能解决它们特定领域内的问题,SQL用于数据库操作,而正则表达式则是用来处理文本字符串,它们也都有自己的语法,但是你无法使用它们在计算机上编写完整的程序;所以它们并不是我们常规意义上理解的“编程语言”,那些有能力在计算机上编写几乎任何程序的编程语言,诸如,Kotlin,Java,Python等等我们有一个专业术语来定义它们,叫做图灵完备语言,而上面介绍的那些DSL就不是图灵完备的。
这里再多说一句,针对上面这个概念,还有一个比较好的例子来说明它们。了解区块链的朋友们也许知道,比特币内部有一种比特币脚本语言,它用来描述整个比特币的交易过程,它同样有顺序,分支等结构,但是没有循环;它除了用来做比特币交易之外什么都做不了,因此它是一种DSL,当然,它也不是图灵完备的。而区块链的另一大系统——以太坊则完全不同,它的内部也有一种用来描述以太坊内部运作的语言——Solidity,但是它有完整的编程语言结构,而以太坊内部执行Solidity的系统被称为EVM(以太坊虚拟机),我们甚至可以使用Solidity来编写一个国际象棋游戏并发布到以太坊区块链,因此Solidity是一种图灵完备的编程语言。我们通过以下两个简单的例子来直观的感受DSL和图灵完备语言在语法上的不同之处:
/**
* 比特币脚本语言
* 摘自《区块链:技术驱动金融》P74
*/
OP_DUP
OP_HASH160
69e02e18...
OP_EQUALVERIFY
OP_CHECKSIG
比特币脚本语言的语法规则非常简单,就像一行一行在输入命令。
/**
* 以太坊中的Solidity
* 摘自《区块链:技术驱动金融》P351
*/
contract NameRegistry {
mapping(bytes32 => address) public registryTable;
function claimName(byte32 name) {
if (msg.value < 10) {
throw;
}
if (registryTable[name] == 0) {
registryTable[name] = msg.sender;
}
}
}
我们可以看到Solidity的语法看起来就非常强大,各种常见的if语句,赋值运算等就不说了,还能定义且调用和执行强大的函数,这些常规编程语言中最常见的功能它都有;通过这两个直观的例子,我们很容易看出来DSL和图灵完备语言的不同之处。
Kotlin中的DSL
区块链那一块有点扯远了,我们回到Kotlin;我们如果要在我们编写的程序中使用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的例子
我们来举一个Kotlin DSL的例子,如果我们使用JetBrain构建Android UI的开源库Anko的话,我们可以用DSL重构一份XML代码;我们先来看看XML:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="30dp"
android:orientation="vertical" >
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="Name"
android:textSize="24sp" />
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="Password"
android:textSize="24sp" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Login"
android:textSize="26sp" />
</LinearLayout>
如果换成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。
Part 2:深入理解带接收者的lambda
对象调用其对应的类内部的方法,是所有有面向对象编程经验的开发者都知道的原则,但这里要讲清楚带接收者的lambda,还是要从这里讲起。我们先来看下面的例子:
class A {
fun function1() {
function2()
}
fun function2() {
// do something...
}
}
// 扩展函数
fun A.function3() {
function1()
function2()
}
fun main(args: Array<String>) {
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 <T : View> 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来看,实际上就是以下三点:
- 1.使用扩展函数来限制函数的调用范围
- 2.使用带接收者的lambda来保证API中的嵌套关系
- 3.使用inline修饰符,把这些有lambda表达式作为参数的函数声明成内联的来保证执行效率
我们这里再详细说一下第二点。
我们在编写HTML和XML的时候,其中一点非常重要,那就是嵌套关系;这些嵌套关系即保证了这些元素之间的包含和被包含的关系,又保证了HTML或XML的可读性;以使用XML来编写Android UI为例,如果不使用XML,而是直接编写Java代码的话,也是可行的,但是我们只能使用Java那种从上到下不停new出一个对象,然后用对象不停调用不同方法的办法来创建UI,当然也是可行的,但是这几乎可以说是让代码的可读性瞬间归零,这样编写代码即容易出错,后期也几乎不可维护。但是现在Kotlin有了带接收者的lambda,我们可以在保留嵌套关系的同时,使用Kotlin这样的图灵完备语言来编写我们需要的UI,这样就实现了Part 1中提到的内部DSL的全部优点。
Part 3:函数式的对象——invoke约定
Kotlin的约定有很多种,而比如使用便捷的get操作,以及重载运算符等等,invoke约定也仅仅是一种约定而已;我们可以把lambda表达式或者函数直接保存在一个变量中,然后就像执行函数一样直接执行这个变量,这样的变量通常声明的时候都被我们赋值了已经直接定义好的lambda,或者通过成员引用而获取到的函数;但是别忘了,在面向对象编程中,一个对象在通常情况下都有自己对应的类,那我们能不能定义一个类,然后通过构造方法来产生一个对象,然后直接执行它呢?这正是invoke约定发挥作用的地方。
class A(val str: String) {
operator fun invoke() {
println(str)
}
}
fun main(args: Array<String>) {
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<String>) {
val a = A("Hello")
println(a())
}
输出:Hello
Hello
直接让一个类继承自函数类型,这样invoke的函数类型就和继承的类型一致了,我们也可以像上面那样直接调用A类的对象,最终会执行invoke函数。
使用invoke约定可以构建出什么样的DSL API呢?在Anko中好像还没有发现这样的例子,但是在Gradle的构建脚本中这样的例子就比较常见:
/**
* invoke约定的例子
* 摘自《Kotlin实战》P313
*/
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做点什么呢?
Part 4:那些优秀的DSL开源库
下面介绍的Kotlin DSL开源库都是Kotlin的亲爹JetBrain开发的,这说明,就目前来看广大开发者应该还没有把DSL的潜力发挥到极致,如果您有其它优秀的的DSL库推荐,可以给文章留言。
数据库操作:Exposed
Exposed是JetBrain推出的,可以使用DSL代替SQL来操作数据库的开源库,项目地址如下:
动态构建Android UI:Anko
Anko也是JetBrain推出的,上文已经提到过了;它是一款便于Android开发者使用Kotlin进行Android开发的函数库,其中,使用DSL动态构建Android UI只是其中的一部分功能,这个库的Github地址如下:
我本人对Anko的实践较多,曾写过一篇讨论Anko的文章;
我使用Anko编写过一个完全使用DSL构建UI的App,如果您感兴趣,也可以参考它的源码,Github地址如下:
欢迎star和fork。
动态构建HTML布局:kotlinx.html
也是JetBrain官方推出的库,用来使用DSL来构建HTML布局,从它的包名中含有kotlinx就可以看出来,它的受重视程度高于Anko,基本上属于Kotlin官方develop kit中的一部分,它的Github地址如下:
除此之外,Gradle已经支持使用Kotlin DSL来编写构建脚本,使用Gradle的同学,也不妨立刻开始尝试。