1. オブジェクト比較の問題
おさらい: 参照の比較とオブジェクトの比較
ご存知のとおり、Java でオブジェクトに対して演算子 == を使うと参照を比較します――つまり、同じメモリアドレスを指しているかどうかです。フィールドが同じでも、new で別々に生成した 2 つのオブジェクトは == では異なるものと見なされます。
Person p1 = new Person("Sasha", 20);
Person p2 = new Person("Sasha", 20);
System.out.println(p1 == p2); // false — これはメモリ上で異なるオブジェクト!
中身が等しいかどうかを知りたい場合は equals()、hashCode() を使います……これは既にご存知ですね。では、誰が「年上」か、「年下」か、「辞書順でどちらが先か」を知りたいときはどうすればよいでしょうか。たとえば、ユーザーのリストを年齢や名前でソートしたい場合です。
オブジェクトのソートと検索の必要性
例えば、ユーザーのリストがあり、年齢で並べ替えたいとします:
List<Person> people = new ArrayList<>();
people.add(new Person("Vasya", 25));
people.add(new Person("Petya", 20));
people.add(new Person("Katya", 30));
// どうやってソートする?
Collections.sort(people); // おっと!Java は Person の比較方法を知りません!
コンパイラはすぐに文句を言います。クラス Person がインターフェイス Comparable を実装していないからです。Java は私たちの考えを読めませんし、Person にとっての「大きい」「小さい」が何を意味するのか知りません。これを教えるには、比較の規則を明示的に記述する必要があります。
2. Comparable インターフェイス
インターフェイスの宣言
インターフェイス Comparable は、Java に「このクラスは比較可能で、その方法はこれです」と伝える標準的な手段です。
public interface Comparable<T> {
int compareTo(T o);
}
おなじみの a.compareTo(b) は次を返します:
- 負の数 — つまり、a が b より「小さい」。
- 0 の場合 — オブジェクトは等しいと見なされます。
- 正の数 — a が b より「大きい」。
例: Person クラスの compareTo 実装
年齢で比較できるクラス Person を作ってみましょう:
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// ゲッター(後の例で使用)
public String getName() { return name; }
public int getAge() { return age; }
// compareTo メソッドの実装
@Override
public int compareTo(Person other) {
// 年齢でソート(昇順)
return Integer.compare(this.age, other.age);
// 代替案: return this.age - other.age;
}
}
重要なポイント: 降順にしたい場合は引数を入れ替えるだけです: Integer.compare(other.age, this.age)。
たとえ話。compareTo は競技の審判のようなものです。誰が先で、誰が後で、誰が同着かを明確に判断しなければなりません。すべての審判(メソッド compareTo)がばらばらの基準で判定したら――カオスになります!
3. Comparable の利用
Comparable によるコレクションのソート
クラスが Comparable を実装したので、ソートは「そのまま」動きます:
List<Person> people = new ArrayList<>();
people.add(new Person("Vasya", 25));
people.add(new Person("Petya", 20));
people.add(new Person("Katya", 30));
Collections.sort(people); // compareTo を使います!
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
// Petya (20)
// Vasya (25)
// Katya (30)
同様に、リストのメソッド sort でも動作します:
people.sort(null); // null を渡すと compareTo が使われる
名前でのソート
名前でソートしたい場合は、実装を次のように変えます:
@Override
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
複数フィールドでのソート
まずあるフィールドで比べ、同値なら別のフィールドで比べる、ということもあります:
@Override
public int compareTo(Person other) {
int cmp = Integer.compare(this.age, other.age);
if (cmp != 0) return cmp;
return this.name.compareTo(other.name);
}
4. Comparable 実装のベストプラクティス
Comparable の契約を守る
- もし a.compareTo(b) == 0 なら、b.compareTo(a) も必ず 0。
- もし a.compareTo(b) が < 0 なら、b.compareTo(a) は > 0(逆も同様)。
- もし a.compareTo(b) == 0 のとき、できれば a.equals(b) が true であること(ただし厳密な必須ではない)。
なぜ重要か?
コレクション(例えば TreeSet、TreeMap)やソート処理は、この契約が破られると予測不能に振る舞うことがあります。例えば、本来重複を許さないコレクションに「二重登録」が発生することがあります。
equals と hashCode も忘れずに
compareTo を実装するなら、equals と hashCode の実装が適切かどうかも考えましょう。特に、HashSet や Map といったコレクションで使用する場合です。
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person other = (Person) o;
return age == other.age && Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
compareTo で null になり得るフィールドを無検査で使わない!
フィールドが null になり得る場合は、安全な比較を使いましょう:
@Override
public int compareTo(Person other) {
return Objects.compare(this.name, other.name, Comparator.nullsFirst(String::compareTo));
}
オブジェクトが既に整列済みコレクション内にあるなら、compareTo に関与するフィールドを変更しない
これを行うと、コレクション内――例えば TreeSet や TreeMap――でオブジェクトが「見失われる」おそれがあります。
5. 学習用アプリを発展させる: ユーザーのソート
ステップ 1: クラスを定義する
public class Person implements Comparable<Person> {
private String name;
private int age;
// ... コンストラクタ、ゲッター、compareTo、equals、hashCode ...
}
ステップ 2: ユーザーを追加する
List<Person> people = new ArrayList<>();
people.add(new Person("Masha", 23));
people.add(new Person("Grisha", 19));
people.add(new Person("Anya", 25));
ステップ 3: ソートして出力する
Collections.sort(people);
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
結果:
Grisha (19)
Masha (23)
Anya (25)
6. Comparable の動作の流れ
┌────────────────────────────┐
│ あなたのクラス (Person) │
├────────────────────────────┤
│ implements Comparable │
│ ↓ │
│ public int compareTo(T o) │
│ ↓ │
│ (this < o) → -1 │
│ (this == o) → 0 │
│ (this > o) → 1 │
└────────────────────────────┘
│
▼
Collections.sort(list)
│
▼
ソートが動く!
7. 役立つニュアンス
Collections.sort の仕組み
- リスト内のオブジェクトが Comparable を実装している場合、ソートはそのメソッド compareTo を使用します。
- 実装していない場合はコンパイルエラーになります。
- 標準型(Integer、String など)には既に Comparable が実装されています。
複数の比較方法を用意できるか?
- 1 つのクラスにおける「自然順序」は Comparable で 1 つだけです。
- 別の順序付けが必要なら Comparator を使いましょう(次の講義)。
例: 文字列の compareTo
String a = "apple";
String b = "banana";
System.out.println(a.compareTo(b)); // 負の数。なぜなら "apple" < "banana" だから
表: compareTo が返すもの
| 比較 | 戻り値 |
|---|---|
|
|
|
|
|
|
8. Comparable 実装での典型的なミス
誤り1: compareTo の契約違反。
もし a.compareTo(b) が 0 を返し、b.compareTo(a) が 0 でないなら、コレクションは奇妙な振る舞いをします。例えば、TreeSet がオブジェクトを別物とみなし、両方を追加してしまうことがあります。
誤り2: 未初期化(null)のフィールドを使う。
比較に使うフィールドが null になり得るのに検査をしないと、NullPointerException を招きます。
誤り3: compareTo と equals の不一致。
compareTo がオブジェクトは等しい(0)と言っているのに、equals が異なる(false)と言うと、コレクション操作でバグになります。
誤り4: 整列済みコレクションに追加した後に compareTo が参照するフィールドを変更する。
これは、すでにアルファベット順の列に並んでいるのに途中で名字を変えるようなものです。コレクションがあなたのオブジェクトを「見失う」可能性があります。
誤り5: -1、0、1 だけを返すと思い込む。
メソッド compareTo は、任意の負の数または正の数を返して構いません。必ずしも厳格に -1 や 1 だけである必要はありません。ただし、単純化のために -1/0/1 を用いることはよくあります。
GO TO FULL VERSION