How to make a builder for a Kotlin data class with many immutable properties
Asked Answered
C

4

8

I have a Kotlin data class that I am constructing with many immutable properties, which are being fetched from separate SQL queries. If I want to construct the data class using the builder pattern, how do I do this without making those properties mutable?

For example, instead of constructing via

var data = MyData(val1, val2, val3)

I want to use

builder.someVal(val1)
// compute val2
builder.someOtherVal(val2)
// ... 
var data = builder.build()

while still using Kotlin's data class feature and immutable properties.

Chaim answered 6/8, 2017 at 18:36 Comment(1)
Why not make the class Java and use something like Lombok instead of the copy hack or rolling your own builder pattern?Judaic
U
4

I agree with the data copy block in Grzegorz answer, but it's essentially the same syntax as creating data classes with constructors. If you want to use that method and keep everything legible, you'll likely be computing everything beforehand and passing the values all together in the end.

To have something more like a builder, you may consider the following:

Let's say your data class is

data class Data(val text: String, val number: Int, val time: Long)

You can create a mutable builder version like so, with a build method to create the data class:

class Builder {
    var text = "hello"
    var number = 2
    var time = System.currentTimeMillis()

    internal fun build()
            = Data(text, number, time)

}

Along with a builder method like so:

fun createData(action: Builder.() -> Unit): Data {
    val builder = Builder()
    builder.action()
    return builder.build()
}

Action is a function from which you can modify the values directly, and createData will build it into a data class for you directly afterwards. This way, you can create a data class with:

val data: Data = createData {
    //execute stuff here
    text = "new text"
    //calculate number
    number = -1
    //calculate time
    time = 222L
}

There are no setter methods per say, but you can directly assign the mutable variables with your new values and call other methods within the builder.

You can also make use of kotlin's get and set by specifying your own functions for each variable so it can do more than set the field.

There's also no need for returning the current builder class, as you always have access to its variables.

Addition note: If you care, createData can be shortened to this:

fun createData(action: Builder.() -> Unit): Data = with(Builder()) { action(); build() }.

"With a new builder, apply our action and build"

Untold answered 6/8, 2017 at 21:31 Comment(1)
Your builder has invalid/temporary data in it, which means you could just as easily instantiate a data class with that data, and then use the copy constructor to avoid the builder entirely. Instead, use lateinit and/or Delegates.notNull (for primitives). The builder will produce an exception at runtime unless all the necessary variables are set, which IMO is the right behavior!Pearlene
L
3

I don't think Kotlin has native builders. You can always compute all values and create the object at the end.

If you still want to use a builder you will have to implement it by yourself. Check this question

Liggitt answered 6/8, 2017 at 18:40 Comment(2)
I am currently creating the object at the end like you said, however it seems counter intuitive to keep track of all the constructor parameters that will be passed in. Essentially I'm having to maintain the same list of parameters in both the data class and in the code block where I am constructing instances of the data class.Chaim
@BrianVoter but that's also how builder models work in Java. The values are in the builder class and copied over to the data class upon creation.Untold
H
2

There is no need for creating custom builders in Kotlin - in order to achieve builder-like semantics, you can leverage copy method - it's perfect for situations where you want to get object's copy with a small alteration.

data class MyData(val val1: String? = null, val val2: String? = null, val val3: String? = null)

val temp = MyData()
  .copy(val1 = "1")
  .copy(val2 = "2")
  .copy(val3 = "3")

Or:

val empty = MyData()
val with1 = empty.copy(val1 = "1")
val with2 = with1.copy(val2 = "2")
val with3 = with2.copy(val3 = "3")

Since you want everything to be immutable, copying must happen at every stage.

Also, it's fine to have mutable properties in the builder as long as the result produced by it is immutable.

Hodge answered 6/8, 2017 at 19:4 Comment(1)
Let's just remember that in JVM every copy execution creates a new instance of the data class. So e.g. val temp = MyData().copy(val1 = "1").copy(val2 = "2").copy(val3 = "3") creates four instances of MyData to create that single val. This + the fact that default values of data class properties can be complex & expensive expressions, means that all this can get out of hand pretty quickly (performance-wise).Cogitative
A
1

It's possible to mechanize the creation of the builder classes with annotation processors. I just created ephemient/builder-generator to demonstrate this.

Note that currently, kapt works fine for generated Java code, but there are some issues with generated Kotlin code (see KT-14070). For these purposes this isn't an issue, as long as the nullability annotations are copied through from the original Kotlin classes to the generated Java builders (so that Kotlin code using the generated Java code sees nullable/non-nullable types instead of just platform types).

Ahola answered 7/8, 2017 at 5:39 Comment(2)
Did you use JavaPoet over KotlinPoet for any particular reason? I was hoping Kotlin had a more standard approach that I had missed but this is really interesting!Chaim
@BrianVoter The annotation processor API provides a view on Java classes, and it's not trivial to recover the original Kotlin semantics (such as default arguments), so I found it it easier to just work with Java.Ahola

© 2022 - 2024 — McMap. All rights reserved.