Effective Javaを勉強します【第3章】

Objectクラスのequals、hashCode、toString、clone、finalizeはすべて オーバーライドされるように設計されているため、明示的な一般契約がある。

これに従わないと、この契約に依存しているHashMapやHashSetなど他のクラスが 適切に機能しない恐れがあるため、いつ、どのようにオーバーライドすべきか学びましょうと いう話。 (ComparableはObjectではないが同様の特徴をもつため、本章で扱う)

項目8. equalsをオーバーライドする時は一般契約に従う

equals()をオーバーライドすべきでない場合

以下の条件のいずれかに当てはまる場合は、equals()をオーバーライドしない方が適切。

  • クラスの個々のインスタンスは本質的に一意である
    • Threadなどのように、値よりは能動的な実態を表しているクラスの場合
    • Objectクラスの提供するequals()の振る舞いが正しい
      • Objectの提供するequals()の実装は以下になってる。参照が同じである=等しいと判断される。
public boolean equals(Object obj) {
    return (this == obj);
}
  • 「論理的等価性」検査をクラスが提供するかどうかに関心がない

    • 例えばRandomインスタンスがこれから同じ乱数列を生成するか検査をする必要はない
    • この場合もObjectの提供するequals()の振る舞いが正しい
  • スーパークラスがすでにequalsをオーバーライドしており、スーパークラスの振る舞いがこのクラスに対して適切である

    • 例えばSetならAbstractSet、ListならAbstractList、MapならAbstractMap
  • クラスがprivateあるいはパッケージプライベートであり、そのequalsメソッドが決して呼び出されないことが確かである

    • 万が一呼び出された場合に備えて、以下のように例外をスローするようオーバーライドする
@Override public boolean equals (Object obj) {
    throw new AssertionError();
}

equals()をオーバーライドすべき場合

クラスが論理的等価性という概念を持っていて、スーパークラスequals()をオーバーライドしていないとき。

  • 論理的等価性=論理的に同値であるのかを知りたい
    • オブジェクトの同一性(参照先が同じ)を確かめたいのではなく、持つ値が正しいのかを知りたい

equalsをオーバーライドする時は、以下の一般契約を厳守しなければならない

反射性

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

  • オブジェクトがそれ自身と等しくなければならないということ
  • これを意図的に破ることは難しい

対称性

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

以下の例のように簡単に破ることが可能なので注意。

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)
            // 引数がStringの場合、大文字小文字を無視して比較しているが
            // Stringクラスでは無視しないため対称性が成り立たない
            return s.equalsIgnoreCase((String) o);
        return false;
    }

    // 正しい例
    @Override
    public boolean equals(Object o) {
        // クラスが異なる場合はfalseを返すのが正解(Stringは比較しない)
        return o instanceof CaseInsensitiveString &&
            ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    }
}

推移性

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

  • インスタンス化可能なあるクラスを拡張して、equals契約を守ったまま値要素を追加したサブクラスを実装する方法はないので注意
    • あるクラスに値要素を追加したい時は、継承ではなくコンポジションを用いるべきである(項目16)

例) 2次元座標を保持したPointクラスと、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をオーバーライドしなければ、ColorPointのequals比較では色情報が無視されてしまうため、 色情報も一致したときにtrueを返すよう以下のように実装することを考える。

// ColorPointの誤ったequals()の例1
// 対称性が守られていない
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) {
        return false;
    }
    return super.equals(o) && ((ColorPoint) o).color == color;
}

これは以下のように比較を行った場合、対称性が守られないため不適切。

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp); // => true(色情報が無視される)
cp.equals(p); // => false

cp.equals(p)がtrueを返すよう、以下のようにColorPointクラスとPointクラスの比較の際は色情報を無視するよう実装すると、推移性が守られない。

// ColorPointの誤ったequals()の例1
// 推移性が守られていない
@Override
public boolean equals(Object o) {
    if (!(o instanceof Point)) {
        return false;
    }
    // oがPointクラスの場合は、Pointクラスのequals()で色情報を無視した比較を行う
    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

以下のようにコンポジションを用いることで、equals契約を守ったままPointクラスに色情報を付加できる。 (ColorPointクラスはPointのサブクラスではないためcp.equals(p)p.equals(cp)もfalseになる)

public class ColorPoint {
    private Point point;
    private Color color;
    public ColorPoint(int x, int y, Color color) {
        if (color == null) {
            throw new NullPointerException();
        }
        point = new Point(x, y);
        this.color = color;
    }
    // ビューを返す
    public Point asPoint() {
        return point;
    }
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) {
            return false;
        }
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

ただし抽象クラスのサブクラスには、equals契約を守ったまま値要素の追加が可能。 (スーパークラスインスタンス化されないため、サブクラスと比較されて推移性に違反することがないため)

整合性

nullでない任意の参照値xとyに対して、オブジェクトに対するequals比較に使用される情報が変更されなければ、 x.equals(y)は常に同じ値を返し続けなければならない。

  • 信頼できない資源に依存するequalsメソッドを書いてはならない
    • 例) java.net.URLのequalsメソッドは、比較対象URLに関連づけられたホストのIPに依存している
      • ホストをIPに変換する時ネットワークへのアクセスが必要であり、常に同じ結果が保証されないため不適切
    • メモリ中のオブジェクトのみに依存する処理を行うべき

非null性

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

  • nullが渡されたときにNullPointerExceptionがスローされるのは契約違反なので注意(falseを返すようにする)

高品質なequalsメソッドの書き方

  1. 引数が自分自身のオブェクトであるかどうかを検査する場合は == を利用する
  2. 引数が正しい型であるかを調べるためには instanceof を使う。
    • 正しい型とは、普通はメソッドが定義されているクラスである。場合によっては、このクラスによって実装されているインターフェース。
  3. 引数を正しい型にキャストする
  4. そのインスタンスのフィールドと、与えられたオブジェクトのフィールドを検査する
    • 基本データ型には==を、オブジェクトには再帰的にequalsを用いる
    • ただし、floatとdoubleにはFloat.compareDouble.compareを利用する。
    • 配列のフィールドはArray.equalsが利用できる。
  5. 「対称性」、「推移性」、「整合性」の性質を満たしたかどうかのテストを書く
    • 「反射性」と「非 null 性」は心配しなくても大抵の場合満たされる。

項目9. equalsをオーバーライドする時は、常にhashCodeをオーバライドする

equalsをオーバーライドしているすべてのクラスで、hashCodeをオーバーライドしなければならない。

  • 上記を守っていないクラスがHashmap、HashSet、Hashtableを含む、すべてのハッシュに基づくコレクションで用いられると適切に機能しない

守らなければならない契約は以下。

  1. equals比較で使用されるオブジェクトの情報が変更されない限り、hashCodeメソッドは同じオブジェクトに対して常に同じ整数を返さなければならない
  2. 2つのオブジェクトがequals比較により等しければ、2つのオブジェクトの個々に体するhashCodeメソッド呼び出しは、同じ整数を返さなければならない
  3. 2つのオブジェクトがequals比較により等しくない場合、2つのオブジェクトの個々に体するhashCodeメソッド呼び出しが別の整数を返さなければならない、ということは要求されない。しかし別の整数を返す場合、ハッシュテーブルのパフォーマンスが改善される可能性がある。

hashCodeをオーバーライドするのを忘れた場合、上記2が破られる。

例) hashCodeをオーバーライドしていないPhoneNumberClass

public class PhoneNumber {
    private final short areaCode;
    private final short prefix;
    private final short lineNumber;

    public PhoneNumber(int areaCode, int prefix, int lineNumber) {
        rangeCheck(areaCode, 999, "areaCode");
        rangeCheck(prefix, 999, "prefix");
        rangeCheck(lineNumber, 9999, "line number");
        this.areaCode = (short) areaCode;
        this.prefix = (short) prefix;
        this.lineNumber = (short) lineNumber;
    }

    private static void rangeCheck(int arg, int max, String name) {
        if (arg < 0 || max < arg) {
            throw new IllegalArgumentException(name + ": " + arg);
        }
    }

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;

        PhoneNumber phoneNumber = (PhoneNumber) o;
        return phoneNumber.lineNumber == this.lineNumber
                && phoneNumber.prefix == prefix
                && phoneNumber.areaCode == areaCode;
    }

    // hashCode がない!
}

putgetで利用される2つのPhoneNumberクラスのハッシュコードが一致しないため、下記のようにHashMapが正しく動作しない。

Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");
m.get(new PhoneNumber(707, 867, 5309)); // => null

以下の手順に従うことで、理想的なハッシュ関数を計算することが可能。

  • 何らかのゼロでない定数、例えば 17 をresultという int 変数に保存する。
  • オブジェクト内のequalsメソッドで考慮されるフィールドfに対して以下のことを行う。
    • 各フィールドに対する intのハッシュコードcを計算する。
      1. boolean ならば、(f ? 0: 1)を計算する。
      2. byte, char, short, int ならば(int) fを計算する。
      3. long ならば、(int)(field ^ (f >>> 32))を計算する。
      4. float ならば、Float.floatToIntBits(f)を計算する。
      5. double ならばDouble.doublToLongBits(f)を計算し、結果の long を6と同様にハッシュする。
      6. オブジェクト参照で、equals内でフィールドをequals再帰的呼び出しによって比較しているならば、再帰的にhashCodeを呼び出す。フィールドがnullなら0を返す。
      7. 配列ならば各要素を別々のフィールドとして取り扱い、各意味のある要素に対して再帰的にこれらの規則を適用してハッシュを計算する。要素全てに意味がある場合、Arrays.hashCodeメソッドの利用が可能。
    • 上記で計算したcresult = 31 * result + c;の式に代入する。
  • resultを返す。
  • 等しいインスタンスが等しいハッシュコードを持つかどうかを自問し、単体テストを書く。

もしクラスが不変でハッシュコードを計算するコストが高いならば、オブジェクト内にハッシュコードをキャッシュしておくことを検討すべきである。

もしそのクラスのオブジェクトのほとんどがハッシュキーとして利用される場合、以下のいずれかの対策をすべきである。

  • インスタンス生成時にハッシュコードを計算する
  • 最初にhashCodeが呼び出された時に、遅延初期化をする(項目71)

項目10. toStringを常にオーバーライドする

toStringメソッドのデフォルト実装は、"クラス名@ハッシュコードの符号なし16進数表現"の文字列を返す。

→ 分かりづらいので、オーバーライドしてオブジェクトに含まれる有益な(ユーザーが興味があるであろう)情報を全て返すようにすべき。

toStringをオーバーライドする時に考慮すべき点として以下が挙げられる。

  • ドキュメンテーションjavadoc)で戻り値の形式を明示するかどうか
    • 明示することでユーザーに正確な表現形式を伝えることができる
    • 一方で、明示した表現形式に依存した利用がされている可能性を考えると、その後の仕様変更がしづらくなるというデメリットもある
  • 明示する、しないに関わらず、その意図は正確にjavadocに記載すべき
  • toStringの戻り値に含まれる全ての情報への、プログラミングによるアクセス手段を提供すべき

項目11. cloneを注意してオーバーライドする

cloneの一般契約は以下の通り。

  • 「コピー」の正確な意味はオブジェクトのクラスに依存する
  • 大まかな意図は以下が成り立つことだが、絶対的な要件ではない
    • 任意のオブジェクト x に対してx.clone() != xが true であること
    • またx.clone().getClass() == x.getClass()も true であること
    • x.clone().equals(x)が true であること
    • そのクラスの新たなインスタンスを生成することに加え、内部データ構造のコピーも必要な場合がある
    • コンストラクタは呼び出されない

cloneメソッドをオーバーライドするならば、super.cloneにより得られたオブジェクトを返すべき

以下の例を考える。

public class Data1 implements Cloneable {
    ...
    @Override
    public Object clone() throws CloneNotSupportedException {
        return new Data1(x);
    }
    ...
}
 
public class Data2 extends Data1 {
    ...
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    ...
}

上記の場合、Data2.cloneを呼び出すとsuper.cloneによりData1のコンストラクタが返却される。 したがって「x.clone().getClass() == x.getClass()も true であること」が成り立たない。 これはData1クラスでも同様に、clonesuper.cloneを返すようにすることで解決する。

また上記でcloneメソッドのアクセス修飾子が public となっていることも大切である。 本書に書いてある通り、Cloneableを実装しているクラスは、適切に機能している public のcloneメソッドを提供することが期待されている。

これらを踏まえて、java1.5からは下記のようにcloneメソッドを修正することができる。

public class Data1 implements Cloneable {
    ...
    @Override
    public Data1 clone() {
        try {
            return (Data1)super.clone();
        } catch(CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    ...
}
 
public class Data2 extends Data1 {
    ...
    @Override
    public Data2 clone() {
        try {
            return (Data2)super.clone();
        } catch(CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    ...
}

上記の修正したcloneメソッドにおいて、注目すべき点が3点ある。

  • 修正したcloneの戻り値型がData1/Data2になっている
    • java1.5から導入された共変戻り値型によるもの(オーバーライドしたメソッドの戻り値型は元のメソッドの戻り値型のサブタイプであってもよい)
  • super.cloneの戻り値は親クラスであるため、キャストを忘れずに行う(キャストはクライアントでなくライブラリーですべき処理)
  • CloneNotSupportedExceptionのスロー宣言が削除されている
    • チェックされる例外をスローしない方が使い勝手が良い(publicのcloneメソッドはスローしないべき)
    • 継承されることを想定したクラスでcloneをオーバーライドする場合、cloneを protected と宣言し、CloneNotSupportedExceptionのスロー宣言をし、Cloneableは実装しないようにする(Object.cloneをまねるべき)

フィールドに可変オブジェクトが含まれる場合は、再帰的にcloneを呼び出す

下記の例を考える。

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
    public Stack() {
        this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }
    @Override
    public Stack clone() {
        try {
            Stack result = (Stack) super.clone();
            result.elements = elements.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    ...
}

上記のcloneメソッド内のsuper.cloneでは、elementsフィールドの参照がコピーされるだけである。 (浅いコピー。元のオブジェクトとcloneオブジェクトで同一の配列が共有されてしまう。) したがってelements.clone()のように、再帰的にcloneを呼び出す必要がある。

しかしelementsフィールドがfinalの場合、新たな値を代入することができないため正しく機能しない。 この場合は、フィールドのfinal修飾子を取り除く必要がある。(cloneアーキテクチャは可変オブジェクトを参照している final のフィールドと両立しない)

内部クラスを保持するなど、クラスの構造が複雑な場合、上記のように再帰的にcloneを呼び出すだけでは不十分な場合もある。 その時はsuper.cloneを呼び出し、返されたオブジェクトのすべてのフィールドを初期化した上で、オブジェクトの状態を再現するために 「深いコピー」を実現するための処理を呼び出すべき。

一般的に、クラスが基本データ型のフィールドか不変オブジェクトへの参照しか保持していない場合、上記のような「深いコピー」のための処理は不要。

cloneが本当に必要かよく検討する

これまで述べたようにcloneの実装は気をつけるべき点が多く複雑であり、本当に必要なケースは稀である。 (配列のコピー用途以外ではcloneを利用しないという人も多い)

以下のいずれかの手段で代替できないかよく検討する。

  • コピーコンストラクタかコピーファクトリーを提供する java public Yum(Yum yum); //コピーコンストラクタ public static Yum newInstance(Yum yum); //コピーファクトリー
  • そもそもオブジェクトの複製機能を提供しない

項目12. Comparableの実装を検討する

Comparableを実装することで、インスタンスが自然な順序を持っていることを明示する。

→アルファベット順、数値順、年代順など、明らかに自然な順序を持つ値クラスを書く場合、Comparableを実装しcompareToメソッドを提供する。

compareToメソッドの一般契約は以下。これを守らないと、compareToに依存しているTreeSetTreeMap、検索とソートアルゴリズムを含む CollectionsArraysが正しく機能しない。

sgn(expression)は、expressionが負の場合 -1、ゼロの場合 0、正の場合 1を返す関数)

  • すべての x と y に関してsgn(x.compareTo(y)) == -sgn(y.compareTo(x))を保証しなければならない
    • x.compareTo(y)が例外をスローする( y のクラスは x と比較できない)場合のみ、y.compareTo(x)は例外をスローしなければならない
  • (x.compareTo(y) > 0 && y.compareTo(z) > 0)がtrueならばx.compareTo(z) > 0もtrueでなければならない
  • すべての z に関してx.compareTo(y) == 0ならばsgn(x.compareTo(z)) == sgn(y.compareTo(z))でなければならない
  • (x.compareTo(y) == 0) == x.equals(y)は強く推奨されるが必須ではない(破る場合は明示すべき)

上記制限はequals制限と同じく、反射性、対称性、推移性に従わなければならないことを示している。 したがってequalsと同様に、インスタンス化可能なクラスを拡張してcompareTo契約を守ったまま新たな値要素を追加する方法はなく、コンポジションが推奨される。

compareToは以下にしたがって実装すると良い。

  • 基本データ型フィールドは関係演算子><で比較する
  • 浮動小数点の比較ではDouble.compareFloat.compareを使用する
  • 配列は個々の要素に対して上記を適用する
  • 比較するフィールドが複数ある場合、最も意味のあるフィールドから順に比較を行う
    • 例えば電話番号の場合、市外局番を先に比較すれば、残りの番号の比較が不要となる可能性がある