こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 22 回です。Weekly Report については、第 1 回の記事を参照してください。
To equal, or not to equal
Java や Kotlin では、equals
をオーバーライドすることで「構造の等価性 (structural equality)」を定義することができます。(ただし、equals
をオーバーライドするときは、hashCode
もオーバーライドする必要があります。)
以下のような、ユーザプロファイルの UI のデータモデルがあるとしましょう。このデータモデルでは、独自の equals
と 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
}
}
このコードになにか問題点はありますか?
オレンジもりんごも同じ「フルーツ」なので同じ「もの」?
一般論として、equals
で一部のプロパティだけを比較するのは避けるべきです。equals
(equality) は通常、「同一性 (identity, referential equality, same object)」か「等価性/同値性 (equivalence)」のどちらかを示すべきです。
先程の UserProfileViewData.equals
の実装が示しているものは、同一性でも等価性でもありません。そのため、思わぬバグを引き起こす可能性があります。
UserProfileViewData
の情報が変わる度に、UI も更新することを想定しましょう。UserProfileViewData
のインスタンスは、以下の ような “observable” なものとして提供されるとします。
- Kotlin コルーチンの
StateFlow
- Kotlin コルーチンの
Flow
(distinctUntilChanged
付き) - Rx の
Observable
(distinctUntilChanged
付き) - Android の
LiveData
(distinctUntilChanged
付き)
これら 4 つの “observable” は、UserProfileViewData
が変わる度にそのインスタンスを出力するのですが、「UserProfileViewData
が変わったか」の判定に equals
を使います。つまり、1 つ前のインスタンスと equals
で比較をして true
ならば無視し、false
なら出力するという挙動です。したがって、nickname
や statusMessage
が更新されたとしてもuserId
が等しいならば equals
も true
となるため、UI が更新されません。これは、nickname
を更新しても画面に反映されないというバグを引き起こします。
equals
(equality) の定義は、以下の 2 つのどちらかであるべきです。
- 同一性 (identity): 2 つのオブジェクト(やその参照)が同一であるときに
true
。Java や Kotlin の場合はObject
やAny
のequals
をそのまま使う。 - 等価性/同値性 (equivalence): すべてのプロパティやフィールドが等価/同値の場合に
true
。すべてのプロパティやフィールドに等価/同値が定義されている必要がある。Kotlin の場合はdata
として簡単に実装できる。
もし、一部のプロパティだけを比較するような関数が必要なときは、equals
とは別の関数として定義するべきです。以下の例では、userId
のみを比較する関数を独自に定義しています。
class UserProfileViewData(
val userId: Int,
...
) {
fun hasSameIdWith(other: UserProfileViewData): Boolean =
userId == other.userId
...
}
補足 1: Kotlin の data class
Kotlin の data class
におけるデフォルトの equals
の実装は、コンストラクタパラメータのプロパティ以外は無視することに留意してください。
data class Data(val i: Int) {
var j: Int = 0
}
val data1 = Data(42)
val data2 = Data(42)
data2.j = 2
data1 == data2 //=> true!
補足 2: 例外ケース
等価性/同値性を定義する際に、例外的にいくつかのプロパティを無視することがあります。計算結果のキャッシュやメモは、その典型的な例です。以下の Factorial
は、n
の階乗を計算する求めるクラスで、一度計算した値を cachedValue
として保持します。この cachedValue
は equals
で比較されていません。しかし、キャッシュの有無による振る舞いの差を無視できる場合(計算時間の差などを無視して良い場合など)はこの実装で問題ないでしょう。
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
}
}