How to implement `equals` and `hashCode` on `List` subtypes?

I just noticed that, in contrast to what is done in Java, the List interface in Kotlin does not define either equals or hashCode methods.

This is also true for other interfaces like Set and Map.

From what I understand, this means that the following statements are not necessarily true (although they actually are in practice):

  • listOf(1) == arrayListOf(1)
  • listOf(1).hashCode == arrayListOf(1).hashCode

So, this leads to the following questions:

  • Why doesn’t the List interface in Kotlin explicitly override equals and hashCode (as its counterpart in Java does)?
  • Say I want to create a custom List implementation called MyEmptyList. Instances from this class are always empty. This means that MyEmptyList() == emptyList() should be always true. How should hashCode be implemented in this case?
3 Likes

As far as I know Javas List interface does not override hashCode or equals. The interface declares that each list needs to implement hashCode and equals. The jdoc documentation just states how this should be implemented.
This should also answer your second question.
Kotlins Lists do not have equals and hashCode as part of the interface description because all classes in Kotlin are a subclass of Any which declares hashCode and equals already.

If you look at the code of ArrayList for example you see that it extends AbstractList which implements hashCode. There is also an equivalent class called AbstractSequentialList which should be used for lists backed by sequentially accessed data.

I also stumbled over this and I have to disagree with Wasabi375.

It’s true, Javas List interface does not actually implement equals (which would be impossible), but it overwrites the JavaDoc to clearly state, what a List.equals method must fullfil. It must compare elements by index etc. This is much more specific then just relying on the general contract of Object.equals (or in Kotlin Any.equals).

So following this, in Java, I can be sure that any lists are equal if they have equal elements. Since Kotlin wants to be compatible (and in practice it is) the KDocs of List.equals and Set.equals etc. should also be aligned to Java.

2 Likes

This is one thing I don’t like about Kotlin. Compared to Java, the documentation and method contracts are rather unspecified.

Java says how equals and hashCode for various collections should be implemented. Java specifies that CharSequence.toString() should return a String containing the same characters the CharSequence represents. Java specifies which exceptions should occur if List.get() is called with an out-of-bounds index. And so on. Kotlin specifies nothing like that. Assuming you don’t know about the Java conventions or you ignore them, Code you write in Kotlin will likely violate Java specifications. That’s not a good situation and will cause problems sooner or later.

I strongly agree. List, Set, etc. should re-specify the contracts for equals and hashCode – otherwise we are bound to get inconsistent behaviour!

In Kotlin, the problem goes further since we can conform to an interface by delegation. See, for example, this issue on kotlinx.serialization. We have

data class JsonArray(val content: List<JsonElement>) 
	: JsonElement(), 
	  List<JsonElement> by content { ... }

but the delegation does not apply to equals! We get fun test failures like [1,2,3] does not equal [1,2,3]. We have to explicitly override equals to get the expected behaviour (component-wise comparions).

MWE:

interface Foo {
    val f: Int
}

class Foos(override val f: Int): Foo {
    override fun equals(other: Any?) : Boolean =
        if (other is Foo) {
            f == other.f
        } else {
            false
        }
}

class Fooses(override val f: Int) : Foo by Foos(f)

fun main() {
    println(Foos(7) == Foos(7))
    println(Fooses(7) == Fooses(7))
}

But:

//sampleStart
interface Foo {
    val f: Int
    override fun equals(other: Any?) : Boolean
}
//sampleEnd

class Foos(override val f: Int): Foo {
    override fun equals(other: Any?) : Boolean =
        if (other is Foo) {
            f == other.f
        } else {
            false
        }
}

class Fooses(override val f: Int) : Foo by Foos(f)

fun main() {
    println(Foos(7) == Foos(7))
    println(Fooses(7) == Fooses(7))
}

Therefore, I strongly support the case for re-stating equals (and similarly affected functions) on collection interfaces (and other similar cases), both to document intent (or rather, specify the behaviour for all collections!) and to make delegation work as one would expect (given the implicit convention of how to compare collections).

One might even be so bold as to ask for default implementations in the interfaces; if that’s reasonable requires a more in-depth discussion. (Also, “An interface may not implement a method of ‘Any’”.)

2 Likes

Related issues: KT-28781, KT-28183

1 Like