I have a lot of small data holders which are essen...
# getting-started
c
I have a lot of small data holders which are essentially named tuples. For example,
Copy code
class Example internal constructor(
    val foo: Int,
    val bar: String,
    val baz: Foo,
)
It's very important for me that: • I can add new fields in a backwards-compatible manner (as you can see, the constructor is
internal
, so adding new fields is not a binary compatibility issue when constructing the object) • Low boilerplate: it's used in a lot of places! • They can be compared using `equals`/`hashCode`. The first solution that comes to mind would be using
data class
, but then changing the order of fields is binary-incompatible due to
componentN
, and adding a field is binary-incompatible due to
copy
. Writing
equals
and
hashCode
manually is a lot of boilerplate, and susceptible to mistakes if fields are added in the future without updating them. It's also harder to review. Is there some way to have
data class
-like `equals`/`hashCode` (/ optionally
toString
), without
copy
nor
componentN
, to ensure binary-compatibility when adding fields/changing their order later?
c
Perhaps encapsulate the data class to maintain binary compatibility and delegate to it?
Copy code
class Example internal constructor(
    val foo: Int,
    val bar: String,
    val baz: Foo,
) {
    private val impl = Impl(foo = foo, bar - bar, baz = baz)
    override fun equals(other: Any?): Boolean {
        if( other !is Example) {
            return false
        }
        return impl.equals(other.impl)
    }

    override fun hashCode(): Int {
        return impl.hashCode()
    }

    override fun toString(): String {
        return impl.toString()
    }

    private data class Impl(val foo : Int, val bar : String, val baz : Foo)
}
c
It would work, but that's also a lot of work. This pattern is applied enough times in the codebase that it starts to matter for final binary size.
c
In the absence of binary compatibility concerns a
data class
would work nicely. What are the issues with binary compatibility preventing that?
c
The project is a library. These objects are exposed to users of the library. I know for a fact new fields will be added relatively often in the future. It's important that it doesn't break downstream code when it happens.
^ a simpler variant of your idea is just to declare an interface, and have a private data class:
Copy code
interface Example {
    val foo: Int,
    val bar: String,
    val baz: Foo,
}

private data class ExampleImpl(
    override val foo: Int,
    override val bar: String,
    override val baz: Foo,
) : Example
Still requires to duplicate everything, though.
c
nice. less code than the class with inner data class. The interface is the contract that is exposed. Have used that pattern in a few places, works well.
maintenance-wise it’s not too bad with the duplication - adding a property to the interface errors out on the implementation, allowing the IDE to suggest adding it to the impl.
j
There's this compiler plugin that might help.
e
other alternatives: • publish a single non-data class and use reflection in tests to check that all fields are considered in hashCode and equals (which I've done before in an old Java project) • implement equals/hashCode reflectively (I don't like this but mentioning for completeness) • generate the equals/hashCode functions with KSP or KAPT. the generated functions have to live outside of the class but you can simply forward to them from your class