Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.
This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see 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
(withdistinctUntilChanged
) - Rx's
Observable
(withdistinctUntilChanged
) - Android's
LiveData
(withdistinctUntilChanged
)
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 defaultequals
fromObject
orAny
. - Equivalence:
true
if all properties or fields are equal. All properties or fields must have defined equivalence. In Kotlin, you can easily implement this withdata
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
and2/4
are different. - If it's a model for calculation: It should return
true
. For example, the result of1/4 + 1/4
should be "the same" as1/2
.
Summary
Clarify whether equals
indicates identity or equivalence.
Keywords: identity
, equivalence
, equals