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

文章目录

      • DSL的简单介绍
      • Kotlin中的DSL
      • Kotlin DSL的例子
      • 深入理解带接收者的lambda
      • 函数式的对象的invoke约定
      • 那些优秀的DSL开源库
      • 学习交流

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:

        
            
            

            

            

如果换成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) {
    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来看,实际上就是以下三点:

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

那些优秀的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的同学,也不妨立刻开始尝试。

学习交流

欢迎入群交流学习,Android、Java开发技术交流群

你可能感兴趣的:(Kotlin)