Kotlin DSL
Posted on May 27, 2018 • 5 minutes • 1054 words
Table of contents
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 are 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.
Kotlin DSL
Kotlin provides first class support for DSL 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 -
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. Above DSL specifies compile and testCompile dependencies for a gradle project in very concise and expressive form.
How does Kotlin support DSL
Before we get in to Kotlin’s support for DSL, 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 which invokes buildString()
will look like the below code -
val str = buildString {
it.append("Hello")
it.append(" ")
it.append("World")
}
A few things to note here:
buildString()
takes lambda as the last parameter. If a function takes lambda as the last parameter, Kotlin allows you to invoke the function using braces { .. }, no need of using parenthesesit
is the implicit parameter available in lambda body which is an instance ofStringBuilder
in this example
This information is good enough to write a gradle
dependencies DSL.
First Attempt at 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 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 DependencyHandler
as a parameter and returns Unit. DependencyHandler
is the type T
which has compile
and testCompile
methods.
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
is used in the client code. Can we remove it
? To remove the implicit parameter, we need to look at “Lambda With Receiver”.
Lambda with receiver
Receiver is a simple type in Kotlin which is extended. Let’s see this with an example -
fun String.lastChar() : Char =
this.toCharArray().get(this.length - 1)
We have extended the String
type 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 the lastChar()
function is the receiver object. Can we combine these 2 concepts - lambda and receiver?
Let’s rewrite our buildString
function using lambda with receiver -
fun buildString(action: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.action()
return sb.toString()
}
A few things to note here:
buildString()
takes a lambda with receiver as a parameterStringBuilder
is the receiver type in the lambda- the way we invoke action function is different this time.
Because
action
is an extension function onStringBuilder
we invoke it usingsb.action()
, wheresb
is an instance ofStringBuilder
Let’s now 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 which takes lambda with receiver as a parameter.
Shall we rewrite our gradle
dependencies DSL code?
Another Attempt at 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 done 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 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 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 the invoke
operator on a class allows it to be called on any instances of the class without a method name.
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:
- is an operator function
- takes a parameter
- can be overloaded
- is being called on the instance of the
Greeter
class without a method name
Let’s use the invoke
function in building the DSL.
Building DSL using the invoke function
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 the DependencyHandler
which takes a lambda with receiver as a parameter. This means invoke
will automatically be called on instance(s) of DependencyHandler
and the 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("")
}
The operator function invoke()
can come in handy while building DSL.
Conclusion
- Kotlin provides a first class support for DSL which is type safe
- One can create a DSL in Kotlin using -
- Lambda as function parameters
- Lambda with receiver as function parameter
- Operator function
invoke
along with lambda with receiver as function parameter
References
- Kotlin In Action