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

MET11-J. 比較演算に用いるキーは不変にする

順序集合やマップにおいてキーの役目を果たすオブジェクトは不変(immutable)でなくてはならない。オブジェクトの一部のフィールドを可変(mutable)にしなくてはならない場合、equals(), hashCode(), compareTo() メソッドは、オブジェクトを比較する際に不変な状態のみを比較しなくてはならない。このルールに違反すると、コレクションの順序の一貫性が失われる。java.util.Interface Set<E>java.util.Interface Map<K,V>の仕様には、この点に関する注意書きが記されている。たとえば、API仕様にはインタフェース Map について以下のように記載されている。[API 2006]

注:可変オブジェクトをマップキーとして使用する場合は細心の注意が必要である。オブジェクトがマップ内のキーであるときに、equals の比較に影響を与える方法でオブジェクトの値が変更された場合、マップの動作は保証されない。この禁止事項の特殊な例として、マップがそれ自身をキーとして持つことができないことが挙げられる。マップがそれ自身を値として持つことは許可されるが、その場合は細心の注意が必要である。そのようなマップでは euqalsメソッドおよびhashCodeメソッドの結果は保証されない。
違反コード

以下の違反コード例では、可変クラス Employeeを定義している。このクラスを構成するフィールド namesalary の値は、setEmployeeName() および setSalary() メソッドを使って変更される。equals()メソッドは、従業員の名前(name)に基づいて比較を行うようにオーバーライドされている。

// 可変クラス Employee
class Employee {
  private String name;
  private double salary;

  Employee(String empName, double empSalary) {
    this.name = empName;
    this.salary = empSalary;
  }

  public void setEmployeeName(String empName) {
    this.name = empName;
  }

  // ... hashCode の実装

  @Override public boolean equals(Object o) {
    if (!(o instanceof Employee)) {
      return false;
    }

    Employee emp = (Employee)o;
    return emp.name.equals(name);
  }
}

// クライアントコード
Map<Employee, Calendar> map =
  new ConcurrentHashMap<Employee, Calendar>();
// ...

マップの順序が決まった後でオブジェクトの属性が変更されうるため、Employee オブジェクトをマップキーとして使用するのは危険である。たとえば、クライアントプログラムは、従業員の名字が変更されるとnameフィールドを変更することができる。その結果、クライアントプログラムはオブジェクトの一貫しない動作を観測することになる。

適合コード

以下の解決策では final宣言されたフィールド employeeIDをクラスに追加しており、この値は初期化後変更することができない。equals() メソッドはこのフィールドに基づいてEmployeeオブジェクトを比較する。

// 可変クラス Employee
class Employee {
  private String name;
  private double salary;
  private final long employeeID;  // 従業員ごとにユニークな値

  Employee(String name, double salary, long empID) {
    this.name = name;
    this.salary = salary;
    this.employeeID = empID;
  }

  // ... hashCode の実装

  @Override public boolean equals(Object o) {
    if (!(o instanceof Employee)) {
      return false;
    }

    Employee emp = (Employee)o;
    return emp.employeeID == employeeID;
  }
}

// クライアントコードは変更なし
Map<Employee, Calendar> map =
  new ConcurrentHashMap<Employee, Calendar>();
// ...

クライアントプログラムのコードにおいて Employee クラスをマップキーとして安全に使用することができる。

違反コード

シリアライズするとハッシュコードが変わるという事実に驚くプログラマは少なくない。hashCode()メソッドの一般契約では、同じアプリケーションの別の実行でハッシュコードが同じであることは要求されていない。同様に、オブジェクトがシリアライズされ、後に復元される場合、復元後のハッシュコードは元のハッシュコードと異なるかもしれない。

以下の違反コードでは MyKey クラスを Hashtable のインデックスキーとして使用している。MyKey クラスは Object.equals() をオーバーライドしているが、デフォルトのObject.hashCode() を呼び出している。Java API [API 2006] の Hashtable のドキュメントには以下のように記されている。

ハッシュテーブルにオブジェクトを格納したり、そこから取り出したりするには、キーとして使用するオブジェクトに、hashCode メソッドと equals メソッドが実装されていなければならない。

以下の違反コード例は API 仕様のアドバイスに従っているが、シリアライズと復元の後、問題が発生する。復元した後に元のキーを使ってオブジェクトの値を取り出すことはできない。

class MyKey implements Serializable {
  // hashCode() をオーバーライドしない
}

class HashSer {
  public static void main(String[] args)
                     throws IOException, ClassNotFoundException {
    Hashtable<MyKey,String> ht = new Hashtable<MyKey, String>();
    MyKey key = new MyKey();
    ht.put(key, "Value");
    System.out.println("Entry: " + ht.get(key));
    // キーを使って取り出す, 成功する

    // ハッシュオブジェクトをシリアライズ
    FileOutputStream fos = new FileOutputStream("hashdata.ser");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(ht);
    oos.close();

    // ハッシュオブジェクトを復元
    FileInputStream fis = new FileInputStream("hashdata.ser");
    ObjectInputStream ois = new ObjectInputStream(fis);
    Hashtable<MyKey, String> ht_in =
        (Hashtable<MyKey, String>)(ois.readObject());
    ois.close();

    if (ht_in.contains("Value"))
      // ハッシュテーブルの中に実際にオブジェクトが存在するかどうかを確認
      System.out.println("Value was found in deserialized object.");

    if (ht_in.get(key) == null) // 出力される
      System.out.println(
          "Object was not found when retrieved using the key.");
  }
}
適合コード

以下の解決法では、キーの型をIntegerオブジェクトに変更している。これにより、同一のプログラムを何度実行しても、シリアライズと復元を行っても、あるいは異なるJVM実装でプログラムを実行しても、キーの値は同じままである。

class HashSer {
  public static void main(String[] args)
                     throws IOException, ClassNotFoundException {
    Hashtable<Integer, String> ht = new Hashtable<Integer, String>();
    ht.put(new Integer(1), "Value");
    System.out.println("Entry: " + ht.get(1)); // キーを使って取り出す

    // Hashtable オブジェクトをシリアライズ
    FileOutputStream fos = new FileOutputStream("hashdata.ser");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(ht);
    oos.close();

    // Hashtable オブジェクトを復元
    FileInputStream fis = new FileInputStream("hashdata.ser");
    ObjectInputStream ois = new ObjectInputStream(fis);
    Hashtable<Integer, String> ht_in =
        (Hashtable<Integer, String>)(ois.readObject());
    ois.close();

    if (ht_in.contains("Value"))
      // Hashtable の中に実際にオブジェクトが存在するかどうかを確認
      System.out.println("Value was found in deserialized object.");

    if (ht_in.get(1) == null)  // 出力されない
      System.out.println(
          "Object was not found when retrieved using the key.");
  }
}

MyKey クラスの hashcode()をオーバーライドすることでこの問題を回避することもできたが、実装依存のパラメータを使用するハッシュテーブルをシリアライズしないのが最善の解決策である。

リスク評価

比較演算に使用するキーを不変にしないと、プログラムの不安定な動作につながる。

ルール 深刻度 可能性 修正コスト 優先度 レベル
MET11-J P2 L3
自動検出

静的解析ツールの中には、可変フィールドから値を読み取るcompareTo()メソッドのインスタンスを検出するものがある。

参考文献
[API 2006] java.util.Interface Set<E> and java.util.Interface Map<K,V>
翻訳元

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

MET11-J. Ensure that keys used in comparison operations are immutable (revision 41)

Top へ

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