LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 22: To equal, or not to equal

Hello. I'm Ishikawa, and I develop the mobile client for the communication app "LINE".

This article is the 22nd installment of our weekly series "Weekly Report". For more about the Weekly Report, please refer to the first article.

To equal, or not to equal

In Java and Kotlin, you can override equals to define "structural equality". (However, when you override equals, you also need to override hashCode.)

Let's consider a data model for a user profile UI. This data model defines its own equals and hashCode.

class UserProfileViewData(
    val userId: Int,
    val accountName: String,
    val nickname: String,
    val profileImageUri: Uri,
    val statusMessage: String,
    ...
) {

    override fun hashCode(): Int {
        return userId
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as UserProfileViewData

        return userId == other.userId
    }
}

Is there a problem with this code?

Are oranges and apples the same fruit?

As a general rule, you should avoid comparing only some properties in equals. equals should usually indicate either "identity" (referential equality, same object) or "equivalence" (all properties are equal).

The implementation of UserProfileViewData.equals shown above indicates neither identity nor equivalence. This can lead to unexpected bugs.

Imagine that the UI should update whenever the information in UserProfileViewData changes. Instances of UserProfileViewData are provided as "observable" objects, such as:

  • Kotlin coroutines' StateFlow
  • Kotlin coroutines' Flow (with distinctUntilChanged)
  • Rx's Observable (with distinctUntilChanged)
  • Android's LiveData (with distinctUntilChanged)

These observables output the instance of UserProfileViewData whenever it changes, using equals to determine if it has changed. If equals returns true when comparing the current instance with the previous one, the change is ignored. If it returns false, the change is output. Therefore, if nickname or statusMessage is updated but userId remains the same, equals will return true, and the UI won't update. This causes a bug where updating the nickname doesn't reflect on the screen.

The definition of equals should be one of the following:

  • Identity: true if two objects (or their references) are the same. In Java or Kotlin, use the default equals from Object or Any.
  • Equivalence: true if all properties or fields are equal. All properties or fields must have defined equivalence. In Kotlin, you can easily implement this with data classes.

If you need a function that compares only some properties, define it separately from equals. In the example below, a function that compares only userId is defined.

class UserProfileViewData(
    val userId: Int,
    ...
) {

    fun hasSameIdWith(other: UserProfileViewData): Boolean =
        userId == other.userId
    
    ...
}

Note 1: Kotlin data class

Be aware that the default implementation of equals in a Kotlin data class ignores properties that aren't constructor parameters.

data class Data(val i: Int) {
    var j: Int = 0
}

val data1 = Data(42)
val data2 = Data(42)
data2.j = 2
data1 == data2 //=> true!

Note 2: Exception cases

When defining equivalence, you might sometimes ignore certain properties. Caches or memoized values are typical examples. The Factorial class below calculates the factorial of n and stores the result in cachedValue. This cachedValue isn't compared in equals. If the behavior difference due to the presence of a cache can be ignored (e.g., ignoring the difference in calculation time), this implementation is acceptable.

data class Factorial(val n: UInt) {
    private var cachedValue: UInt? = null

    val value: UInt
        get() {
            val cached = cachedValue
            if (cached != null) {
                return cached
            }

            val calculated = (2u..n).fold(1u) { acc, i -> acc * i }
            cachedValue = calculated
            return calculated
        }
}

Note 3: Representation or actual value

So far, we've treated equivalence as something that can be defined straightforwardly. However, equivalence can vary depending on the perspective, so it needs to be defined carefully. Let's assume the rational number Rational is defined as follows:

class Rational(numerator: Int, denominator: UInt) {
    ...
}

The numerator represents the signed numerator, and the denominator represents the denominator. Now, consider comparing Rational(1, 2) (1/2) and Rational(2, 4) (2/4).

Rational(1, 2) == Rational(2, 4) // true or false?

Whether this result should be true or false depends on what Rational represents.

  • If it's a model for display: It should return false. For example, if you output "1/2 + 2/4 = 1" to the UI, 1/2 and 2/4 are different.
  • If it's a model for calculation: It should return true. For example, the result of 1/4 + 1/4 should be "the same" as 1/2.

Summary

Clarify whether equals indicates identity or equivalence.

Keywords: identity, equivalence, equals

List of articles on improving code quality