Kotlin-简约之美-进阶篇(十六):DSL原理解析

@[toc]
DSL(领域特定语言)是Kotlin所带来的强大语法特性之一,也是Java中所不存在的功能,JetBrain也基于DSL开发出了众多的开源库,Kotlin的开发者可以使用DSL来重构许多已有的代码,甚至有可能做到彻底抛弃HTML,XML,SQL等代码的地步。
现代编程语言已经越来越向自然语言靠拢,因此学习使用一个语法特性并非难事,所以本文将延续本专题的风格:“理论先行”,重点在于详细讲解DSL在Kotlin中的实现原理,以及如果我们使用DSL怎样去构建一套合理的API,而如何去使用已有的DSL库,这个根据不同的开发需求因人而异,本文会简单介绍,但不是重点;文章将会先解释到底什么是DSL,再来详细讲解DSL的两大原理——带接收者的lambda和invoke约定,最后简单介绍一些好用的基于DSL的开源库。

DSL的简单介绍

文章开头我们就已经提到,DSL是领域特定语言的英文缩写。那到底什么是领域特定语言?我们最常使用的领域特定语言就是SQL以及正则表达式,SQL和正则表达式都只能解决它们特定领域内的问题,SQL用于数据库操作,而正则表达式则是用来处理文本字符串,它们也都有自己的语法,但是你无法使用它们在计算机上编写完整的程序;所以它们并不是我们常规意义上理解的“编程语言”,那些有能力在计算机上编写几乎任何程序的编程语言,诸如,Kotlin,Java,Python等等我们有一个专业术语来定义它们,叫做图灵完备语言,而上面介绍的那些DSL就不是图灵完备的。

Kotlin中的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的例子

我们来举一个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。

深入理解带接收者的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的全部优点。

函数式的对象的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的构建脚本中这样的例子就比较常见:

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做点什么呢?

那些优秀的DSL开源库

下面介绍的Kotlin DSL开源库都是Kotlin的亲爹JetBrain开发的,这说明,就目前来看广大开发者应该还没有把DSL的潜力发挥到极致,如果您有其它优秀的的DSL库推荐,可以给文章留言。

  • 数据库操作:Exposed
    Exposed是JetBrain推出的,可以使用DSL代替SQL来操作数据库的开源库,项目地址如下:Exposed
  • 动态构建Android UI:Anko
    Anko也是JetBrain推出的,上文已经提到过了;它是一款便于Android开发者使用Kotlin进行Android开发的函数库,其中,使用DSL动态构建Android UI只是其中的一部分功能,这个库的Github地址如下:Anko
  • 动态构建HTML布局:kotlinx.html
    也是JetBrain官方推出的库,用来使用DSL来构建HTML布局,从它的包名中含有kotlinx就可以看出来,它的受重视程度高于Anko,基本上属于Kotlin官方develop kit中的一部分,它的Github地址如下:
    kotlinx.html
    除此之外,Gradle已经支持使用Kotlin DSL来编写构建脚本,使用Gradle的同学,也不妨立刻开始尝试。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容