こんにちは。コミュニケーションアプリ「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
}
}
補足 3: 表現か、実際の値か、それが問題だ
ここまでの説明では、まるで等価性/同値性は当然に定義できるものであるかのように扱いました。しかし実際には、等価性/同値性は見方によっても変わるため、注意深く定義する必要があります。有理数 Rational が以下のように定義されていることを想定しましょう。
class Rational(numerator: Int, denominator: UInt) {
...
}
numerator は符号付きの分子を示し、denominator が分母を示します。ここで、Rational(1, 2) (1/2) と Rational(2, 4) (2/4) の比較について考えます。
Rational(1, 2) == Rational(2, 4) // true or false?
この結果が true であるべきか、false であるべきかは Rational が何を示すかによって異なります。
- 表示のためのモデルの場合:
falseを返すべき。例え ば"1/2 + 2/4 = 1"を UI に出力する場合、1/2と2/4は別のもの。 - 計算のためのモデルの場合:
trueを返すべき。例えば、1/4 + 1/4の計算結果は1/2と「同じ」である必要がある。
一言まとめ
equals が同一性 (identity) と等価性/同値性 (equivalence) のどちらを示しているのかを明確にする
キーワード: identity, equivalence, equals