As one step in a larger builder setup, I need to build a small collection of objects, each with a small number of required attributes, and each of those with a small set of possible values. The actual scenario can be analogized to building a short list of vehicle objects, selecting the color, make, and type for each from a limited set of choices, modelled thusly:
data class Vehicle(val color: Color, val make: Make, val type: Type)
enum class Color { RED, GREEN, BLUE }
enum class Make { BUICK, CHEVY, FORD }
enum class Type { CAR, TRUCK, MOTORBIKE }
I thought to leverage Kotlin's considerable language customization capabilities to make this concise and fluid. A small DSL seemed at first appropriate, but the standard design appears to offer no real benefit over the regular language; e.g., listOf(Vehicle(Color.RED, Make.FORD, Type.CAR), ...)
would translate to something like vehicles { vehicle { color = RED, make = FORD, type = CAR } ...}
.
In trying to devise some way to trim that down, I wondered if function calls without parentheses (not counting trailing lambdas) were possible, and quickly found a comment that pointed out that property getters are essentially exactly that.
Delegating a (read-only) property will allow us to trigger a separate function upon a getter call, and if we return from that getter some class with more such delegated properties, we can create chains of property calls that construct our objects with those separate functions, while the return types can dictate available options at each point in the chain and, along with the enclosing function, the type we must ultimately end with.
This abstract class is the basis of my current design:
sealed class Choice<Builder, Data, NextChoice>(protected val builder: Builder) {
protected abstract fun setChoice(value: Data)
protected abstract fun nextChoice(): NextChoice
protected fun setter(value: Data) =
ReadOnlyProperty<Choice<Builder, Data, NextChoice>, NextChoice> { _, _ ->
setChoice(value)
return@ReadOnlyProperty nextChoice()
}
}
With this, I put together a "builder" for the data classes above, which itself is little more than a container object
:
object VehiclesBuilder {
fun chooseVehicles(builderBlock: ColorChoice.() -> CompleteVehicle): List<Vehicle> {
ColorChoice.builderBlock()
vehicles.toList().let {
vehicles.clear()
return it
}
}
private val vehicles = mutableListOf<Vehicle>()
private lateinit var tempColor: Color
private lateinit var tempMake: Make
private fun setColor(color: Color) {
tempColor = color
}
private fun setMake(make: Make) {
tempMake = make
}
private fun setType(type: Type) {
vehicles.add(Vehicle(tempColor, tempMake, type))
}
object ColorChoice : Choice<VehiclesBuilder, Color, MakeChoice>(this) {
val red by setter(Color.RED)
val green by setter(Color.GREEN)
val blue by setter(Color.BLUE)
override fun setChoice(value: Color) {
builder.setColor(value)
}
override fun nextChoice() = MakeChoice
}
object MakeChoice : Choice<VehiclesBuilder, Make, TypeChoice>(this) {
val buick by setter(Make.BUICK)
val chevy by setter(Make.CHEVY)
val ford by setter(Make.FORD)
override fun setChoice(value: Make) {
builder.setMake(value)
}
override fun nextChoice() = TypeChoice
}
object TypeChoice : Choice<VehiclesBuilder, Type, CompleteVehicle>(this) {
val car by setter(Type.CAR)
val truck by setter(Type.TRUCK)
val motorbike by setter(Type.MOTORBIKE)
override fun setChoice(value: Type) {
builder.setType(value)
}
override fun nextChoice() = CompleteVehicle
}
object CompleteVehicle {
operator fun plus(done: CompleteVehicle) = CompleteVehicle
val and: ColorChoice
get() = ColorChoice
}
}
The entry point here is chooseVehicles()
, which takes a function on ColorChoice
, the first Choice
in our chain. Calling any of the properties there sets the associated Color
value in the builder and returns the next in the chain, MakeChoice
, where we get one of its properties, setting its value, and so on. When a TypeChoice
property is finally called, its builder callback instantiates the Vehicle
with the temporary values, and adds it to the list.
TypeChoice
returns CompleteVehicle
which is not itself a Choice
, but offers ways to restart the chain to construct another Vehicle
. The plus
operator allows us to concatenate CompleteVehicle
s (though there's really only one runtime instance), which means we can +
chains together. The and
property returns ColorChoice
to start the chain again, which allows us to connect chains with an extra .and.
link. The plus operator makes more sense for our contrived example here, but my actual implementation is using and
, where the semantics are slightly different.
To ensure only complete chains are provided, that function on ColorChoice
that we build in expects a CompleteVehicle
return, which only happens upon a property call on TypeChoice
, the last link in the chain.
In action, it looks like this:
val vehicleList = chooseVehicles { red.buick.car + green.chevy.truck + blue.ford.motorbike }
which I feel is about as concise as it gets. It also works well with code completion, error messages, etc.:
Questions and concerns:
Is this anything new? When I initially thought of this, my first searches were for examples and pointers, but I was unable to find anything like it. However, as I distilled my initial mess of an attempt down to the current, less awful mess, I got the nagging sense that I'd read about something at least similar, long ago before I was able to fully understand it due to my unfamiliarity with the language.
How terrible/silly is this? I have a slight feeling that it's arguably abusing the language and/or its constructs, or otherwise violating some programming principle or sensibility.
If you're familiar with Kotlin synthetics in Android, you might know that they too involved some trickery with properties, and were considered abuse of those by some. However, DSLs themselves could be considered comparable trickery with functions, I would say.
Also, though I'm not asking about specific correctness (it definitely works), is this actually right? That is, is it valid and viable; should it work, always? Or is this a somehow shaky approach, for any reason?
If this might actually be usable, how can I improve it?
I am still relatively new to Kotlin, so tips on idioms, conventions, and the like are most certainly welcome.
Am I exposing anything unnecessarily? I'm not wholly certain of Kotlin's concept of visibility yet.
This is the last of many iterations, and most of the first ones used regular classes for the
Choice
s, needlessly creating several objects that would then be immediately discarded. Singletons – via Kotlin'sobject
– seem like an apt solution, but I'm not sure if I'm missing any potential pitfalls. I should note here that this builder will only ever run on a single thread.Could I gain anything in the way of generality and reusability by introducing a
Builder
sealed class
orinterface
? Or is that needless complexity? Clearly, this could be used for more than just constructing lists of simple objects, in which case certain namedBuilder
callbacks could certainly be useful. However, as the current setup is allobject
s, it's essentially just a bunch of interwoven "static" functions.