2

I want use type-safe builders in Kotlin for a type that has immutable properties.

class DataClass(val p1:String, val p2:String) {}

fun builder(buildBlock: DataClass.() -> Unit) {
    return DataClass(p1 = "",p2 = "").also(block)
}
//...
builder {
    p1 = "p1" // <-- This won't compile, since p1 is a val
    p2 = "p2" // <-- This won't compile, since p2 is a val
}

I thought of two solutions to work around this:

Option 1: Create a builder class:

class DataClass(val p1: String, val p2: String) {}

class DataClassBuilder(){
    lateinit var p1: String
    lateinit var p2: String
    fun build() = DataClass(p1, p2)
}

fun builder(buildBlock: DataClassBuilder.() -> Unit) {
    return DataClassBuilder().also(block).build()
}

Option 2: Create a custom delegate to prevent setting the value again:

class InitOnceDelegate: ReadWriteProperty<DTest, String> {
    private var state: String? = null
    override fun getValue(thisRef: DTest, property: KProperty<*>): String {
        return state ?: throw IllegalStateException()
    }

    override fun setValue(thisRef: DTest, property: KProperty<*>, value: String) {
        if (state == null) {
            state = value
        } else {
            throw IllegalStateException("${property.name} has already been initialized")
        }

    }
}

class DataClass() {
    var p1: String by InitOnceDelegate()
    var p2: String by InitOnceDelegate()
}

fun builder(buildBlock: DataClass.() -> Unit) {
    return DataClass(p1 = "",p2 = "").also(block)
}
//...
val d = builder {
    p1 = "p1" 
    p2 = "p2" 
}
d.p1 = "another value" // <-- This will throw an exception now.

Option 1 has the drawback that I have to maintain two class, option 2 has the drawback that the compiler will allow setting the value in DataClass again and the check will only happen at runtime.

Is there a better way to solve this without the mentioned drawbacks?

msrd0
  • 7,816
  • 9
  • 47
  • 82
Benni
  • 357
  • 6
  • 18
  • 2
    I think Option 1 is the best you can do for now, even with the burden of having to maintain the builder class. Also, you could add checks to see whether the properties have been initialized when you're building the result to throw meaningful exceptions. – zsmb13 Jun 03 '18 at 14:00
  • To make maintaining the builder class trivial, you can [delegate all its fields to a map](https://kotlinlang.org/docs/reference/delegated-properties.html#storing-properties-in-a-map). Still not ideal, but eliminates most of the boilerplate code. – Paul Hicks Jun 03 '18 at 21:21
  • Possible duplicate of [How to implement Builder pattern in Kotlin?](https://stackoverflow.com/questions/36140791/how-to-implement-builder-pattern-in-kotlin) – David Rawson Jun 04 '18 at 05:52

1 Answers1

0

This is kind-of a hacky solution that still requires you to maintain two classes:

interface DataClass
{
    companion object
    {
        fun builder(callback : DataClassImpl.() -> Unit) : DataClass
            = DataClassImpl().apply { callback() }
    }

    val p1 : String
    val p2 : String
}

class DataClassImpl : DataClass
{
    override lateinit var p1 : String
    override lateinit var p2 : String
}

Unlike Option 1, you only create one instance in this example, and unlike Option 2 the compiler will tell you if you try to change the value of p1 or p2 after the builder block.

To avoid being able to cast the result to a DataClassImpl and change the values after the fact you could delegate the interface like this:

interface DataClass
{
    companion object
    {
        fun builder(callback : DataClassBuilder.() -> Unit) : DataClass
            = DataClassBuilder().apply { callback() }.let { DataClassImpl(it) }
    }

    val p1 : String
    val p2 : String
}

class DataClassBuilder : DataClass
{
    override lateinit var p1 : String
    override lateinit var p2 : String
}

class DataClassImpl(
    private val delegate : DataClass
) : DataClass by delegate
msrd0
  • 7,816
  • 9
  • 47
  • 82