equalsをオーバーライドする場合に守るべき内容について、かなり詳細に記載しています。
記載内容
equalsのオーバライドが不要な場合
以下の条件のいずれかに該当する場合、equalsをオーバーライドしないのが正しい。
- クラスの個々のインスタンスが本質的に一意(Threadクラス等)
- 「論理的等価性」の検査を提供する必要が無い(正規表現のPattern等)
- スーパークラスでオーバーライドしており、その振る舞いで問題ない(AbstractSetとSet等)
- クラスがprivate、パッケージプライベートであり、equalsが呼ばれないことが確実
equalsのオーバライドが必要な場合
一般に、値クラス(Integer、Stringの様に、値を表現するクラス)の場合は、
equalsのオーバーライドが必要になる。
ただし、個々の値に対して1つのインスタンスしか存在しないクラスは
オーバーライド不要である。
(Objectのequalsメソッドが十分機能するため)
equalsメソッドの一般契約
equalsメソッドは、Objectクラスの使用に記載されている一般契約(反射性、対称性、推移性、一貫性、nullとの比較)に従う必要が有る。
(一般契約についての説明はObject.equals()の通りなのでここでは割愛)
反射性
この性質を意図せず破ってしまうことは少ない
対称性
この性質は、意図せずに破ってしまう可能性がある。
例として、大文字小文字を区別しない文字列クラスCaseInsensitiveStringを考えた場合、
equalsメソッドを以下の様に実装すると、対称性を破っている。
private final String s; // 内部保持する文字列
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
if (o instanceof String)
return s.equalsIgnoreCase((String) o);
return false;
}
この場合、以下の様になる
CaseInsensitiveString cis = new CaseInsensitiveString("Foo");
String s = "foo";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false 対称性を破っている
System.out.println(List.of(cis).contains(s)); // 対称性を破っているため、結果は不明。OpenJDKではたまたまfalseになる
この問題を解決するには、CaseInsensitiveStringのequalsメソッドからStringクラスとうまく機能しようとしている部分を取り除くしかない。
推移性
この性質は、スーパークラスに新たな値要素を付加するサブクラスを考えると、容易に破ってしまう。
Point(点)とそのサブクラスColorPoint(色付きの点)の比較を考えた場合に、
以下のコードで、ColorPoint.equalsが色を無視した比較を行うと、対称性は守っているが、推移性を破ってしまう。
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
if (!(o instanceof ColorPoint))
return o.equlas(this); // 色を無視した比較
// oはColorなので完全な比較
return super.equals(o) && ((ColorPoint) o).color == color;
}
対称性、推移性は以下の様になる。
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p1)); // true 対称性は守れている
System.out.println(p2.equals(p3)); // true
System.out.println(p1.equals(p3)); // false 推移性が守れていない
これは、オブジェクト指向言語における同値関係の基本的な問題であり、
インスタンス化可能なクラスを拡張して、equals契約を守ったまま値要素を追加する方法ない。
この問題の回避方法は項目18「継承よりもコンポジションを選ぶ」ことである。
ColorPointがPointを拡張するのをやめ、ColorPointにPoint、Colorのフィールドを持たせ、
以下の様な、ColorPointの位置のPointを返すビューメソッドを返すようにすれば良い。
public Point asPoint() {
return point;
}
整合性
この性質は、信頼性のない資源に依存するequalsメソッドを記載した場合に、破ってしまう可能性がある。
例として、java.net.URLクラスのequalsメソッドは、整合性を破っており、呼び出すたびに結果が異なる可能性がある。
これは、URLクラスのequalsメソッドは、URLに関連付けられたIPアドレスの比較に依存しており、
ホストのIPアドレスへの変換は常に成功するとは限らず、同じ結果になることも保証されないためである。
しかし、互換性を維持するために、この誤った振る舞いを変更することが出来ずにいる。
非null性(名前が無いために命名)
o.equals(null)==trueとなる、誤った実装が行われることは考えづらいが、
誤ってNullPointerExceptionがスローされることは考えられる。
一般契約では、引数がnullの場合にNullPointerExceptionのスローは認められていないため、falseを返す必要が有る。
考察
長い項目ですが、記載されている内容はequalsに関する基本的な内容であり、
特に一般契約については理解しておく必要が有ります。
equalsのオーバライド有無
実はequalsを実装するかで悩ましいのは、既存のクラスにequalsメソッドを追加してしまってよいか、
というケースだと思っています。
(この項目の記載とは少しずれますが・・・)
特に、既存のクラスでjUnitが不十分だった場合に、比較を行いたいクラス(主にメソッドの戻り値)にequalsを追加したくなりますが、
追加した場合に、他の処理に影響が無いか、というのが確認しづらいです。
(さらに、equalsを追加する場合はhashcodeも実装するわけで、問題ないかが不安になります・・・)
やはり、値クラスに対しては、使用有無に関係なく、equalsとhashcodeは初期から実装しておくべきと思います。
使用しないコードは記載するべきではない、という意見もあると思いますが、
意図せずObject.equalsが使用されているかもしれず、equalsを実装して良いか分からない、
という状況になるより良いはずです。
一般契約
一般契約で主に問題になるのは、継承と推移性の問題と思います。
やはり、値クラスは継承するべきではない、ということになると思います。
(継承用に定義した、abstractの値クラスの継承は除きますが)
さらに一般化して、継承用に定義した抽象クラス以外は、継承を行うべきではないということになると思います。
equalsの実装
現在は、IntelliJ、Eclipse等のIDEで、equalsメソッドとhashcodeメソッドをセットで生成してくれますので、
これに従うのが良いと思います。
もし、何らかの理由で特殊な比較を行う場合は、その旨をコメントで記載するべきです。
さらに、RecordがJavaに導入されれば、値クラスに対してequalsの記載が不要にあり、
equals、hashcodeに対して、決まり切ったコードを記載する煩わしさが減るはずです。