Kotlin DSL: 基础知识

Kotlin极简教程Kotlin编程社区2018-12-18 15:02:20

A domain-specific language (DSL) is a computer language specialized to a particular application domain. This is in contrast to a general-purpose language (GPL), which is broadly applicable across domains. There is a wide variety of DSLs, ranging from widely used languages for common domains, such as HTML for web pages, down to languages used by only one or a few pieces of software.

域特定语言(DSL)是专用于特定应用程序域的计算机语言。这与通用语言(GPL)形成对比,通用语言(GPL)广泛适用于各个领域。存在各种各样的DSL,范围广泛用于通用域的语言,例如用于网页的HTML,以及仅由一个或几个软件使用的语言。

Kotlin DSL

Kotlin provides first-class support for DSLs, which allows us to express domain-specific operations much more concisely than an equivalent piece of code in a general-purpose language.


Let’s try and build a simple DSL in Kotlin:

Kotlin为DSL提供了一流的支持,这使我们能够比通用语言中的同等代码更简洁地表达特定于域的操作。

让我们尝试在Kotlin中构建一个简单的DSL:


dependencies {
   compile("io.arrow-kt:arrow-data:0.7.1")
   compile("io.arrow-kt:arrow-instances-core:0.7.1")
   testCompile("io.kotlintest:kotlintest-runner-junit5:3.1.0")
}


This should be familiar to people using Gradle as their build tool. The above DSL specifies compile and testCompile dependencies for a Gradle project in a very concise and expressive form.


对于使用Gradle作为构建工具的人来说,这应该是熟悉的上面的DSL以非常简洁和富有表现力的形式为Gradle项目指定了compile和testCompile依赖项。


How Does Kotlin Support DSLs?

Before we get into Kotlin’s support for DSLs, let’s look at lambdas in Kotlin.

fun buildString(action: (StringBuilder) -> Unit): String {
   val sb = StringBuilder()
   action(sb)
   return sb.toString()
}


buildString() takes a lambda as a parameter (called action) and invokes it by passing an instance of StringBuilder. Any client code that invokes buildString() will look like the following code:

val str = buildString {
    it.append("Hello")
    it.append(" ")
    it.append("World")
}


A few things to note here:

  • buildString() takes the lambda as the last parameter. If a function takes a lambda as the last parameter, Kotlin allows you to invoke the function using braces { .. }, there is no need to use parentheses

  • it is the implicit parameter available in the lambda body, which is an instance of StringBuilder in this example

The information is good enough to write a Gradle dependencies DSL.

这里有几点需要注意:

  • buildString()将  lambda作为最后一个参数如果函数将lambda作为最后一个参数,Kotlin允许您使用大括号{..}调用该函数,不需要使用括号

  • 是lambda体中可用的隐式参数,在本例中是StringBuilder的一个实例

这些信息足以编写  Gradle依赖DSL。



First Attempt at a DSL

In order to build a Gradle dependencies DSL we need a function called dependencies, which should take a lambda of type T as a parameter, where T provides the compile and testCompile functions.

Let’s try:

fun dependencies(action: (DependencyHandler) -> Unit): DependencyHandler {
    val dependencies = DependencyHandler()
    action(dependencies)
    return dependencies
}
class DependencyHandler {
    fun compile(coordinate: String){
        //add coordinate to some collection
    }
    fun testCompile(coordinate: String){
        //add coordinate to some collection
    }
}


dependencies is a simple function which takes a lambda accepting an instance of DependencyHandleras a parameter and returning Unit. DependencyHandler is the type T that has the compile and testCompile functions.

The client code for the above concept will look like:

dependencies {
    it.compile("") //it is an instance of DependencyHandler
    it.testCompile("")
}


Are we done? Not really.

The problem is the implicit parameter it used in the client code. Can we remove it?

In order to remove implicit parameters, we need to look at another concept known as a “Lambda With Receiver.”

Lambda With Receiver

A receiver in Kotlin is a simple type that is extended. Let’s see this with an example:

fun String.lastChar() : Char = 
                  this.toCharArray().get(this.length - 1)


We have extended String to have lastChar() as a function, which means we can always invoke it as:

"Kotlin".lastChar()


Here, String is the receiver type and this used in the body of lastChar() is the receiver object. These two concepts can be combined to form a Lambda With Receiver.

Let’s rewrite our buildString function using a lambda with receiver:

fun buildString(action: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.action()
    return sb.toString()
}


  • buildString() takes a lambda with receiver as a parameter.

  • StringBuilder is the receiver type in the lambda (action parameter).

  • The way we invoke the action function is different this time. Because action is an extension function of StringBuilder we invoke it using sb.action(), where sb is an instance of StringBuilder.

Let’s create a client of the buildString function:

val str = buildString {
    this.append("Hello") //this here is an instance of StringBuilder
    append(" ")
    append("World")
}


Isn’t this brilliant? Client code will always have access to this while invoking a function that takes a lambda with receiver as a parameter.

Shall we rewrite our Gradle dependencies DSL code?

Another Attempt at a DSL

fun dependencies(action: DependencyHandler.() -> Unit): DependencyHandler {
    val dependencies = DependencyHandler()
    dependencies.action()
    return dependencies
}
class DependencyHandler {
    fun compile(coordinate: String){
        //add coordinate to some collection
    }
    fun testCompile(coordinate: String){
        //add coordinate to some collection
    }
}


The only change we have made here is in the dependencies function, which takes a lambda with receiver as the parameter. DependencyHandler is the receiver type in the action parameter, which means the client code invoking the dependencies function will always have access to the instance of DependencyHandler.

Let’s see the client code:

dependencies {
   compile("")     //same as this.compile("")
   testCompile("")
}


We are able to create a DSL using a lambda with receiver as a parameter to a function.

Operator Function invoke()

Kotlin provides an interesting function called invoke, which is an operator function. Specifying an invoke operator on a class allows it to be called on any instances of the class without a method name.

Kotlin提供了一个名为invoke的有趣函数,它是一个运算符函数。在类上指定调用操作符允许在没有方法名称的类的任何实例上调用它

Let’s see this in action:

class Greeter(val greeting: String) {
    operator fun invoke(name: String) {
        println("$greeting $name")
    }
}
fun main(args: Array<String>) {
    val greeter = Greeter(greeting = "Welcome")
    greeter(name = "Kotlin")
    //this calls the invoke function which takes String as a parameter
}


A few things to note about invoke() here. It:

  • Is an operator function.

  • Can take parameters.

  • Can be overloaded.

  • Is being called on the instance of a Greeter class without method name.

Let’s use invoke in building a DSL.

Building DSLs Using Invoke Functions

class DependencyHandler {
    fun compile(coordinate: String){
        //add coordinate to some collection
    }
    fun testCompile(coordinate: String){
        //add coordinate to some collection
    }
    operator fun invoke(action: DependencyHandler.() -> Unit): DependencyHandler {
        this.action()
        return this
    }
}


We have defined an operator function in DependencyHandler, which takes a lambda with receiver as a parameter. This means invoke will automatically be called on instances of DependencyHandler and client code will have access to the instance of DependencyHandler.

Let’s write the client code:

val dependencies = DependencyHandler()
dependencies { //as good as dependencies.invoke(..)
    compile("")
    testCompile("")
}


invoke() can come in handy while building DSLs.

Conclusion

  • Kotlin provides first-class, typesafe support for DSLs.

  • One can create a DSL in Kotlin using:

    • Lambdas as function parameters.

    • A lambda with receiver as a function parameter.

    • An operator function invoked along with a lambda with receiver as a function parameter.


  • Kotlin为DSL提供一流的,类型安全的支持。

  • 可以使用以下方法在Kotlin中创建DSL:

    • Lambdas作为函数参数。

    • 带接收器作为函数参数的lambda。

    • 调用运算符函数以及带接收器作为函数参数的lambda。



References

  • Kotlin In Action



Exploring Kotlin's DSL

Here we see how to use Kotlin's DSL to represent domain operations. We use JSON in this example, expressing it in Kotlin and outputting the result to JSON.

Kotlin is a statically typed JVM language that provides excellent support for a DSL. Since that DSL blueprint is well-embedded within Kotlin, we can express a domain-specific operation much more concisely than an equivalent piece of code in a general-purpose language.

Let's try and understand this with an example. Here, what we want to do is create a DSL to express JSON in Kotlin and output the result to actual JSON. So, we should be able to represent the following JSON in our code:

{
    "language": "Kotlin",
    "description" : "Statically typed JVM language"
    "version" : "1.1.4"
}


In order to express the same in Kotlin's DSL, the first thing that we may need to consider is how should we model this in code. Since we are trying to represent JSON, we could think of representing it with a bunch of classes, including Json and Obj.

class Json {
    private lateinit var jsonObject: Obj
}
class Obj {
    private val entries = linkedMapOf<String, Any>() //representing key/value pair
}


For simplicity, we shall assume that we can have a single object within the Json class. With this done, we can think of creating a DSL to represent JSON.

We could think of representing JSON in the form of the following DSL:

json {
    obj {
        "language" to "kotlin"
        "description" to "Statically typed JVM language"
        "version" to "1.1.4"
    }
}


Here, json, obj, and to are functions, of which to appears to be the simplest. to is a simple extension function, which is defined via the String class.

fun String.to(value: Any): Unit {
    //code ommitted
}


The next point to be understood is where the to function should be defined. Looking at the above DSL, the to function is invoked within the context of obj, meaning an instance of Obj should be available to invoke the to functionLet's define the obj function:

infix fun obj(init: Obj.() -> Unit): Obj {
    val obj    = Obj()
    obj.init()
    jsonObject = obj
    return obj
}


This function takes one parameter named init, which is also a function. The type of the function is Obj.() -> Unit, which is a function type with a receiver. This means that this function can be invoked on an instance of Obj (receiver) and we can call/access members of that instance inside the function.

With the above reasoning, we can define a json function as:

fun json(init: Json.() -> Unit): Json {
    val json = Json()
    json.init()
    return json
}


So, what does this function do? It creates a new instance of Json, then initializes it by calling the function that is passed as an argument (in our example, this boils down to calling obj on the Json instance), and then it returns the newly created Json instance. 

Now, with the definition of json and obj, we can write the complete code our problem statement:

fun json(init: Json.() -> Unit): Json {
    val json = Json()
    json.init()
    return json
}
class Json {
    private lateinit var jsonObject: Obj
  infix fun obj(init: Obj.() -> Unit): Obj {
        val obj    = Obj()
        obj.init()
        jsonObject = obj
        return obj
    }
    fun render(): String = jsonObject.toJsonString()
}
class Obj {
    private val entries = linkedMapOf<String, Any>()
    infix fun String.to(value: Any): Unit {
        entries.put(this, value)
    }
  fun toJsonString(indent: String = " "): String {
      val sb = StringBuilder().append("{").append("\n")
        for ( (k, v) in entries ){
          with(sb) {
            append("""$indent"$k" : """)
            when(v){
              is String    -> append(""""$v"""")
              is Array<*>  -> append("[${v.joinToString()}]")
              else         -> append("$v")                                   
            }
            append("\n")
          }
      }
      return sb.append("$indent}").toString()
    }
}


Using the code behind the DSL is pretty straightforward:

val expectedJson = """
    {
                       "language" : "Kotlin"
                       "description" : "Statically typed JVM language"
                       "version" : "1.1.4"
                    }
   """
val json = json {
              obj {
                "language" to "Kotlin"
                "description" to "Statically typed JVM language"
                "version" to "1.1.4"
              }
          }
json.render() shouldBe expectedJson


This is a basic implementation of a DSL in Kotlin to represent JSON. It looks concise and expressive enough to represent domain operations.

Implementation with support for nested JSON structures can be found here.






Copyright © 温县电话机虚拟社区@2017