Interpreter Design Patterns
This design pattern may seem very simple or very hard, all based on how much background you have in computer science. Some books that discuss classical software design patterns even decide to omit it altogether, or put it somewhere at the end, for curious readers only. The reason behind this is that the interpreter design pattern deals with translating certain languages. But why would we need that? Don't we have compilers to do that anyway?
We need to go deeper In this section we discuss that all developers have to speak many languages or sub-languages. Even as regular developers, we use more than one language. Think of tools that build your projects, like Maven or Gradle. You can consider their configuration files, build scripts, as languages with specific grammar. If you put elements out of order, your project won't be built correctly. And that's because such projects have interpreters to analyze configuration files and act upon them. Other examples would be query languages, be it one of the SQL variations or one of the languages specific to NoSQL databases. If you're an Android developer, you may think of XML layouts as such languages too. Even HTML could be considered a language that defines user interfaces. And there are others, of course.
Maybe you've worked with one of the testing frameworks that define a custom language for testing, such as Cucumber: github.com/cucumber. Each of these examples can be called a Domain Specific Language (DSL). A language inside a language. We'll discuss how it works in the next section.
A language of your own In this section, we'll define a simple DSL-for-SQL language. We won't define the format or grammar for it, but only an example of what it should look like:
val sql = select("name, age", {
from("users", {
where("age > 25")
}) // Closes from
}) // Closes select
println(sql) // "SELECT name, age FROM users WHERE age > 25" The goal of our language is to improve readability and prevent some common SQL mistakes, such as typos (like FORM instead of FROM). We'll get compile time validations and autocompletion along the way. We'll start with the easiest part—select: fun select(columns: String, from: SelectClause.()->Unit):
SelectClause {
return SelectClause(columns).apply(from)
}
We could write this using single expression notation, but we use the more verbose version for clarity of the example. This is a function that has two parameters. The first is a String, which is simple. The second is another function that receives nothing and returns nothing. The most interesting part is that we specify the receiver for our lambda: SelectClause.()->Unit
This is a very smart trick, so be sure to follow along:
SelectClause.()->Unit == (SelectClause)->Unit
Although it may seem that this lambda receives nothing, it actually receives one argument, an object of type SelectClause. The second trick lies in the usage of the apply() function we've seen before. Look at this: SelectClause(columns).apply(from)
It translates to this:
val selectClause = SelectClause(columns)
from(selectClause)
return
selectClause Here are the steps the preceding code will perform: Initialize SelectClause, which is a simple object that receives one argument in its constructor. Call the from() function with an instance of SelectClause as its only argument. Return an instance of SelectClause. That code only makes sense if from() does something useful with SelectClause. Let's look at our DSL example again:
select("name, age", {
this@select.from("users", {
where("age > 25")
})
})
We've made the receiver explicit now, meaning that the from() function will call the from() method on the SelectClause object.
You can start guessing what this method looks like. It clearly receives a String as its first argument, and another lambda as its second:
class SelectClause(private val columns: String) {
private lateinit var from : FromClause
fun from(table: String, where: FromClause.()->Unit): FromClause {
this.from = FromClause(table)
return this.from.apply(where)
}
}
This could again be shortened, but then we'd need to use apply() within apply(), which may seem confusing at this point. That's the first time we've met the lateinit keyword. This keyword is quite dangerous, so use it with some restraint. Remember that the Kotlin compiler is very serious about null safety. If we omit lateinit, it will require us to initialize the variable with a default value. But since we'll know it only at a later time, we ask the compiler to relax a bit. Note that if we don't make good on our promises and forget to initialize it, we'll get UninitializedPropertyAccessException when first accessing it. Back to our code; all we do is: Create an instance of FromClause Store it as a member of SelectClause Pass an instance of FromClause to the where lambda Return an instance of FromClause Hopefully, you're starting to get the gist of it:
select("name, age", {
this@select.from("users", {
this@from.where("age > 25")
})
})
What does it mean? After understanding the from() method, this should be much simpler. The FromClause must have a method called where() that receives one argument, of the String type:
class FromClause(private val table: String) {
private lateinit var where: WhereClause
fun where(conditions: String) = this.apply {
where = WhereClause(conditions)
}
}
Note that we made good on our promise and shortened the method this time. We initialized an instance of WhereClause with the string we received, and returned it. Simple as that:
class WhereClause(private val conditions: String) {
override fun toString(): String {
return "WHERE $conditions"
}
}
WhereClause only prints the word WHERE and the conditions it received: class FromClause(private val table: String) {
// More code here... override fun toString(): String {
return "FROM $table ${this.where}"
}
}
FromClause prints the word FROM as well as the table name it received, and everything WhereClause printed: class SelectClause(private val columns: String) {
// More code here...
override fun toString(): String {
return "SELECT $columns ${this.from}"
}
}
SelectClause prints the word SELECT, the columns it got, and whatever FromClause printed.
Taking a break
Kotlin provides beautiful capabilities to create readable and type-safe DSLs. But the interpreter design pattern is one of the hardest in the toolbox. If you didn't get it from the get-go, take some time to debug this code. Understand what this means at each step, as well as when we call a function and when we call a method of an object.
Call suffix
In order not to confuse you, we left out one last notion of Kotlin DSL until the end of this section. Look at this DSL:
val sql = select("name, age", {
from("users", {
where("age > 25")
}) // Closes from
}) // Closes select It could be rewritten as: val sql = select("name, age") {
from("users") {
where("age > 25")
} // Closes from
} //
Closes select This is common practice in Kotlin. If our function receives another function as its last argument, we can pass it out of parentheses. This results in a much clearer DSL, but may be confusing at first.
Code Example :
package designpatterns
fun main(args: Array<String>) {
val sql = select("name, age") {
from("users") {
where("age > 25")
}
}
println(sql) // "SELECT name, age FROM users WHERE age > 25"
}
fun select(columns: String, from: SelectClause.() -> Unit): SelectClause {
from(SelectClause(columns))
return SelectClause(columns).apply(from)
}
class SelectClause(private val columns: String) {
private lateinit var from: FromClause
fun from(table: String, where: FromClause.() -> Unit): FromClause {
this.from = FromClause(table)
return this.from.apply(where)
}
override fun toString(): String {
return "SELECT $columns ${this.from}"
}
}
class FromClause(private val table: String) {
private lateinit var where: WhereClause
fun where(conditions: String) = this.apply {
where = WhereClause(conditions)
}
override fun toString(): String {
return "FROM $table ${this.where}"
}
}
class WhereClause(private val conditions: String) {
override fun toString(): String {
return "WHERE $conditions"
}
}
Output :
SELECT name, age FROM users WHERE age > 25
No comments:
Post a Comment