Domain Specific Language in Kotlin

From bibbleWiki
Jump to navigation Jump to search

Introduction

Approaches to Extending Code

There may three options for extending a feature

  • Change the Object Model

This has imperative code and therefore you will be forced to abstract extend, abstract extend. You be able to build quickly but it does not scale.

  • External DSL (e.g. JSON)

E.g. Use json to describe the new features. You will need to extend the parser and write the code. Build will be slow but the scalability will be great

  • DSL in Kotlin

Because it is just kotlin building will be quicker and it will scale hmmmmmm

Attributes of DSL Code

  • Language Nature Code is meaningful and has a fluid nature
  • Domain Focus DSL is focus one problem only
  • Limited Expressiveness Supports only what it needs to to accomplished its task


Imperative vs Declarative

val castle = Castle()
val towerNE = Tower()
val towerSE = Tower()
val towerNW = Tower()
val towerSW = Tower()
val keep = Keep()

keep.connectTo(towerNE)
keep.connectTo(towerSE)
keep.connectTo(towerNW)
keep.connectTo(towerSW)

DSL Restricts the syntax to allow better IDE support and keep focus

castle {
   keep {
          to("sw")
          to("nw")
          to("se")
          to("nw")
        }
}

val castle = Castle()
val towerNE = Tower()
val towerSE = Tower()

Kotlin Language Features

Lambda with Receiver Invoke

A lambda with a receiver allows you to call methods of an object in the body of a lambda without any qualifiers. It is similar to the typed extension function but this time, for function types. The idea is similar to object initializers in C# but is extended to functions and in a declarative way.

We pass an object (StringBuilder) with an attibute (String) and a function to use with the two.

fun encloseInXMLAttribute(
  sb : StringBuilder, 
  attr : String, action : 
  (StringBuilder) -> Unit) : String {
    sb.append("<$attr>")
    action(sb)
    sb.append("</$attr>")
    return sb.toString()
}

// When a lambda expression is at the end of the parameter list, you can take it out of the parentheses during invocation.
val xml = encloseInXMLAttribute(StringBuilder(), "attr") {
    it.append("MyAttribute")
}

print(xml)

Operator Overloading

Simple Overloading

Overloading Operators is supported in Kotlin

Expression Function Name
a*b times
a/b div
a%b mod
a+b plus
a-b minus

Example

data class Point(val x: Int, val y: Int)

operator fun Point.plus(other: Point) = Point(x + other.x, y + other.y)

val p1 = Point(0, 1)
val p2 = Point(1, 2)

println(p1 + p2)
Point(x=1, y=3)

Unary Overloading Example 1 (plus)

We can overload unary operators too. For example

val s = shape { 
    +Point(0, 0)
    +Point(1, 1)
    +Point(2, 2)
    +Point(3, 4)
}

In Kotlin, that’s perfectly possible with the unaryPlus operator function.

Since a Shape is just a collection of Points, then we can write a class, wrapping a few Points with the ability to add more:

class Shape {
    private val points = mutableListOf<Point>()

    operator fun Point.unaryPlus() {
        points.add(this)
    }
}

And note that what gave us the shape {…} syntax was to use a Lambda with Receivers

fun shape(init: Shape.() -> Unit): Shape {
    val shape = Shape()
    shape.init()

    return shape
}

Unary Overloading Example 2 (Inc)

This allow you to defined how ++ works

operator fun Point.inc() = Point(x + 1, y + 1)

var p = Point(4, 2)
println(p++)
println(p)
Point(x=4, y=2)
Point(x=5, y=3)

Property Override

In kotlin we can override properties like methods

open class Employee {
    // Use "open" modifier to allow child classes to override this property
    open val baseSalary: Double = 30000.0
}

class Programmer : Employee() {
    // Use "override" modifier to override the property of base class
    override val baseSalary: Double = 50000.0
}

Extension Functions

Existing Extension Methods

There are many extensions already in the language for Kotlin. In this case languages is the receiver as it thing apply acts on An example of apply is shown below.

  val languages = mutableListOf<String>()
  languages.apply { 
      add("Java")
      add("Kotlin")
      add("Groovy")
    add("Python")
  }.apply {
      remove("Python")
 }

This could have been written

  val languages = mutableListOf<String>()
  languages.add("Java");
  languages.add("Kotlin");
  languages.add("Groovy");
  languages.add("Python");
  languages.remove("Python");

Writing Your Own Extension

We just need to specify the receiver class followed by a period. Here is an extension to String

fun String.escapeForXml() : String {
  return this
    .replace("&", "&amp;")
    .replace("<", "&lt;")
    .replace(">", "&gt;")
}

Using Generics with Extensions

We can use generics to write extensions and the compiler will help.

fun <T> T.concatAsString(b: T) : String {
    return this.toString() + b.toString()
}

5.concatAsString(10) // compiles
"5".concatAsString("10") // compiles
5.concatAsString("10") // doesn't compile

Infix Notation

With infix we a provide a more readable experience where the periods and bracket of an extension method can be omitted.

infix fun Number.toPowerOf(exponent: Number): Double {
    return Math.pow(this.toDouble(), exponent.toDouble())
}

// We can now call this the same as any other infix method

3 toPowerOf 2 // 9
9 toPowerOf 0.5 // 3

A more complicated example

infix fun String.substringMatches(r: Regex) : List<String> {
    return r.findAll(this)
      .map { it.value }
      .toList()
}

val matches = "a bc def" substringMatches ".*? ".toRegex()
Assert.assertEquals(listOf("a ", "bc "), matches)

Implementation Technics

Function Sequencing

This is when you write the sequence of the function calls.

builder.thing("My thing")

builder.subthing("Sub thing 1")
builder.subthing("Sub thing 2")
builder.subthing("Sub thing 3")
builder.subthing("Sub thing 4")

We can use apply to improve the but apply is not domain specific

builder.apply {
  thing("My thing")
  subthing("Sub thing 1")
  subthing("Sub thing 2")
  subthing("Sub thing 3")
  subthing("Sub thing 4")
}

Function Chaining

This is when an object returns the next object

builder.thing("My thing")
  .subthing("Sub thing 1")
  .subthing("Sub thing 2")
  .subthing("Sub thing 3")
  .subthing("Sub thing 4")

Symbol Table

Nested Builder

Within the DSL we do not want to build illegal chains of commands. To help with this one approach is to implement different types when returning to the compiler will constraint the user. E.g.

package dsl.castlebuilder.buildercontext

fun main(args : Array<String>) {
    println ("start")
    TempDsl().runDsl()
}

class TempDsl {

    fun runDsl() {
        val temp = TempBuilder().current()
                .toF().addF(10.0f)
                .toC().addC(10.0f)
                .toF().addF(10.0f).build()

        println("final temp ${temp.current}")

        TempBuilder().current().toF().addF(10.0f)
        TempBuilder().current().toC().addC(10.0f)

        TempBuilder().current().toF().addF(10.0f).toC().addC(10.0f)

        println("final temp ${temp.current}")
    }
}

// model
class Temp(var current: Float = 10.0f) {
    fun add(amount: Float) {
        current += amount
        println("temp is now: $current")
    }
    fun convertToC() {
        current = (current - 32) * .5556f
    }
    fun convertToF() {
        current = (current * .5556f) + 32
    }
}

// builders

open class TempBuilder(var temp: Temp = Temp()) {
    /**
     * set the current degrees as F
     */
    fun current() : TempBuilderImmutable {
        temp.current = 20.0f
        println("current temp is now: ${temp.current}")
        return TempBuilderImmutable(temp)
    }
    fun toF() : TempBuilderFahrenheit {
        temp.convertToF()
        return TempBuilderFahrenheit(temp)
    }
    fun toC() : TempBuilderCelsius {
        temp.convertToC()
        return TempBuilderCelsius(temp)
    }
    fun build() : Temp {
        return temp
    }
}

class TempBuilderImmutable(temp: Temp) : TempBuilder(temp)


class TempBuilderFahrenheit(temp: Temp) : TempBuilder(temp) {
    fun addF(amount: Float) : TempBuilderFahrenheit {
        temp.add(amount)
        return this
    }
}

class TempBuilderCelsius(temp: Temp) : TempBuilder(temp) {
    fun addC(amount: Float) : TempBuilderCelsius {
        temp.add(amount)
        return this
    }
}

Context Variables

Example 1

The is were we keep state within the DSL to reduce the code required.

// Without
val paint = Paint()
paint(10,10)
paint.lineFromTo(10,10,10,20)
paint.lineFromTo(10,20,20,10)
paint.lineFromTo(20,20,10,20)

// With
val paint = Paint()
paint.lineFromTo(10,100)
paint.lineFromTo(10,20)
paint.lineFromTo(20,20)
paint.lineFromTo(10,20)

Partial Functions

Partial application is the act of taking a function which takes multiple arguments, and “locking in” some of those arguments, producing a function which takes fewer arguments. For instance, say we have this function in javaScript.

function power(exponent: number, base: number) : number {
  let result = 1;
  for (let i = 0; i < exponent; i++) {
    result = result * base;
  }
  return result;
}

We can use this to raise numbers to some power, like doing power(2, 3) to see that 3 squared is 9, or power(3, 2) to see that 2 cubed is 8. It would be more readable to just say square(3) or cube(2), though. To do this, we want to produce a function that’s the result of “locking-in” 2 and 3 to power.

We can do this by manually declaring new functions:

const square = x => power(2, x), cube = x => power(3, x);
console.log(square(2));
console.log(cube(2));
// Gives
// 4
// 8

This is partial application: we create square by making a new function, which just passes its arguments through to another function, adding some hard-coded arguments to that other function. We could be more explicit in our intent by making a partialApply function that produces these new functions for us:

const square = partialApply(power, 2);
const cube = partialApply(power, 3);

This partialApply function is quite easy to write, because it turns out that partial application is baked into JavaScript:

function partialApply(fn, ...args) {
 return fn.bind(null, ...args);
}

Using Context With Partial Functions

Here is an example where the DSL uses a partial function with a context. In our made up castle builder DSL we are going to connect the towers.

// Declaring functions
fun connectFrom(from: String): (String) -> CastleBuilder {
   return {to: String -> connect(from, to)}
}

private fun connect(from: String, to: String): CastleBuilder {
  connections[from] = to
  return this
}

fun to(to: String): CastleBuilder {
    last?let {
        connect(it, to) 
    }
    last = to
    return this
}
...
// We capture the value "keep" and return a function with it.
val to = builder.connectFrom("keep")
now we can keep calling the to function with the captured "keep" value.
to("sw")
to("nw")
to("ne")
to("se")

Using Context Without Partial Functions

We can implement the same functionality with storing the value.

fun fix(from: String): CastleBuilder {
   last = from
   return this
}

fun fixTo(to: String): CastleBuilder {
   last?.let{
      connect(it,to)
   }
   return this
}

builder.fix("keep")
       .fixTo("sw")
       .fixTo("nw")
       .fixTo("ne")
       .fixTo("se")

Implementing DSL

DSL Transformations

Goal Original Transformed
Apply Remove References builder.keep("keep")

builder.tower("sw")

builder.apply { keep("keep") tower("sw") }
Vararg connect("keep"), "sw")

connect("keep"), "nw") connect("keep"), "ne") connect("keep"), "se")

builder.connectToAll("keep","sw","nw","ne","se")
mapOf Intuitive syntax builder.connect("sw","nw") builder.connect("nw","ne")

builder.connect(mapOf("sw" to"nw"), "nw" to"ne")

DSL Techniques

Goal Usage
Symbol Table Allows DSL to accept string literals, resolve the dependencies later symbols.lookup("sw")
Builder Encapsulate object construction and hold the DSL functions builder.keep("keep") builder.tower("sw")
Function Chaining Reduce calls to the builder. Make sentences build.keep("keep")tower("sw")

DSL Context Techniques

Goal Original Transformed
-
Context Variable Store state within the DSL to reduce parameters builder.connect("sw","nw") builder.connect("nw","ne") builder.connect("sw") builder.connect("nw") builder.connect("ne")
Nested Builder Objects Use different builder objects to contain lanaguage builder.connect("sw","nw") builder.connect("nw","ne") builder.tower("sw").wall().tower("nw").wall().tower("ne")