LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック: 第 22 回(To equal, or not to equal)

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 22 回です。Weekly Report については、第 1 回の記事を参照してください。

To equal, or not to equal

Java や Kotlin では、equals をオーバーライドすることで「構造の等価性 (structural equality)」を定義することができます。(ただし、equals をオーバーライドするときは、hashCode もオーバーライドする必要があります。)

以下のような、ユーザプロファイルの UI のデータモデルがあるとしましょう。このデータモデルでは、独自の equalshashCode を定義しています。

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 なら出力するという挙動です。したがって、nicknamestatusMessage が更新されたとしてもuserId が等しいならば equalstrue となるため、UI が更新されません。これは、nickname を更新しても画面に反映されないというバグを引き起こします。

equals (equality) の定義は、以下の 2 つのどちらかであるべきです。

  • 同一性 (identity): 2 つのオブジェクト(やその参照)が同一であるときに true。Java や Kotlin の場合は ObjectAnyequals をそのまま使う。
  • 等価性/同値性 (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 として保持します。この cachedValueequals で比較されていません。しかし、キャッシュの有無による振る舞いの差を無視できる場合(計算時間の差などを無視して良い場合など)はこの実装で問題ないでしょう。

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/22/4 は別のもの。
  • 計算のためのモデルの場合: true を返すべき。例えば、1/4 + 1/4 の計算結果は 1/2 と「同じ」である必要がある。

一言まとめ: equals が同一性 (identity) と等価性/同値性 (equivalence) のどちらを示しているのかを明確にする

キーワード: identity, equivalence, equals