The Builder pattern is useful when we need to initialize many fields during new object creation. According to this pattern, instead of using a constructor with a lot of parameters, we can create a nested class that collects all passed arguments and constructs a new object. Let's look at the typical implementation of this pattern in a classic Java way. Let's imagine that we need to cook a burger. For this, we need to define classes of ingredients:
class Meat
class Cheese
class Ketchup
class Bun
We also need to define the Burger class, which encapsulates all ingredients:
class Burger {
private val meat: Meat
private val cheese: Cheese
private val ketchup: Ketchup
private val topBun: Bun
private val bottomBun: Bun
private constructor(meat: Meat, cheese: Cheese, ketchup: Ketchup, topBun: Bun, bottomBun: Bun) {
this.meat = meat
this.cheese = cheese
this.ketchup = ketchup
this.topBun = topBun
this.bottomBun = bottomBun
}
}
The Builder pattern assumes that we use the nested Builder class that obtains all arguments and creates a new instance. This class can be written as follows:
class Builder {
private var meat: Meat = Meat()
private var cheese: Cheese = Cheese()
private var ketchup: Ketchup = Ketchup()
private var topBun: Bun = Bun()
private var bottomBun: Bun = Bun()
fun setMeat(meat: Meat): Builder {
this.meat = meat
return this
}
///..............
fun setBottomBun(bottomBun: Bun): Builder {
this.bottomBun = bottomBun
return this
}
fun build(): Burger {
return Burger(meat, cheese, ketchup, topBun, bottomBun)
}
We can use this implementation as follows:
fun main(args: Array<String>) {
val burger: Burger = Burger.Builder()
.setMeat(Meat())
.setKetchup(Ketchup())
.build()
}
You don't have to use all setters of the Builder class because fields have already been initialized by default values.
As you can see, a classic implementation of this pattern requires a lot of boilerplate code, but in Kotlin we can use named and default argument features, for instance. Let's create the Kotlinger class:
class Kotlinger(private val meat: Meat = Meat(),
private val cheese: Cheese = Cheese(),
private val ketchup: Ketchup = Ketchup(),
private val topBun: Bun = Bun(),
private val bottomBun: Bun = Bun())
You can use this as follows:
val kotlinger: Kotlinger = Kotlinger(
meat = Meat(),
ketchup = Ketchup()
)
Using the named and default argument features, we have the same benefits as when we use classical implementation.
For more complex cases, we can use Type-Safe Builders. This concept is based on the function with the receiver object feature of Kotlin and brings the power of domain-specific languages (DSLs) to the Builder pattern. Let's consider the following simplified example of user interface creation:
class Window(init: Window.() -> Unit) {
private var header: TextView? = null
private var footer: TextView? = null
init {
init()
}
fun header(init: TextView.() -> Unit) {
this.header = TextView().apply { init() }
}
fun footer(init: TextView.() -> Unit) {
this.footer = TextView().apply { init() }
}
}
The Window class uses the TextView class, which looks as follows:
class TextView {
var text: String = ""
var color: String = "#000000"
}
To make creating a new object of the Window class more understandable, we can create the following window function:
fun window(init: Window.() -> Unit): Window {
return Window(init)
}
Finally, we can create a new instance of the Window class with complex initialization, like this:
window {
header {
text = "Header"
color = "#00FF00"
}
footer {
text = "Footer"
}
}