In previous topics, we have seen the power of working with functions in Kotlin. We have learned how important it is to use extension functions and lambdas with receivers.
In this topic, we will focus on how to use those with the builder pattern to implement DSL (domain-specific language) and make a type-safe builder for our code.
Builders in Kotlin
The builder pattern is a creational design pattern that allows making complex objects step by step. It provides a way to construct objects by separating the construction logic from the representation, which results in more readable and maintainable code.
The traditional builder pattern is commonly implemented using a combination of a class representing the object to be built and a separate builder class. The builder class provides methods to set the properties of the object being built, allowing for a fluent and customizable construction process. The builder pattern helps improve the readability and flexibility of object construction by providing a clear and customizable way to construct complex objects, avoiding the need for multiple constructors or large parameter lists.
class Person private constructor(
val firstName: String,
val lastName: String,
val age: Int,
val address: String
) {
class Builder {
private var firstName: String = ""
private var lastName: String = ""
private var age: Int = 0
private var address: String = ""
fun setFirstName(firstName: String): Builder {
this.firstName = firstName
return this
}
fun setLastName(lastName: String): Builder {
this.lastName = lastName
return this
}
fun setAge(age: Int): Builder {
this.age = age
return this
}
fun setAddress(address: String): Builder {
this.address = address
return this
}
fun build(): Person {
return Person(firstName, lastName, age, address)
}
}
}
fun main() {
val person = Person.Builder()
.setFirstName("John")
.setLastName("Doe")
.setAge(30)
.setAddress("123 Main St")
.build()
}
However, you can implement this pattern using Kotlin's functional advantages: extension functions and lambdas with receivers or function literals with receivers. Let's see an example:
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
val address: String
)
data class PersonBuilder(
val firstName: String = "",
val lastName: String = "",
val age: Int = 0,
val address: String = ""
)
fun personBuilder(init: PersonBuilder.() -> Unit): Person {
val builder = PersonBuilder()
builder.init()
return Person(builder.firstName, builder.lastName, builder.age, builder.address)
}
fun main() {
val person = personBuilder {
firstName = "John"
lastName = "Doe"
age = 30
address = "123 Main St"
}
}
In this approach, the Person class remains the same, but the PersonBuilder class is defined as a data class that represents the builder's state. The personBuilder function is defined as a higher-order function that takes a lambda with a receiver (init: PersonBuilder.() -> Unit) to configure the builder. The lambda with a receiver (init) allows you to directly set the properties of the PersonBuilder object using named parameters. After configuring the builder, calling the personBuilder function creates and returns the corresponding Person object. This functional programming approach provides a concise and declarative way to build objects using a builder pattern, leveraging the power of lambdas with receivers and immutable data classes.
Here is another example, which shows how to build a String based on the above pattern.
fun buildString(action: (StringBuilder).() -> Unit): String {
val stringBuilder = StringBuilder()
action(stringBuilder)
return stringBuilder.toString()
}
fun main() {
println(buildString {
append("I Love ")
append("learning Kotlin")
append(" with Hyperskill")
}) // I Love learnig Kotlin with Hyperskill
}DSL
According to Martin Fowler, a Domain-Specific Language (DSL) is a specialized programming language designed to tackle specific types of problems, as opposed to general-purpose languages. In Kotlin, with its powerful features, you can create internal DSLs to address complex hierarchical problems using extension functions, lambdas, lambdas with receivers, and operator overloading. These language constructs allow you to define a DSL that closely resembles the problem domain and provides a natural and intuitive syntax for working with complex hierarchical structures. You can create internal DSLs that offer a more focused and expressive way of solving specific problems within a particular domain, resulting in code that is easier to read, write, and maintain.
Examples of using DSL in Kotlin include generating markup with Kotlin code, such as HTML or XML, or configuring routes for a web server in Ktor.
Type-safe builders
Type-safe builders in Kotlin are based on two main concepts: DSLs and the builder design pattern. By combining these two concepts, type-safe builders in Kotlin enable us to create APIs with a fluent and readable syntax, where code blocks can be structured similarly to a hierarchical structure or a markup language. Type-safe builders in Kotlin are defined through extension functions in a class or companion object. These functions extend a base type and allow for building and configuring objects safely at compile time. By using type-safe builders, the Kotlin compiler can verify the correctness of the values and properties used in the building block, preventing common errors and providing a safer and less error-prone development experience.
As an example, we will code a tree of String values to organize nodes in a hierarchical structure. The first example is without DSL: the TreeNodeBuilder class is responsible for building the tree structure. It has a root property, an empty TreeNode, and methods like value and child. The value method creates a new TreeNode with the specified value and adds it as a child to the root node. The child method creates a new TreeNodeBuilder, adds its built child to the root node, and returns the child builder for further configuration. The build method in the TreeNodeBuilder class returns the root node of the built tree. The buildTree function is a top-level function that creates a new TreeNodeBuilder and returns it.
data class TreeNode(val value: String) {
val children = mutableListOf<TreeNode>()
fun addChild(child: TreeNode) {
children.add(child)
}
fun parent(): TreeNodeBuilder {
return TreeNodeBuilder(root)
}
}
class TreeNodeBuilder(private val root: TreeNode? = null) {
private val currentNode: TreeNode
init {
currentNode = if (root != null) {
root
} else {
TreeNode("")
}
}
fun value(value: String): TreeNodeBuilder {
val node = TreeNode(value)
currentNode.addChild(node)
return this
}
fun child(): TreeNodeBuilder {
val childBuilder = TreeNodeBuilder(currentNode)
currentNode.addChild(childBuilder.build())
return childBuilder
}
fun parent(): TreeNodeBuilder {
if (root != null) {
return TreeNodeBuilder(root)
} else {
throw IllegalStateException("Cannot go back to parent node. Already at the root.")
}
}
fun build(): TreeNode {
return currentNode
}
}
fun buildTree(): TreeNodeBuilder {
return TreeNodeBuilder()
}
fun main() {
val tree = buildTree()
.value("Root")
.child()
.value("Child 1")
.child()
.value("Grandchild 1.1")
.parent()
.child()
.value("Grandchild 1.2")
.parent()
.parent()
.child()
.value("Child 2")
.child()
.value("Grandchild 2.1")
.parent()
.build()
printTree(tree)
}
fun printTree(node: TreeNode, level: Int = 0) {
val indentation = " ".repeat(level)
println("$indentation${node.value}")
for (child in node.children) {
printTree(child, level + 1)
}
}
When you run the main function, it will print the tree structure: each level of the tree is indented by two spaces and the strings representing the nodes are displayed. This gives you a visual representation of the hierarchical structure of the tree of strings.
Root
Child 1
Grandchild 1.1
Grandchild 1.2
Child 2
Grandchild 2.1
Now, we will transform this code to a type-safe builder to use it as a DSL. In this example, the type-safe builder TreeNodeBuilder provides methods such as value and child, which can be chained together to build the tree structure. This allows the tree to be built in a declarative and readable way. In the end, you will get a tree object that represents the data structure of the tree. As an example, we code a tree structure and the items in a hierarchical structure. The buildTree function outside the TreeNode class is used to initiate the building process by taking a lambda with a receiver (TreeNodeBuilder.() -> Unit). Inside the TreeNodeBuilder class, the functions value and child are defined to configure and build the tree structure. The value function in the TreeNodeBuilder class is used to set the value of the current node. It creates a new TreeNode with the specified value and adds it as a child to the root node. The child function in the TreeNodeBuilder class is used to add a child node to the current node. It takes a lambda with a receiver (TreeNodeBuilder.() -> Unit) that defines the configuration of the child node. Inside the lambda, a new TreeNodeBuilder is created, and the lambda block is executed within the scope of the child builder. The resulting child node is then added to the current node's list of children.
data class TreeNode(val value: String) {
val children = mutableListOf<TreeNode>()
fun addChild(child: TreeNode) {
children.add(child)
}
}
class TreeNodeBuilder {
private val root = TreeNode("")
private var currentNode = root
fun value(value: String) {
currentNode = TreeNode(value)
root.addChild(currentNode)
}
fun child(block: TreeNodeBuilder.() -> Unit) {
val childBuilder = TreeNodeBuilder()
childBuilder.block()
currentNode.addChild(childBuilder.build())
}
fun build(): TreeNode {
return root
}
}
fun buildTree(block: TreeNodeBuilder.() -> Unit): TreeNode {
val builder = TreeNodeBuilder()
builder.block()
return builder.build()
}
fun main() {
val tree = buildTree {
value("Root")
child {
value("Child 1")
child {
value("Grandchild 1.1")
}
child {
value("Grandchild 1.2")
}
}
child {
value("Child 2")
child {
value("Grandchild 2.1")
}
}
}
printTree(tree)
}
fun printTree(node: TreeNode, level: Int = 0) {
val indentation = " ".repeat(level)
println("$indentation${node.value}")
for (child in node.children) {
printTree(child, level + 1)
}
}
That gives you visually the same representation of the hierarchical structure of the tree of strings as in the previous example.
Root
Child 1
Grandchild 1.1
Grandchild 1.2
Child 2
Grandchild 2.1Using Builders with Builder Type Inference
In Kotlin, starting from version 1.7.0, it's possible to use builders with builder type inference, which is particularly useful when working with generic builders. This feature enables the compiler to infer the type arguments of a builder call based on the type information about other calls within its lambda argument.
Usage Example
Consider the use of buildMap():
fun addEntryToMap(baseMap: Map<String, Number>, additionalEntry: Pair<String, Int>?) {
val myMap = buildMap {
putAll(baseMap)
additionalEntry?.let { put(it.first, it.second) }
}
}
Here, the compiler automatically infers the type arguments of the buildMap() call as String and Number, based on the information about the putAll() and put() calls.
Writing Your Own Builders with Type Inference
To enable type inference in your builders:
- Ensure your builder's declaration includes a lambda parameter with a receiver.
- The receiver type should use the type of arguments that are to be inferred. For example:
fun <V> buildList(builder: MutableList<V>.() -> Unit) { ... } - The receiver type should provide public members or extensions that include the corresponding type arguments in their signatures.
Supported Features
- Inferring several types of arguments.
- Inferring type arguments of multiple builder lambdas within one call.
- Inferring type arguments whose type parameters are the lambda's parameter or return types.
How Builder Type Inference Works
Builder type inference operates in terms of "postponed type variables", which appear inside the builder lambda during the builder inference analysis. The compiler uses them to collect information about the type argument. At the end of the builder type inference analysis, all collected type information is considered and attempted to be merged into the resulting type.
Scope control: @DslMarker
In Kotlin, the @DslMarker annotation is used to define a DSL marker interface or annotation. It allows you to specify a marker that indicates the scope of the DSL and helps enforce scoping rules within the DSL. When creating a DSL, it is often desirable to restrict the availability of certain functions or builders within specific scopes. The @DslMarker annotation enables you to define a marker interface or annotation that acts as a signal to the compiler and other developers that certain functions or builders are intended to be used only within a specific DSL scope.
Let's apply this annotation to our tree structure definition:
@DslMarker
annotation class TreeNodeDslMarker
@TreeNodeDslMarker
data class TreeNode(val value: String) {
val children = mutableListOf<TreeNode>()
fun addChild(child: TreeNode) {
children.add(child)
}
}
class TreeNodeBuilder {
private val root = TreeNode("")
private var currentNode = root
fun value(value: String) {
currentNode = TreeNode(value)
root.addChild(currentNode)
}
fun child(block: TreeNodeBuilder.() -> Unit) {
val childBuilder = TreeNodeBuilder()
childBuilder.block()
currentNode.addChild(childBuilder.build())
}
fun build(): TreeNode {
return root
}
}
fun buildTree(block: TreeNodeBuilder.() -> Unit): TreeNode {
val builder = TreeNodeBuilder()
builder.block()
return builder.build()
}
fun main() {
val tree = buildTree {
value("Root")
child {
value("Child 1")
child {
value("Grandchild 1.1")
}
child {
value("Grandchild 1.2")
}
}
child {
value("Child 2")
child {
value("Grandchild 2.1")
}
}
}
printTree(tree)
}
fun printTree(node: TreeNode, level: Int = 0) {
val indentation = " ".repeat(level)
println("$indentation${node.value}")
for (child in node.children) {
printTree(child, level + 1)
}
}
The @DslMarker annotation is applied to the TreeNode class, marking it as part of the DSL. The @TreeNodeDslMarker annotation serves as a marker for the DSL, indicating that the TreeNode class is specifically designed for use within the DSL. By applying the @DslMarker annotation, you can clearly indicate that the TreeNode class is intended to be used within the DSL scope and prevent its accidental usage outside of the DSL context. This serves as a way to control the visibility and scoping of the DSL functions and builders. @DslMarker is optional, and its usage depends on your specific requirements for scoping and DSL design, but it can be useful in enforcing scoping rules and providing better clarity about the intended usage of the DSL.
Conclusion
In this topic, we have learned how to use type-safe builders to make our DSL for building complex hierarchical data structures in a semi-declarative way. You can try implementing that in your further projects.
Now is the time to do some tasks to check what you have learned. Are you ready?