【Effective Java】項目8:equals をオーバーライドするときは一般契約に従う

第3章『すべてのオブジェクトに共通のメソッド』に入る。

第3章は、Object.equals()、Object.hashCode()、Object.toString()、Object.clone() について、いつ、どのようにオーバーライドするかを説明する。 finalize も同様のものだが、すでに項目7で議論しているので省略されている。 Comparable.compareTo は Object のメソッドではないが、同様の特徴を持っているのでここで議論する。

すべてのオブジェクトの基底クラスである Object は具象クラスであるが、主に拡張されるために設計されている。 その final でないメソッド(equals, hashCode, toString, clone, finalize)はすべてオーバーライドできるように設計されていおり、明示的な一般契約を持っている。

これらの一般契約を守ることはこれらのメソッドをオーバーライドするクラスの責任である。 これらの契約に従わないと、一般契約に依存して実装されている HashMap などの各種クラスが正しく動作しなくなる。

最初の項目は「equals をオーバーライドするときは一般契約に従う」である。

equals をオーバーライドする

equals はオーバーライドするのが難しく、よく問題を起こす。

問題を回避する最も簡単な方法は equals をオーバーライドしないことである。 下記の条件にあてはまる場合は、equals はオーバーライドしないほうがよい。

  • クラスの個々のインスタンスが本質的に一意
    • Thread などのような値というより能動的な実体を表している場合
  • 「論理的等価性」検査を、クラスが提供するかに関心がない(後述)
  • スーパークラスで equals が存在し、そちらの振る舞いが適切
    • 例えば各 Set 実装の equals は AbstractSet の実装が使われる(List, Map も同様の構造を持つ)
  • クラスが private またはパッケージプライベートで、クライアントから equals が呼び出されないことが保証されている
  • インスタンス制御され、オブジェクトが一つしか存在しないことが保証されている場合
    • たとえば enum 型はこれにあてはまる

では、どのような場合にオーバーライドするのか?

  • クラスがオブジェクトの同一性を超えた、「論理的等価性」の概念を持っている
  • スーパークラスが equals をオーバーライドしていない時

上記の条件を満たすクラスは、一般的には値クラスと呼ばれるクラスである場合が多い。

euqlas の一般契約

Object の equals をオーバーライドする場合に、厳守しなければならない一般契約の性質は以下の通りである。

  • 反射性(reflexive)
  • 対照性(symmetric)
  • 推移性(transitive)
  • 整合性(consistent)
  • 非 null 性(non-null)

反射性(reflexivity)

定義 null でない任意の参照値 x に対して、x.equals(x) は true を返さなければならい

この性質は、オブジェクトがそれ自身と等しくならなければならないことを要求する。 equals のオーバーライドにおいて、たいていの場合この性質は満たされるので、特に気にしなくてもよい。

対称性(symmetry)

定義 null でない任意の参照値 x と y に対して、y.equals(x) が true の場合のみ、x.equals(y) は true を返さなければならない

この性質は、2つのオブジェクトが、それらが等しいかどうかで合意する必要があることを示す。

大文字小文字を区別しない文字列のクラスである CaseInsensitiveString を例にすると、容易にこの条件を破る equals を実装できる。

public class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s) {
        if (s == null) {
            throw new NullPointerException();
        }
        this.s = 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;
    }
}

この例では、以下の様に x.equals(y) と y.equals(x) で真偽値が異なってしまう。 これは、String クラスの equals は CaseInsensitiveString に関しては何も知らないために起きる。

CaseInsensitiveString x = new CaseInsensitiveString("Hello");
String y = "hello";
x.equals(y); // => true
y.equals(x); // => false

もし、このクラスに対して List を生成した場合の挙動は完全に実装に依存し、仕様上の挙動は未定義となる。

List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>();
list.add(x);
list.contains(y); // => true ? false? 

推移性

定義 null でない任意の参照値 x, y, z に対して、x.equals(y) と y.equals(z) が true を返すなら、x.equals(z) は true を返さなければならない

1つ目と2つ目のオブジェクトが等しく、さらに2つ目と3つ目のオブジェクトが等しい場合は、1つ目と3つ目も等しくなければならない。

スーパークラスに新たな値要素を追加するようなクラスを考えてみる。 この場合、サブクラスでは equals で比較する要素が一つ増えるわけである。

例えば、単純な Point クラスを仮定し、さらにそれに色情報を付加する ColorPoint をサブクラスとして作成するとする。

public class Point {
    private final int x;
    private final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) {
            return false;
        }
        Point p = (Point) o;
        return p.x == this.x && p.y == this.y;
    }
}

public class ColorPoint extends Point {
    private Color color;
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
}

この場合、次のような ColorPoint に対して次のような equals を書くと対称性が守られない。 

@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) {
        return false;
    }
    return super.equals(o) && ((ColorPoint) o).color == color;
}

 

// 対称性が成り立たない
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
p1.equals(p2); // => false
p2.equals(p1); // => true

一方、次のように色を無視するように書くと、対称性は守られるが、推移性が守られない。

@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) {
        return false;
    }
    // o が普通のポイントなら、色を無視した比較をする
    if (!(o instanceof  ColorPoint)) {
        return o.equals(this);
    }
    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);
p1.equals(p2); // => true
p2.equals(p3); // => true
p1.equals(p3); // => false

この問題を解決する方法として instanceOf 検査の代わりに getClass() を用いる方法を耳にするが、その場合、リスコフの置換原則(項目39)を破ることになる。

リスコフの置換原則と equals

推移性を守るために以下の様に equals を実装したとする。

// ColorPoint の equals 
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) {
        return false;
    }
    return super.equals(o) && ((ColorPoint) o).color == color;
}

// Point の equals
@Override
public boolean equals(Object o) {
    if (o == null || o.getClass() != getClass()) {
        return false;
    }
    Point p = (Point) o;
    return p.x == this.x && p.y == this.y;
}

 

// 推移性が成り立つ
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
p1.equals(p2); // => false
p2.equals(p3); // => false
p1.equals(p3); // => false

この場合、オブジェクトの実装クラスが同じ場合にだけ equals が true を返すので推移性を満たすようになる。

しかし、Point と ColorPoint を HashSet に格納している場合に期待通りに動作しない。

HashSet は equals を使って実装されており、Point に対して ColorPoint の equals が false を返すようになるため、リスコフの置換原則を破ってしまう。

オブジェクト指向の抽象化を諦めないでインスタンス化可能なクラスを拡張し、equals 契約を守ったまま値要素を追加する方法はない。 インスタンス化可能クラスを拡張して値を追加するよい方法は、拡張の代わりにコンポジションを持ちいて view メソッドを利用する方法である。

一方、インスタンス化可能クラスのサブクラスで、かつ値要素を追加しているクラスが Java のライブラリにある。 java.sql.Timestamp は java.util.Date のサブクラスで nanoseconds フィールドを追加している。 Timestamp の equals 実装は対称性を守っておらず、Timestamp オブジェクトと Date オブジェクトが同一のコレクションで利用されると一貫性のない振る舞いを引き起こす。 これはよくないケースの見本である。

インスタンス化可能クラスとは異なり、抽象クラスのサブクラスは equals 契約を破ることなく値を追加できる。 上記で示した問題は、スーパークラスインスタンスが生成されない限り発生しない。

整合性(consistency)

定義 null でない任意の参照値 x と y に対して、オブジェクトに対する equals 比較に使用される情報が変更されなければ、x.equals(y) の複数回呼び出しは、終始一貫して true を返すか、終始一貫して false を返さなければならない。

この性質は2つのオブジェクトが変更されない限り、いつまでも等しくなければならい事を示している。

一般的に、信頼できない資源に依存する equals を書いてはならない。 そのような場合はこの整合性を守ることが非常に難しくなる。

java.net.URL の equals はホストを IP アドレスに変換する必要があるため、ネットワークアクセスが必要となる。 そのため、オブジェクトが変更されていないにも関わらず、常に一致することが保証できません。 これはよくない見本であり、実際に問題を引き起こしている。

非 null 性

定義 null でない任意の参照値 x に対して、x.equals(null) は false を返さなければならない

すべてのオブジェクトは null と等しくなってはならない。

equals は NullPointerException がスローすることを認めていないので、equals では null チェックが必要である。 ただし、instanceof 演算子は第1オペランドが null の場合は必ず false を返すことが定められているため、明示的に null をチェックする必要はない。

equals メソッドの書き方

上記の項目をすべてまとめると次の通りです。

  • 引数が自分自身のオブェクトであるかどうかを検査するために == を利用する
    • 必須ではありませんが、比較のコストを低減することができる
  • 引数が正しい型であるかを調べるために instanceof を使う。
    • getClass() を使うとリスコフの置換原則を満たすことができなくなる。
  • 引数を正しい型にキャストする
    • instanceof によって型が検査されているので、例外は発生しない
  • そのインスタンスの意味のあるフィールドと、与えられたオブジェクトのフィールドを検査します
    • 基本データ型には == を、オブジェクトには equals を利用する。
    • ただし、float と double は Float.compare と Double.compare を利用する。
    • 配列のフィールドは Array.equals が利用できる。
  • equals を書き終えた後に「対称性」、「推移性」、「整合性」の三つの性質を満たしたかどうかを自問し、テストを書く
    • もちろん、「反射性」と「非 null 性」を満たす必要もあるが、この二つは心配しなくても大抵の場合満たされる。

これらに加えて、一般的な原則としては次の通りです。

  • equals をオーバーライドするときは常に hashCode をオーバーライドする(項目9)
  • あまりにかしこくなろうとしない
  • 直接、使う側がフィールドをテストすればいい場合が多い。
  • 例えば同じシンボリックリンクを保持する File オブジェクトの論理的等価性を期待しない。
  • equals の引数は Object 型。
  • equals の引数を Object 以外にするとオーバーロードになる
  • これを防ぐために @Override アノテーションをつける

感想

長かった~。 そうえいば、Scala のコップ本にも equals を実装する章があって、リスコフの置換原則にも言及してたけど、原著で読んでたからいまいち理解できなかったの思い出した。 今回はちゃんと理解できた気がする。

IntelliJ IDEA だと equals を機械的に実装してくれるけど、ちゃんとなってるのかなーってちょっと調べたら「サブクラスを受け入れるか?」でオプションがあった。

□ Accept subclasses as parameter to equals() method

While generally incompliant to Object.equals() specification accepting subclass might be necessary for generated method to work correctly with frameworks, which generate Proxy subclass like Hibernate.

Subclass を受け入れると Object.equals() の対称性(か推移性)が守れなくなるけど、Framework 上必要な場合があるよってことだね。

// サブクラス受け入れチェックなし
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    PhoneNumber that = (PhoneNumber) o;

    if (areaCode != that.areaCode) return false;
    if (prefix != that.prefix) return false;
    return lineNumber == that.lineNumber;

}

//サブクラス受け入れチェックあり
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof PhoneNumber)) return false;

    PhoneNumber that = (PhoneNumber) o;

    if (areaCode != that.areaCode) return false;
    if (prefix != that.prefix) return false;
    return lineNumber == that.lineNumber;

}

instanceof か getClass かで null チェックがあったりなかったりするのは、さすがちゃんとしてるなと思った。自分でやってたら実装ミスりそうだわ。