Kotlinにおけるオブジェクト比較、ちゃんと説明できますか?という話題です。
KotlinではJavaほどオブジェクト比較について意識しなくてもよいこともあり、基本的なことではありますが、意外と曖昧な理解のままで、厳密な動作を説明するように言われると戸惑ってしまう人も多いのではないでしょうか?
参照比較とオブジェクト比較
Javaにおいてオブジェクトの==は参照比較です。参照比較とは同一のインスタンスであるか?という評価です。内容の比較は行われません。
String a = "a";
String b = "A".toLowerCase();
System.out.println(a == b);
a、bともに内容はaというStringですが、結果はfalseとなります。内容は同一ですが、インスタンスが異なるからです。
内容を比較したければequalsメソッドを呼び出します。
System.out.println(a.equals(b));
Kotlinにおいてオブジェクトの==はequals()メソッドによる比較となります。
val a = "a"
val b = "A".lowercase()
println(a == b)
これはtrueとなります。
Kotlinにおいて参照比較が必要な場合は=を3つ連続させた===演算子を使います。
println(a === b)
こうするとfalseとなります。
プリミティブ型の比較と同じ感覚で==を使ってしまい内容を比較しているつもりになっていた。というのはJavaの初歩的なミスとしてよくありました。==をequals()による比較としたKotlinの仕様は合理的と言えるでしょう。
Kotlinにおける==はequals()による比較になるだけではなく、オブジェクトがNullableであってもNull安全な比較ができるというメリットがあります。
Javaでは、a、bがNullableである場合、以下のような比較を行う必要があります。
(a == b) || (a != null && a.equals(b))
Kotlinではユーティリティ不要で、==演算子だけで同等の動作が実装できます。
equals() は何を比較している?
equals()メソッドは何を比較しているのでしょうか?
内容を比較する?本当ですか?そして「内容」とは何でしょうか?
答えは クラスによって異なる より正しくは equals()メソッドの実装次第 です。
SDKなどで用意されているクラスの equals()メソッドは利用者の期待を裏切らないように実装されています。そのため厳密な動作を知らなくても問題なく使えているでしょう。しかし、状況によっては厳密な動作を知っていないと予期しない不具合に遭遇することもあります。時にはこのクラスのequals()メソッドは何をもって等しいと判断しているのか?と疑問を持ち、実装を調べるクセを付けるのが良いと思います。
例題
比較の厳密な実装?そんなの簡単だよ。って思いますか?
では、以下の結果はどうなるでしょう?
val a = listOf(1, 2)
val b = listOf(2, 1)
println(a == b)
要素は同一ですが順序が異なりますね。falseです。
では、以下はどうでしょう?
val a = ArrayList<Int>(listOf(1, 2))
val b = LinkedList<Int>(listOf(1, 2))
println(a == b)
内容の順序も同一ですが、Listとしての実装が異なります。別クラスなのでfalseでしょうか?
答えは true です。
Listの実装が異なっていても内容の比較となります。
続いて、以下はどうでしょう?
val a = setOf(1, 2)
val b = setOf(2, 1)
println(a == b)
Setは順序を持っていないから順序は関係なく比較される?
KotlinのsetOf(の現在の実装であるLinkedHashSet)は順序を保持する仕様になっていますよ?
答えはtrueです。
順序を保持してはいるもののequals()の比較では無視されます。
最後に、以下はどうでしょう?
val a = arrayOf(1, 2)
val b = arrayOf(1, 2)
println(a == b)
今度は配列(Array)です。Listと同様に内容比較が行われるでしょうか?
答えはfalseです。
配列のequals()は参照比較となります。
配列の内容を比較したい場合、java.util.Arraysを使ってArrays.equals(a, b)とするか、拡張関数contentEqualsを使ってa.contentEquals(b)のように比較を行う必要があります。
(内容は同一ですがKotlinにおいては拡張関数の利用が推奨されています)
どうでしょう。一切迷いなく答えられたでしょうか?
あれ?どっちだっけ?と少し迷ったりしませんでしたか?
日常的に使っているこれらクラスでも迷ったのであれば、より利用頻度の低いクラス、特定目的で使っているライブラリのクラス、どうなっているでしょう?
equals()の厳密な動作は実装を見てみないとわからない、ということ理解していただけましたでしょうか?
参考までに例題のequals()の実装を見てみましょう。
例題の実装では
listOf()の戻り値は、java.util.Arrays.ArrayList(java.util.ArrayListとは別の実装)
setOf()の戻り値は、java.util.LinkedHashSet
のインスタンスとなります。
(あくまで現時点の実装としてそうなっているのであって、listOf/setOfの仕様ではありませんので誤解なきよう)
Arrays.ArrayList、ArrayList、LinkedList、いずれもequals()はAbstractListで実装されたものが使われます。
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof List))
return false;
ListIterator<E> e1 = listIterator();
ListIterator<?> e2 = ((List<?>) o).listIterator();
while (e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
return !(e1.hasNext() || e2.hasNext());
}
同一インスタンスであれば内容を見るまでもなくtrue。Listインターフェイスを実装していなければfalseと即座に判定できるところを確認した後、要素をひとつひとつ比較しています。
Listの詳細な実装には関係なく、要素と順序が一致していれば同一と判定していますね。そのため、ArrayListとLinkedListの間であっても内容が一致していればtrueとなっていたわけです。
続いてSetです。LinkedHashSetのequals()の実装はAbstractSetで実装されたものが使われています。
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Set))
return false;
Collection<?> c = (Collection<?>) o;
if (c.size() != size())
return false;
try {
return containsAll(c);
} catch (ClassCastException | NullPointerException unused) {
return false;
}
}
前半はListと同様に参照比較、Setインターフェイスを実装しているかをチェック。その後は、サイズ比較。サイズも同一ならcontainsAll()を使って、すべての要素が順序に関係なく含まれるかで判定しています。
こちらについても、Setというインターフェイスとして「順序に関係なく要素が同一であるか」という比較です。Setの実装が異なっていたとしても内容が同一であればtrueが返ります。
さらに言えば要素間の比較は要素クラスのequals()を使って比較しているので、何をもって同一の要素とするかは要素のクラスによって異なります。
いかがでしょう?
たかがオブジェクト間の比較といえど、厳密に見ていくと興味深いですよね。
自作クラスではequalsを適切に実装すべし
以下のようなクラスを作ったとしましょう。
class A(
val a: Int,
val b: Int,
)
以下の結果はどうなるでしょうか?
val a = A(1, 2)
val b = A(1, 2)
println(a == b)
答えはfalseです。
equals()をオーバーライドしていない場合、equals()は参照比較を行います。
ただし、data classの場合は、要素を比較するequals()メソッドが自動的に生成されるため、内容比較となります。以下のようにdata classに変更した場合
data class A(
val a: Int,
val b: Int,
)
さきほどの結果はtrueとなります。
これは、data classのKotlinコードをJavaにデコンパイルするとわかります。
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
} else if (!(other instanceof A)) {
return false;
} else {
A var2 = (A)other;
if (this.a != var2.a) {
return false;
} else {
return this.b == var2.b;
}
}
}
内容を比較するequals()メソッドが自動的に作成されています。
data classでは、プライマリコンストラクターのフィールドが比較されるようにequals()が生成されます。クラスボディに配置したフィールドは比較されません。
クラスを自作する場合、equalsの実装はクラス実装者の責任です。
もちろん、「比較は一切行わない」と断言できるのであれば実装しなくてもよいのですが、後の拡張で比較処理の実装が必要になり、equals()メソッドが実装されていなかったら……トラブルが発生してしまいます。
Kotlinではdata classとして実装しておけば(万能ではないのものの)最低限の要素を評価するequals()メソッドが自動的に生成されます。
データを保持するクラスであれば、data classとして実装しておくのが良いでしょう。
equalsをオーバーライドしたならhashCodeもオーバーライドし適切に実装すべし
Javaプログラマのバイブル「Effective Java」にも「equalsをオーバーライドする時は、常にhashCodeをオーバーライドする」と書かれています。これはKotlinにおいても同様です。
equals()を実装した場合は、必ずhashCode()も適切に実装しなければなりません。
hashCode()には以下の一般契約があります。
- 【必須】
equals()の判定に使われる値が変わらなければ、常に同じ値を返す - 【必須】
equals()がtrueなら、必ず同じ値を返す - 【推奨】
equals()がfalseなら、可能な限り異なる値を返す(衝突可能性が小さいほど性能面で有利)
より詳しくは、Object.hashCode()のドキュメントを参照してください。
equals()に対してhashCode()の振る舞いが求められますので、equals()をオーバーライドして実装するなら、それに対応したhashCode()の実装が合わせて必要となります。hashCode()の契約に従わないhashCode()の実装をしてしまった場合、HashMapなどhashCode()を利用する仕組みが正しく動かなくなってしまいます。
equals()とhashCode()の実装方法
equals()とhashCode()を実装しろと言われても、何も指針がないと難しいですよね。
基本的な実装方法を説明します。
以下のようなクラスを考えます。
class B(
val a: String,
val b: String,
)
これに対して、equals()は以下のよう実装するのが定石です。
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is B) return false
return a == other.a && b == other.b
}
参照比較を行い、同一であればtrue
型比較を行い、必要なインターフェイスが実装されていなければfalse
その後に、各要素の比較を行います。
型比較other !is BはBのサブクラスを許容しています。厳密な対象性が必要な場合は、javaClass != other?.javaClassのようにサブクラスを除外した比較を行います。
hashCodeは以下のように実装するのが定石です。
override fun hashCode(): Int {
var result = a.hashCode()
result = 31 * result + b.hashCode()
return result
}
各要素のhashCode()を算出し、31倍しながら各要素を足し合わせます。
初見ではなぜ31?と思われるかもしれません。まず、係数を掛けているのは順序性を持たせる効果があります。31が選ばれたのは経験則的なところが大きいですが、ビットが多く立った奇数、かつ、シフト演算で最適化されやすいメルセンヌ数(2^x-1)の中で大きすぎず小さすぎない数として31が採用されています。ちなみに31は素数でもありますね。
この方法はaとbが異なるものとして扱う場合にのみ適切です。
Setのように順序は関係なく含まれる要素が同一かどうかで判定したい場合、係数を掛けていると、順序が異なると異なる値になってしまうため都合がよくありません。
その場合は順序が変わっても同じ値となるように、単純にすべてのhashCode()を足し合わせるなどの方法が使われます。
さて、定石通りの実装で良いのであればdata classを使えばよいです。
どういう場合にequals()の実装を自作するか、の一例を紹介します。
以下のように、配列データを保持するclassを考えます。
class C(
val name: String,
val data: ByteArray,
)
これをdata classにした場合、equals()は期待通りに動作しません。(Android StudioやIntelliJ IDEならequals()とhashCode()をオーバーライドするようにと警告がでます)
例題で示したように、配列のequals()比較は参照比較となります。
たとえば、内容がまったく同じコピーを作成した場合、dataは別インスタンスとなりequals()比較はfalseを返します。これは期待通りの動作とは言えないでしょう。
これを解消するには、以下のように配列部分の比較を適切な方法に置き換えた自作のequals()メソッドの実装が必要になります。
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is C) return false
return name == other.name && data.contentEquals(other.data)
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
実はdata classのhashCode()は(equals()と違って)配列を考慮したものが生成されたりするのですが、equals()をoverrideしたならhashCode()も実装せよというルール通り、overrideしたほうが良いでしょう。
配列についてはcontentHashCodeを使って、内容を反映したhashCodeを取得するよう実装します。
その他、
- 比較するうえで特定のフィールドは値の比較に使いたくない
- 取得時刻を保持しているが、取得時刻が異なってもそれ以外の値が同一であれば同一として扱いたい、など
- 表現の仕方が異なるが、表現しているもとのデータが同一であれば、同一と判定したい
- 1/2と2/4は同一の値として扱いたい、など
定石通りの比較では所望の結果が得られないシチュエーションはさまざま考えられます。
そのデータの特性やユースケースに合わせてequals()を実装し、それに対応するhashCode()も一般契約を守れるように実装を行いましょう。
以上です。