JPCERT コーディネーションセンター

MET08-J. equals() メソッドをオーバーライドする時は等価性に関する契約を守る

コンポジションや継承を使い、既存のクラスをカプセル化しかつ1つ以上のフィールドを追加したクラスを新規に作成することがある。このようにあるクラスが別のクラスを拡張する場合、拡張したクラスの2つのインスタンスが等価であるということに、追加されたフィールドが関係する場合もあれば、関係しない場合もある。つまり、2つのオブジェクトの等価性を評価する場合、新規に追記されたフィールドの値も等しくなければならないケースもあれば、そうでないケースもある。サブクラスの等価性によっては、サブクラスで equals() をオーバーライドする場合もある。equals() をオーバーライドする場合にはもちろん、Java言語仕様 [JLS 2005] で規定されている equals() の一般契約に従わなければならない。

オブジェクトはそのメモリ上の位置と状態(実データ)によって特徴づけることができる。== オペレータが比較するのは、2つのオブジェクトのメモリ位置のみである(参照が同じオブジェクトを指すかチェックする)。しかし、java.lang.Object で定義されている equals() メソッドをオーバーライドし、オブジェクトの状態を比較することもできる。あるクラスで equals() メソッドを定義している場合、状態も比較しているかもしれない。しかし、(クラス内で定義するあるいは親クラスから継承するなど)equals() メソッドを独自に定義しない場合は、Object クラスから継承されるデフォルトの Object.equals() メソッドが使われる。Object.equals() メソッドは参照の比較のみを行うため、予期せぬ結果が得られるかもしれない。

equals() メソッドはオブジェクトに対してのみ適用でき、プリミティブに対しては適用できない。

列挙型は一定の数の異なる値を持ち、これらの値の比較には equals() メソッドではなく == が用いられる。列挙型が提供するequals() メソッドは内部で== を使っており、これをオーバーライドすることはできない。一般に、スーパークラスから equals() を継承し、独自の機能を追加する必要のないサブクラスでは、equals() メソッドをオーバーライドする必要はない。

Java言語仕様が定める equals() メソッドの一般仕様契約(general usage contract)には、以下の5つの要求事項が挙げられている。

  1. 反射性: 参照値 x に対し、x.equals(x)true を返す。
  2. 対称性: 参照値 xy に対し、x.equals(y)true を返すとき、かつそのときにかぎり、y.equals(x)true を返す。
  3. 推移性: 参照値 xyz に対し、x.equals(y)y.equals(z)true を返すとき、x.equals(z)true を返す。
  4. 整合性: 参照値 xy に対し、equals() の比較に使われる情報が変更されないかぎり、x.equals(y) の呼び出しの返り値は true あるいは false のどちらか一方のみである。
  5. null でない参照値 x に対し、x.equals(null)false を返す。

equals() メソッドをオーバーライドする場合、これらの要求に違反してはいけない。

違反コード (対称性)

以下の違反コード例では、内部に String を持ち、equals() メソッドをオーバーライドする CaseInsensitiveString クラスを定義している。CaseInsensitiveString クラスは通常の文字列のことを知っているが、String クラスは CaseInsensitiveString のことは何も知らない。そのため、CaseInsensitiveString.equals() メソッドに String クラスのオブジェクトを与えてはいけない。

public final class CaseInsensitiveString {
  private String s;

  public CaseInsensitiveString(String s) {
    if (s == null) {
      throw new NullPointerException();
    }
    this.s = s;
  }

  // このメソッドは対称性に違反している
  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;
  }

  // MET09-J に適合するためにオーバーライド
  public int hashCode() {/* ... */}

  public static void main(String[] args) {
    CaseInsensitiveString cis = new CaseInsensitiveString("Java");
    String s = "java";
    System.out.println(cis.equals(s)); // true を返す
    System.out.println(s.equals(cis)); // false を返す
  }
}

String オブジェクトを引数とする動作において、CaseInsensitiveString.equals() メソッドは第2の要求事項(対称性)に違反している。String オブジェクト sCaseInsensitiveString オブジェクト cisの違いが大文字か小文字かのみである場合、equals()メソッドの非対称性により、cis.equals(s)true を返すが、s.equals(cis)false を返す。

適合コード

以下の適合コードでは、CaseInsensitiveString.equals() メソッドを単純化し、CaseInsensitiveString クラスのインスタンスに対してのみ動作するようにしている。その結果、対称性が保たれている。

public final class CaseInsensitiveString {
  private String s;

  public CaseInsensitiveString(String s) {
    if (s == null) {
      throw new NullPointerException();
    }
    this.s = s;
  }

  public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
  }

  public int hashCode() {/* ... */}

  public static void main(String[] args) {
    CaseInsensitiveString cis = new CaseInsensitiveString("Java");
    String s = "java";
    System.out.println(cis.equals(s)); // 今度は false を返す
    System.out.println(s.equals(cis)); // 今度は false を返す
  }
}
違反コード (推移性)

以下の違反コード例では、Card クラスを拡張する XCard クラスを定義している。

public class Card {
  private final int number;

  public Card(int number) {
    this.number = number;
  }

  public boolean equals(Object o) {
    if (!(o instanceof Card)) {
      return false;
    }

    Card c = (Card)o;
    return c.number == number;
  }

  public int hashCode() {/* ... */}

}

class XCard extends Card {
  private String type;
  public XCard(int number, String type) {
    super(number);
    this.type = type;
  }

  public boolean equals(Object o) {
    if (!(o instanceof Card)) {
      return false;
    }

    // 通常のCard のオブジェクト。type は比較しない。
    if (!(o instanceof XCard)) {
      return o.equals(this);
    }

    // XCard オブジェクト。type も比較する。
    XCard xc = (XCard)o;
    return super.equals(o) && xc.type == type;
  }

  public int hashCode() {/* ... */}

  public static void main(String[] args) {
    XCard p1 = new XCard(1, "type1");
    Card p2 = new Card(1);
    XCard p3 = new XCard(1, "type2");
    System.out.println(p1.equals(p2)); // true を返す
    System.out.println(p2.equals(p3)); // true を返す
    System.out.println(p1.equals(p3)); // false を返す。推移律に違反。
  }
}

この違反コード例では、p1p2 の比較と p2p3 の比較は等しくなるが、p1p3 の比較は等しくならないため推移性に違反している。問題は、Card クラスが XCard クラスのことを知らないため、p2p3type フィールドの値が異なることを考慮できないことにある。

適合コード

残念ながら、このコード例では、equals()の一般使用契約を守った上で、値やフィールドを追加することでインスタンス化が可能なクラス(abstractクラスではない)を拡張することはできない。求める結果を得るためには、継承ではなくコンポジションを用いること[Bloch 2008]。以下の適合コードではこの方針に沿い、XCard クラスに private な card フィールドと public な viewCard() メソッドを追加している。

class XCard {
  private String type;
  private Card card; // コンポジション

  public XCard(int number, String type) {
    card = new Card(number);
    this.type = type;
  }

  public Card viewCard() {
    return card;
  }

  public boolean equals(Object o) {
    if (!(o instanceof XCard)) {
      return false;
    }

    XCard cp = (XCard)o;
    return cp.card.equals(card) && cp.type.equals(type);
  }

  public int hashCode() {/* ... */}

  public static void main(String[] args) {
    XCard p1 = new XCard(1, "type1");
    Card p2 = new Card(1);
    XCard p3 = new XCard(1, "type2");
    XCard p4 = new XCard(1, "type1");
    System.out.println(p1.equals(p2)); // false を出力
    System.out.println(p2.equals(p3)); // false を出力
    System.out.println(p1.equals(p3)); // false を出力
    System.out.println(p1.equals(p4)); // true を出力
  }
}
違反コード (整合性)

URL はリソースの場所とアクセス方法の両方を規定している。Java API 仕様には URL クラスについて以下のように記述されている[API 2006]。

2つの URL オブジェクトが等しいのは、同じプロトコルを持ち、同じホストを参照し、ホスト上のポート番号が同じで、ファイルとファイルのフラグメントが同じ場合である。

2つのホストが等価と見なされるのは、両方のホスト名が同じIPアドレスに解決されるか、どちらかのホスト名を解決できない場合は、大文字小文字に関係なくホスト名が等しいか、両方のホスト名が null に等しい場合である。

URL クラスの equals() メソッドで定義されている動作は HTTP の バーチャルホスティング と整合性がないことが知られている。

バーチャルホスティングにより、1つのコンピュータ上のウェブサーバが複数のウェブサイトをホストすることが可能であり、場合によってはIPアドレスを共用することも可能である。残念ながら、この技術は URL クラスが設計されたときには存在しなかった。そのため、2つの全く異なる URL が同じ IP アドレスに解決される場合、URL クラスはそれらを等しいものとみなしてしまう。

URL オブジェクトの equals() メソッドに関する別のリスクは、インターネットに接続している場合とそうでない場合のロジックが異なることである。インターネットに接続している場合、equals() メソッドは Java API に記述された手順に従うが、インターネットに接続していない場合、2つの URL の文字列比較を行う。したがって、URL.equals() メソッドは equals() の整合性に関する要求に違反する。

組織の従業員が http://mailwebsite.com を通じて外部のメールサービスにアクセスすることを実現するアプリケーションを例に考えてみよう。このアプリケーションはファイアウォールのように他のウェブサイトへのアクセスを拒否するように設計されている。しかし、mailwebsite.com と同じIPアドレスで illegitimatewebsite.com がホストされている場合、ユーザは本来アクセスが禁止されるべきウェブサイト http://illegitimatewebsite.com にアクセスできてしまう。さらに悪いことに、正規のウェブサイトが商用のホスティングサイトで運営されているような場合、攻撃者は(フィッシングを目的として)複数のウェブサイトを同じホスティングサイト上に登録することで、アプリケーションのアクセス制限を回避することが可能になる。

public class Filter {
  public static void main(String[] args) throws MalformedURLException {
    final URL allowed = new URL("http://mailwebsite.com");
    if (!allowed.equals(new URL(args[0]))) {
      throw new SecurityException("Access Denied");
    }
    // アクセスを許可するときの処理
  }
}
適合コード (文字列の比較)

以下の適合コードでは、2つの URL の文字列表現を比較することで URL.equals() の問題を回避している。

public class Filter {
  public static void main(String[] args) throws MalformedURLException {
    final URL allowed = new URL("http://mailwebsite.com");
    if (!allowed.toString().equals(new URL(args[0]).toString())) {
      throw new SecurityException("Access Denied");
    }
    // アクセスを許可するときの処理
  }
}

この解決方法にはまだ問題がある。文字列表現の異なる2つの URL が同じウェブサイトを指している場合がある。しかし、このコードでは、equals() の一般使用契約を守っているため、悪意のある URL へのアクセスを間違って許可することはないので安全である。

適合コード (URI.equals())

URI はリソースを特定する文字列を含んでおり、URL よりも一般的な概念である。java.net.URI クラスは、文字列に基いて動作する equals()hashCode() メソッドを提供するが、これらのメソッドは Object.equals()Object.hashCode() の一般契約を満たしている。また、ホスト名の解決は行わず、ネットワーク接続の影響も受けない。さらに、URI には URL クラスにはない URI の標準化や正規化を行うメソッドも備わっている。最後に、URL.toURI()URI.toURL() メソッドを使えば URL クラスと URI クラスの間で容易に変換を行うことができる。プログラムは可能なかぎり URL ではなく URI を使うべきである。Java API では URI クラスについて以下のように記述している[API 2006]。

URI は絶対、相対のいずれかになる。URI 文字列は、スキーマが指定されていてもそれについては考慮せず、一般的な構文に従って解析される。ホストが存在していてもその検索は実行されず、スキーマに依存するストリームハンドラの構築も行われない。

以下の適合コードでは URL の代わりに URI オブジェクトを使っている。フィルタは文字列比較を行うため、http://mailwebsite.com 以外の文字列が渡されるとアクセスを適切にブロックする。

public class Filter {
  public static void main(String[] args)
                     throws MalformedURLException, URISyntaxException {
    final URI allowed = new URI("http://mailwebsite.com");
    if (!allowed.equals(new URI(args[0]))) {
      throw new SecurityException("Access Denied");
    }
    // アクセスを許可するときの処理
  }
}

URI クラスは('..' のような余計なパスを削除する)正規化と相対化を行う[API 2006][Darwin 2004]。

違反コード (java.security.Key)

デフォルトの java.lang.Object.equals() は、暗号化鍵のように複数の要素から構成されるオブジェクトを比較できない。Key クラスの大半は、Object クラスが標準で実装する equals() メソッドをオーバーライドしていない。このような場合に正しく比較を行うには、オブジェクトを構成する各要素を比較しなくてはならない。

以下の違反コード例では、equals() メソッドを使って2つの鍵を比較している。2つが同じ鍵のインスタンスを表す場合であっても、false が返されるかもしれない。

private static boolean keysEqual(Key key1, Key key2) {
  if (key1.equals(key2)) {
    return true;
  }
  return false;
}
適合コード (java.security.Key)

以下の適合コードでは equals() メソッドを使って最初のテストを行い、次に鍵の提供者に依存しないで動作するようにエンコードした鍵を比較している。このコードでは、たとえば、RSAPrivateKeyRSAPrivateCrtKey が同一の private key を表現しているかを調べることができる[Sun 2006]。

private static boolean keysEqual(Key key1, Key key2) {
  if (key1.equals(key2)) {
    return true;
  }

  if (Arrays.equals(key1.getEncoded(), key2.getEncoded())) {
    return true;
  }

  // 以下、異なる種類の鍵に対応するコード
  // たとえば以下のコードは RSAPrivateKey と RSAPrivateCrtKey が
  // 同じ鍵かどうかを調べることができる
  if ((key1 instanceof RSAPrivateKey) &&
      (key2 instanceof RSAPrivateKey)) {
  
    if ((((RSAKey)key1).getModulus().equals(
         ((RSAKey)key2).getModulus())) &&
       (((RSAPrivateKey) key1).getPrivateExponent().equals(
        ((RSAPrivateKey) key2).getPrivateExponent()))) {
      return true;
    }
  }
  return false;
}
例外

MET08-EX0: 互換性のない型同士を比較しないのであれば、このルールに違反してもよい。Java プラットフォームライブラリ(やその他)には、インスタンス化できるクラスに値を表現するコンポーネントを追加する形で拡張したクラスがいくつもある。たとえば、java.sql.Timestampjava.util.Date を拡張してナノ秒のフィールドを追加したものである。Timestampequals() の実装は対称性に違反しており、Timestamp のオブジェクトと Date オブジェクトが同じコレクションのなかで使われるなど混在すると、正しく動作しない[Bloch 2008]。

リスク評価

equals() メソッドをオーバーライドするときに一般契約に違反すると、想定とは異なる結果を招くことがある。

ルール 深刻度 可能性 修正コスト 優先度 レベル
MET08-J P2 L3
関連ガイドライン
MITRE CWE CWE-697. Insufficient comparison
参考文献
[API 2006] Method equals()
[Bloch 2008] Item 8. Obey the general contract when overriding equals
[Darwin 2004] 9.2, Overriding the equals Method
[Harold 1997] Chapter 3, Classes, Strings, and Arrays, The Object Class (Equality)
[Sun 2006] Determining If Two Keys Are Equal (JCA Reference Guide)
[Techtalk 2007] More Joy of Sets
翻訳元

これは以下のページを翻訳したものです。

MET08-J. Preserve the equality contract when overriding the equals() method (revision 107)

Top へ

Topへ
最新情報(RSSメーリングリストTwitter