Design for Separate Implicit Contexts

In the previous example, all the variables came in from a single context object—that is, a single receiver. In a more complex DSL, where many different groups of variables and functions are used, keeping everything in one context can get complex and hard to maintain. In this section we’ll see how different parts of a DSL may use different contexts.

Gradle kts build files are a great example of a Kotlin DSL—the syntax is lightweight, fluent, concise, and has just the essence. The DSL snippet in this section follows in the spirit of Gradle’s Kotlin DSL.

Suppose we want to express the configurations for building a project—the dependencies, the locations of the source code and test files, parameters to pass to the compiler, and so on. We can write these in a lightweight format, like so:

 build {
  version = ​"1.2"
  src = ​"/src"
  test = ​"/test"
 
  dependency(​"some.library"​)
  dependency(​"another.library"​)
 
  task(​"test"​) {
  jvmArgs = ​"-p somepath"
  }
 }

A quick glance at the example build file shows multiple variables like version, src, test, jvmArgs, and also functions like build(), dependency(), and task(). Certainly, these aren’t defined along with the DSL snippet, thus they have to come from implicit contexts. Let’s explore the design to facilitate this DSL snippet. We’ll design in a way that the code within the lambda passed to build() runs in one execution context. The code within the lambda passed to the task() function call, which is embedded within the call to the build() function, runs in a different context.

The syntax build {...} is syntax sugar for a more traditional function call syntax build({...}). Thus build {...} is nothing but a call to a build() function that takes a lambda as a parameter. In Kotlin, if a function takes a lambda as its last parameter, then the lambda can be placed outside of (). In the case where the lambda is the only parameter, then the () isn’t needed in the call.

Within the lambda passed to build we have an assignment version = "1.2". We can make that line work by introducing a version property, but we have to decide on the object that will hold that property. This is where an implicit receiver comes in.

Have a look at this line:

version = "1.2"

We can read it as the following, where that implicit this can be associated with an implicit receiver for the lambda parameter:

this.version = "1.2"

Let’s examine how to assign an implicit receiver for a lambda. Kotlin provides a special syntax for that purpose. Suppose we want to make a Config class as an implicit receiver for a lambda that’s passed to the call of a build() function. We can do that by using this syntax:

fun build(block: Config.() -> Unit)

The syntax Config.() treats the lambda as if it were an extension function of Config. This says that the lambda, referred to by the variable block, can and should be invoked in the context of an implicit receiver of type Config. As a result, within the lambda, this will refer to the instance of Config that’s used as a target when calling the lambda. We can use this facility to design one of the execution contexts for our DSL snippet.

Let’s define the build() function first:

 fun​ ​build​(block: Config.() -> Unit) {
 val​ configuration = Config()
 
  configuration.block()
 
  println(configuration)
 }

Within the build() function we create an instance of the yet-to-be-written Config class. We then invoke the lambda with the instance of Config as the target—that is, as the implicit receiver. Finally, we print the details stored within the instance.

The three lines of code within build() clearly show the steps we took: create an instance, invoke the lambda with that instance as the context object, and print the details in that context object. If you’re one of those developers, like your humble author, who likes to make code concise, refactor these three lines into a single line expression, using the apply() extension function in Kotlin’s Any class, like so:

 fun​ ​build​(block: Config.() -> Unit) = println(Config().apply(block))

Let’s shift our focus to the Config class as the next step in the design of the DSL. That part is fairly straightforward. The Config class needs a few properties—version, src, and test—which are being assigned in the lambda passed as an argument to the build() function call. In addition, we’ll also create a private property named dependencies to store the values passed to the dependency() function. Also, we’ll create a tasks property to hold compiler arguments passed to the task() function. Finally, we need the dependency function and a toString() function.

 class​ Config {
 var​ version = ​""
 var​ src = ​""
 var​ test = ​""
 var​ tasks = ​""
 private​ ​val​ dependencies = mutableListOf<String>()
 
 fun​ ​dependency​(library: String) = dependencies.add(library)
 
 fun​ ​task​(type: String, commands: CommandContext.() -> Unit) {
 val​ commandContext = CommandContext().apply(commands)
 
  tasks = ​"$tasks---$type configured with ${commandContext}"
  }
 
 override​ ​fun​ ​toString​() = ​"""Config:
  |Version: $version
  |Source path: $src
  |Test Path: $test
  |Dependencies: ${dependencies.joinToString()}
  |Tasks: $tasks
  """​.trimMargin()
 }

With only a few lines of code, we were able to define the necessary context object and the succinct code to set that as an implicit context for the execution of the lambda expression.

The task() function, defined within Config, is intended for the user to specify command line arguments for a task. That is more specialized than the configurations specified within the lambda passed to the build() function. From the design point of view, it’s better to maintain the task()-related details in a separate context. Such separation of concerns helps to reduce complexity and increase cohesion. For that reason, the task() function is using a yet-to-be-defined CommandContext class as an implicit receiver for its commands lambda parameter. Let’s define that class to store the jvmArgs property.

 class​ CommandContext {
 var​ jvmArgs = ​""
 
 override​ ​fun​ ​toString​() = ​"jvmArgs: $jvmArgs"
 }

Whereas the variables version, src, and test came from one context, an instance of Config, the variable jvmArgs comes from a different context, an instance of CommandContext. This illustrates how within the DSL we can smoothly transition from one context to another. Instead of placing all the variables in one context object, we were able to separate them into different execution contexts. With that approach, we were able to make code cohesive and breathe clarity into the design.

We’re all set to execute the DSL. First we have to compile the code for the context objects and refer to the JAR in the classpath for execution for the script.

 kotlinc-jvm -d config.jar config.kt
 kotlinc-jvm -classpath config.jar -script builder.kts

The bytecode for the build() function, the Config class, and the CommandContext class reside in the config.jar file after the compilation. The DSL in the kts file makes use of those classes from that JAR file. The output of the execution is shown here:

 Config:
 Version: 1.2
 Source path: /src
 Test Path: /test
 Dependencies: some.library, another.library
 Tasks: ---test configured with jvmArgs: -p somepath

By setting up the necessary implicit contexts, we’re able to process the elegant and fluent DSL snippet, which looks like a Gradle build file created using the Kotlin DSL.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.36.10