Kotlin DSLs: The Basics
Love Kotlin's inherent support for creating DSLs? Let's take a look at a few different approaches you can take when building a domain-specific language.
Join the DZone community and get the full member experience.
Join For FreeA 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.
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:
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.
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.
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 DependencyHandler as 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.
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.
References
- Kotlin In Action
Published at DZone with permission of Sarthak Makhija, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments