1. Overview

In this tutorial, we’ll explore how the equals() and hashCode() methods are generated for Kotlin’s data classes. Then, we’ll see how we can override or change the behavior of these methods.

2. equals() and hashCode() in Kotlin

The equals() and hashCode() methods are used to check the equality of two objects.

When compiled, all data classes in Kotlin have a default implementation for equals() and hashCode() methods. This allows us to write concise code and lets the compiler create its implementations as per conventions.

For example, let’s define a Kotlin Data Class:

data class Person(val name: String, val age: Int, val address: String)

Here, we define a class Person with two fields – name and age.

When we compile this class and look at the Java class generated for it, we can see that it contains:

  • constructors
  • getter and setters
  • the toString() method
  • implementations for equals() and hashcode() methods

We can find the class in the classpath. Usually, it’s under target/classes. Decompiling the class file into Java code is usually done by the inbuilt decompiler in IDEs like IntelliJ. If it doesn’t work automatically, we can decompile it manually.

Let’s look at the Java class:

public class Person {
    // fields, constructor and getter methods omitted for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age &&
                Objects.equals(name, person.name) &&
                Objects.equals(address, person.address);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age, address);
    }
}

As we can see, this is as per Java conventions:

  • The hashCode() method takes into account all of the nameage, and address fields.
  • Similarly, the equals() method returns true only if all fields are equal.

3. Overriding equals() and hashCode()

In the previous section, we looked at the default implementation of equals() and hashCode() by the compiler. If we want to provide our own implementation, we can override the equals() and hashCode() methods in our Kotlin code.

For example, let’s say we want to only compare the name of the person to check for equality. Let’s provide our implementation for that purpose:

data class PersonWithUniqueName(val name: String, val age: Int, val address: String) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) return false
        val otherPerson: PersonWithUniqueName = other as PersonWithUniqueName
        return otherPerson.name == name
    }

    override fun hashCode(): Int {
        return Objects.hash(name)
    }
}

Here, we’ve removed the age and address fields from the comparisons. If we compile this file, we’ll see that the same is reflected in the compiled Java class.

4. Excluding a Field Without Overriding

In the above section, we saw an implementation to exclude the address and age fields from the comparisons. In Kotlin’s data classes, it’s possible to do so without overriding the implementation for equals() and hashCode() methods.

Let’s look at an example of excluding the address field from the comparisons:

data class PersonWithUniqueNameAndAge(val name: String, val age: Int) {
    lateinit var address: String
}

Here, we remove the address field from the class signature and add it to the body. We add the lateinit and var keywords to indicate that the field is mutable and can be set after the object is created. Moreover, this removes the address field from the default constructor.

The compiler only checks the fields defined in the class signature for comparison. If we look at the compiled Java class, we can see that the address field is removed from comparisons:

public class PersonWithUniqueNameAndAge {
    // fields, constructor and getter methods omitted for brevity

    public PersonWithUniqueNameAndAge(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PersonWithUniqueNameAndAge that = (PersonWithUniqueNameAndAge) o;
        return age == that.age && Objects.equals(name, that.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

We defined the address field as mutable, and it may not always be safe to do so for data objects. Unless we need to have mutable data in our object, it’s recommended to override the implementations instead of using this approach.

5. Testing

Now that we’ve defined different types of equality comparisons in the above classes, let’s test that they work correctly.

5.1. Default Implementation

Let’s write a test to check that the default equals() implementation works as expected:

class PersonUnitTest {
    @Test
    fun _equal when fields are equal_() {
        val person1 = Person("John", 18, "Address")
        val person2 = Person("John", 18, "Address")
        assertEquals(person1, person2)
    }

    @Test
    fun _not equal when fields are different_() {
        val person1 = Person("John", 18, "Address")
        val person2 = Person("John", 18, "Another Address")
        assertNotEquals(person1, person2)
    }
}

Here, we have two tests. First, we check that both Person objects are equal when all fields are equal. Then, we confirm that when the address field is different, the objects aren’t equal. If we run the tests, we’ll see that they both pass.

5.2. Overridden Implementations

Next, let’s test that the implementations we provided exclude the fields from the comparison.

First, let’s test that objects of PersonWithUniqueName are equal even if they have different ages or addresses:

class PersonWithUniqueNameTest {
    @Test
    fun _equal when name field is equal_() {
        val person1 = PersonWithUniqueName("John", 18, "Address")
        val person2 = PersonWithUniqueName("John", 19, "Another Address")
        assertEquals(person1, person2)
    }
}

When we run the tests, we see that objects are equal even if they have different ages or addresses.

Next, let’s test that the objects of PersonWithUniqueNameAndAge are equal even if they have different values for the address field:

class PersonWithUniqueNameAndAgeTest {
    @Test
    fun _equal when name and age fields are equal_() {
        val person1 = PersonWithUniqueNameAndAge("John", 18)
        person1.address = "Address"
        val person2 = PersonWithUniqueNameAndAge("John", 18)
        person2.address = "Another Address"

        assertEquals(person1, person2)
    }

    @Test
    fun _not equal when name and age fields are not equal_() {
        val person1 = PersonWithUniqueNameAndAge("John", 18)
        person1.address = "Address"
        val person2 = PersonWithUniqueNameAndAge("John", 19)
        person2.address = "Address"

        assertNotEquals(person1, person2)
    }
}

In the first test, the objects are expected to be equal as both name and age fields are equal. We test the opposite behavior in the second test. The address field doesn’t have an impact on any of the tests.

We’ve explicitly set the address field for the objects as it’s not part of the default constructor.

6. Conclusion

In this article, we looked at how equals() and hashCode() methods are generated for Kotlin’s data classes. We then learned how to override the default implementation generated by the compiler. We also explored how fields defined inside the class body are excluded from the compiled implementation.

As always, the complete code example is available over on GitHub.

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments