Data interfaces!

Imagine we have a sealed interface called Point that has two fields, x: Double and y:Double, and one sub-interface called MutablePoint. We also have a private data class Impl for both Point and MutablePoint, and we’ve made them data classes, thinking we’re taking advantage of Kotlin’s cool features.

But then this test fails:

@Test
fun equalityTest() {
    val immutable = Point.of(5, 6)
    val set = setOf(immutable)
    val mutable = MutablePoint.of(5, 6)
    assertTrue(mutable in set)
}

And so you realize that the only features that data classes inject into your code, that is having conveniently defined equals, hashCode and toString methods as well as componentN methods, only matter if the class is public and its objects are working with objects of the same class, and they’re useless if they’re private and their core logic is defined in the interfaces they implement, like the example above.

The current solution for this is make the classes non-data, and make them extend a common superclass like AbstractPointImpl where the equals, hashCode are defined in a way that allows the subclasses co-exist together in collections. This is bad because 1) we stopped using data classes and with it we lost the compile-time and runtime optimizations that exist for them and 2) we introduced a whole new class within which we had to resort to ancient modes of programming that is very reminiscent of the Java days, something that Kotlin was trying to put behind especially with data classes.

What I propose is simple: data interfaces! They are just like data classes except for interfaces. They provide equals, hashCode logic based on a handful of fields defined in the parenthesis after the data interface and the interface name. They could also provide predefined toString and componentN behavior but this is optional and not the subject of this post.

Known issue: I realize this won’t work because the JVM does not allow equals and hashCode to be defined in interfaces, because those methods are traditionally a part of Object which all classes implicitly extend, and so interfaces do not have access to them to override them.

1 Like

Data class is really just a simple syntactic sugar to save boilerplate for the most common cases. If your case is not the common one, feel free to implement the same manually in 5 minutes or so (actually, generate the code by IDE).

Also note comparing objects of different types is tricky. If you have another class implementing Point then it would probably didn’t make sense that Point.of(5, 6) == 3DPoint.of(5, 6, 2) returns true (just an example). You said your Point is sealed, so in that case it actually makes sense, but I think it is a rather rare case.

I sounds good for equals, hashCode and toString methods, but if you need to copy data class when you access it by interface, it turns into total crap.

For example we have

sealed interface SomeData {
    val id: Int
    val data: String
    val lastAccessDate: LocalDate
}

data class SomeDataImpl(
    override val id: Int, 
    override val data: String,
    override val lastAccessDate: LocalDate
): SomeData

data class SomeMoreComplexData(
    override val id: Int,
    override val data: String,
    override val lastAccessDate: LocalDate
    val comments: String
): SomeData

If we want to modify all the data by accessing it via interface, we’ll have to do something lile that

fun main() {
    val dataList = listOf(
        SomeDataImpl(
            1, "Data", LocalDate.now()),
        SomeMoreComplexData(
            2,
            "ComplexData",
            LocalDate.now(),
            "This data is too complex to be stored at SomeDataImpl")
    )

    val updatedDataList = dataList.map { 
        when (it) {
            is SomeDataImpl -> it.copy(lastAccessDate = LocalDate.now())
            is SomeMoreComplexData -> it.copy(lastAccessDate = LocalDate.now())
        }
    }
}

instead of simply

val updatedDataList = dataList.map { it.copy(lastAccessDate = LocalDate.now()) }

When there are more data to modify and more interface implementations, that will be much more terrible. Data interface could be a salvation in this situation

The problem with this is that the two objects are not equal:

val immutable: Point = Point.of(5, 6)

val mutable: MutablePoint = MutablePoint.of(5, 6)

Even though MutablePoint extends Point, it’s not the same type; if you try to check if a MutablePoint instance equals a Point instance, that’s always going to be false, because they’re not the same type. The data class equals is just a convenience function that compares the values of two objects of the same type. If you have two different data classes, Foo and Bar, and check if an instance of Foo is equal to an instance of Bar, even if they have the same fields, they’re not gonna equal each other, because they’re different classes.

And what is the problem? They are of different type and are not equal

I think the idea makes sense. Yeah, it won’t have copy, but that wasn’t the point here. The point was to make good defaults to the implementing classes for hashCode and equals.

And yes, you can define MutablePoint and Point to be equal when their contents are equal and it does make sense. Kotlin follows this pattern for List and MutableList.

Try this in the REPL: listOf(1, 2) == mutableListOf(1, 2)

I do wonder, however, if we’re now straying pretty far into the idea of just tracking mutability of objects.

I think I was replying to the original post, not realising it’s from over a year ago.