- JAVA
Java Comparableの実装とComparatorとの違い
Comparableとは、クラス自身に「標準の並び順(自然順序)」を持たせるためのインターフェースで、compareToメソッドを1つ実装します。 Comparableを実装したクラスは、Collections.sort(list) や Arrays.sort(array) に引数を足さずに並べ替えられ、TreeSet や TreeMap のキーとしてもそのまま使えます。一方のComparatorは、クラスの外に置く「比較のルール」で、同じクラスを場面に応じて別の順序で並べたいときに使います。
この記事では、compareToの書き方を「引き算で書くと順序が崩れる例」から始めて、TreeSetで要素が減る例、Comparableを実装していないクラスを並べ替えたときのエラー文まで、すべて実行して確かめた結果とともに紹介します。record・複数キー・null・降順のテンプレート、ComparableとComparatorの比較表、標準クラスの自然順序一覧も用意しました。
確認環境:Ubuntu 24.04/OpenJDK 21.0.10。仕様に関する記述は、Java SE 21 の Comparable のAPIドキュメントを開いて確認した内容です。
ComparableとComparatorの早見表
| 項目 | Comparable | Comparator |
|---|---|---|
| パッケージ | java.lang(import不要) |
java.util(importが必要) |
| 実装するメソッド | int compareTo(T o)(引数1つ。自分と相手を比べる) |
int compare(T a, T b)(引数2つ。2つを比べる) |
| 書く場所 | 並べ替えたいクラスの中 | クラスの外(ラムダ式、定数、別クラス) |
| 表す順序 | そのクラスの自然順序(1種類だけ) | 用途ごとの順序(いくつでも作れる) |
| 並べ替えの呼び出し | Collections.sort(list)、list.sort(null)、stream.sorted() |
list.sort(comparator)、stream.sorted(comparator) |
| TreeSet・TreeMap | new TreeSet<>() でそのまま使える |
new TreeSet<>(comparator) と渡す |
| 自分で変更できないクラス | 追加できない | 使える |
| 向いている場面 | 番号・日付・バージョンなど、誰が見ても順序が1つに決まる値 | 画面や帳票ごとに並び順を変える、複数の並び順を使い分ける |
compareToの戻り値の意味は次のとおりです。
| 戻り値 | 意味 | 昇順に並べたときの位置 |
|---|---|---|
| 負の数 | 自分(this)のほうが小さい | 自分が前 |
| 0 | 順序のうえで同じ | どちらが前でもよい |
| 正の数 | 自分(this)のほうが大きい | 自分が後ろ |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java Comparableの実装とComparatorとの違い」
Comparableを実装する基本の形
会員を番号の小さい順に並べる例です。ポイントは3つあります。
implements Comparable<Member>と、型引数に自分自身のクラスを書く。compareTo(Member other)を実装し、負・0・正のいずれかを返す。- 数値の比較には
Integer.compareを使う。
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class BasicDemo {
// 自然順序 = 番号の小さい順
static class Member implements Comparable<Member> {
final int no;
final String name;
Member(int no, String name) {
this.no = no;
this.name = name;
}
@Override
public int compareTo(Member other) {
return Integer.compare(this.no, other.no);
}
@Override
public String toString() {
return no + ":" + name;
}
}
public static void main(String[] args) {
List<Member> list = new ArrayList<>(List.of(
new Member(30, "Sato"), new Member(10, "Tanaka"), new Member(20, "Abe")));
Collections.sort(list);
System.out.println("sort後 : " + list);
System.out.println("最小 : " + Collections.min(list));
System.out.println("最大 : " + Collections.max(list));
System.out.println("10と20 : " + list.get(0).compareTo(list.get(1)));
System.out.println("20と10 : " + list.get(1).compareTo(list.get(0)));
System.out.println("10と10 : " + list.get(0).compareTo(list.get(0)));
}
}
sort後 : [10:Tanaka, 20:Abe, 30:Sato]
最小 : 10:Tanaka
最大 : 30:Sato
10と20 : -1
20と10 : 1
10と10 : 0
Comparableを1回実装しておくと、ソート・最小・最大・二分探索・TreeSet・TreeMapがすべて同じ順序で動きます。 自然順序を使うAPIの一覧です(いずれもComparableを実装したクラスで動作を確かめました)。
| 自然順序を使うAPI | 書き方 |
|---|---|
| リストを並べ替える | Collections.sort(list)、list.sort(null)、list.sort(Comparator.naturalOrder()) |
| 配列を並べ替える | Arrays.sort(array) |
| ストリームを並べ替える | stream.sorted() |
| 最小・最大を取る | Collections.min(list)、Collections.max(list) |
| 二分探索する | Arrays.binarySearch(array, key) |
| 並んだ状態で保持する | new TreeSet<>()、new TreeMap<>() |
| 自然順序の逆にする | list.sort(Comparator.reverseOrder()) |
引き算でcompareToを書くとソート順が崩れる
return this.point - o.point; という引き算の実装は短く書けますが、差がintの範囲を超えると符号が反転し、並び順が崩れます。 引き算版と Integer.compare 版を並べて実行します。
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SubtractDemo {
// 引き算で書いた compareTo
record BySubtract(int point) implements Comparable<BySubtract> {
@Override
public int compareTo(BySubtract o) {
return this.point - o.point;
}
}
// Integer.compare で書いた compareTo
record ByCompare(int point) implements Comparable<ByCompare> {
@Override
public int compareTo(ByCompare o) {
return Integer.compare(this.point, o.point);
}
}
public static void main(String[] args) {
int[] points = {2_000_000_000, -2_000_000_000, 100, -100, 0};
List<BySubtract> a = new ArrayList<>();
List<ByCompare> b = new ArrayList<>();
for (int p : points) {
a.add(new BySubtract(p));
b.add(new ByCompare(p));
}
Collections.sort(a);
Collections.sort(b);
System.out.println("引き算 : " + a.stream().map(BySubtract::point).toList());
System.out.println("Integer.compare : " + b.stream().map(ByCompare::point).toList());
System.out.println("-2000000000 - 2000000000 = " + (-2_000_000_000 - 2_000_000_000));
System.out.println("Integer.compare(-2000000000, 2000000000) = "
+ Integer.compare(-2_000_000_000, 2_000_000_000));
System.out.println("Math.subtractExact なら: ");
try {
Math.subtractExact(-2_000_000_000, 2_000_000_000);
} catch (ArithmeticException e) {
System.out.println(" " + e);
}
}
}
引き算 : [2000000000, -2000000000, -100, 0, 100]
Integer.compare : [-2000000000, -100, 0, 100, 2000000000]
-2000000000 - 2000000000 = 294967296
Integer.compare(-2000000000, 2000000000) = -1
Math.subtractExact なら:
java.lang.ArithmeticException: integer overflow
引き算版では、最大の 2000000000 が先頭に来ています。-2000000000 - 2000000000 は本来 -4000000000 ですが、intに収まらず 294967296(正の数)になり、「-20億のほうが大きい」という結果を返してしまうためです。例外もコンパイルエラーも出ずに順序だけが変わるので、気づきにくいのが特徴です。
件数が増えると例外になることもある
乱数で作ったintの全範囲の値を、引き算版のcompareToで並べ替えます(プログラム引数で件数を指定)。
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
public class ContractDemo {
record BySubtract(int point) implements Comparable<BySubtract> {
@Override
public int compareTo(BySubtract o) {
return this.point - o.point;
}
}
public static void main(String[] args) {
Random random = new Random(1);
List<BySubtract> list = new ArrayList<>();
int size = Integer.parseInt(args[0]);
for (int i = 0; i < size; i++) {
list.add(new BySubtract(random.nextInt())); // intの全範囲
}
try {
Collections.sort(list);
int broken = 0;
for (int i = 1; i < list.size(); i++) {
if (list.get(i - 1).point() > list.get(i).point()) broken++;
}
System.out.println("並びが逆になっている箇所: " + broken);
} catch (IllegalArgumentException e) {
System.out.println(e);
}
}
}
$ java ContractDemo 5
並びが逆になっている箇所: 0
$ java ContractDemo 1000
並びが逆になっている箇所: 5
$ java ContractDemo 100000
java.lang.IllegalArgumentException: Comparison method violates its general contract!
少ない件数のテストでは通り、データが増えてから表面化する、という動きになります。「Comparison method violates its general contract!」というメッセージを見たら、compareTo(またはComparator)の中に引き算や、後述する規約に合わない比較がないかを確認します。
直し方:型に合った compare メソッドを使う
| フィールドの型 | 引き算やキャストで起きること(実行結果) | おすすめの書き方 |
|---|---|---|
| int | -2000000000 - 2000000000 が 294967296 になり符号が反転 |
Integer.compare(a, b) |
| long | (int) (4294967296L - 0L) が 0 になり「同じ」と判定 |
Long.compare(a, b) |
| double | (int) (0.5 - 0.2) が 0 になり「同じ」と判定 |
Double.compare(a, b) |
| boolean | 引き算できない | Boolean.compare(a, b)(falseが前) |
| char | ― | Character.compare(a, b) |
| String・LocalDateなど | ― | a.compareTo(b) |
(int) (0.5 - 0.2) = 0
Double.compare(0.5, 0.2) = 1
(int) (4294967296L - 0L) = 0
Long.compare(4294967296L, 0L) = 1
Integer.compare(1, 2) = -1
Boolean.compare(true, false) = 1
Character.compare('a', 'b') = -1
「値が0以上と決まっているから引き算でよい」というケースもありますが、後から負の値が入るようになったときに気づけません。最初から Integer.compare と書いておくと、値の範囲を気にせずに済みます。
compareToが0を返すとTreeSet・TreeMapで要素が減る
TreeSetとTreeMapは、equalsではなくcompareToが0を返すかどうかで「同じ要素か」を判断します。年齢だけを比べるcompareToで試します。
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public class TreeDemo {
// 年齢だけで比較する(名前は見ない)
record Member(String name, int age) implements Comparable<Member> {
@Override
public int compareTo(Member o) {
return Integer.compare(this.age, o.age);
}
}
public static void main(String[] args) {
List<Member> members = List.of(
new Member("Sato", 30), new Member("Tanaka", 25), new Member("Abe", 30));
Set<Member> hash = new HashSet<>(members);
Set<Member> tree = new TreeSet<>(members);
System.out.println("HashSet の件数: " + hash.size());
System.out.println("TreeSet の件数: " + tree.size() + " " + tree);
TreeSet<Member> t = new TreeSet<>();
System.out.println("add(Sato,30) : " + t.add(new Member("Sato", 30)));
System.out.println("add(Abe,30) : " + t.add(new Member("Abe", 30)));
System.out.println("contains(Abe,30): " + t.contains(new Member("Abe", 30)));
System.out.println("equals(Sato,Abe): " + new Member("Sato", 30).equals(new Member("Abe", 30)));
TreeMap<Member, String> map = new TreeMap<>();
map.put(new Member("Sato", 30), "A班");
map.put(new Member("Abe", 30), "B班");
System.out.println("TreeMap: " + map);
}
}
HashSet の件数: 3
TreeSet の件数: 2 [Member[name=Tanaka, age=25], Member[name=Sato, age=30]]
add(Sato,30) : true
add(Abe,30) : false
contains(Abe,30): true
equals(Sato,Abe): false
TreeMap: {Member[name=Sato, age=30]=B班}
| 結果 | 起きていること |
|---|---|
| HashSetは3件、TreeSetは2件 | 同じ30歳のAbeさんが「Satoさんと同じ要素」と見なされ、追加されなかった |
add(Abe,30) が false |
TreeSetが「すでにある」と判断した |
contains(Abe,30) が true |
入れていないのに、30歳の誰かがいれば「含まれる」と答える |
equals(Sato,Abe) は false |
equalsでは別人。compareToとequalsの結果が食い違っている |
TreeMapは Sato=B班 |
キーはSatoさんのまま、値だけがAbeさんの「B班」で上書きされた |
ComparableのAPIドキュメントには、この動作がそのまま書かれています。e1.compareTo(e2) == 0 と e1.equals(e2) が、すべての組み合わせで同じ結果になることを「equalsと整合している(consistent with equals)」と呼び、必須ではないものの強く推奨されています。整合していない要素を、Comparatorを指定しないソート済みセット(またはマップ)に入れると、2つ目のaddはfalseを返してサイズが増えない、という説明です。
直し方:equalsがtrueになる組だけ0を返す
区別したいフィールドをすべて比較に含めます。年齢が同じなら名前で比べる形にすると、3件とも保持されます。
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
public class TreeFixed {
// 年齢 → 名前 の順に比べる。equals が true になる組だけ 0 を返す
record Member(String name, int age) implements Comparable<Member> {
private static final Comparator<Member> ORDER =
Comparator.comparingInt(Member::age).thenComparing(Member::name);
@Override
public int compareTo(Member o) {
return ORDER.compare(this, o);
}
}
public static void main(String[] args) {
TreeSet<Member> tree = new TreeSet<>(List.of(
new Member("Sato", 30), new Member("Tanaka", 25), new Member("Abe", 30)));
System.out.println("TreeSet の件数: " + tree.size());
System.out.println(tree);
}
}
TreeSet の件数: 3
[Member[name=Tanaka, age=25], Member[name=Abe, age=30], Member[name=Sato, age=30]]
「年齢順に並べたいだけで、同じ年齢の人も全員残したい」という場合は、自然順序を変えずに list.sort(Comparator.comparingInt(Member::age)) とComparatorでリストを並べ替えるのがシンプルです。リストのソートでは、0を返した要素も消えずに残ります。
標準クラスにもある例:BigDecimal
APIドキュメントでは、equalsと整合しない例外として BigDecimal が挙げられています(ドキュメントの例は4.0と4.00)。2.0と2.00で実行した結果です。
BigDecimal equals : false
BigDecimal compareTo : 0
HashSet の件数 : 2
TreeSet の件数 : 1
new BigDecimal("2.0") と new BigDecimal("2.00") は、equalsでは別、compareToでは同じです。そのためHashSetでは2件、TreeSetでは1件になります。
Comparableを実装していないクラスを並べ替えたときのエラー
Comparableを実装していないクラスを自然順序で並べ替えようとすると、呼び出すAPIによってコンパイルエラーになる場合と、実行時のClassCastExceptionになる場合があります。
コンパイル時にわかる:Collections.sort(list)
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class NoComparableCompile {
static class Item {
final String name;
Item(String name) { this.name = name; }
}
public static void main(String[] args) {
List<Item> list = new ArrayList<>(List.of(new Item("b"), new Item("a")));
Collections.sort(list);
}
}
NoComparableCompile.java:12: error: no suitable method found for sort(List<Item>)
Collections.sort(list);
^
method Collections.<T#1>sort(List<T#1>) is not applicable
(inference variable T#1 has incompatible bounds
equality constraints: Item
upper bounds: Comparable<? super T#1>)
(以下略)
upper bounds: Comparable の行が、「ItemがComparableを実装していない」ことを示しています。
実行時にわかる:list.sort(null)・Arrays.sort・sorted()・TreeSet
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TreeSet;
public class NoComparable {
static class Item { // Comparable を実装していない
final String name;
Item(String name) { this.name = name; }
}
static void run(String label, Runnable r) {
try {
r.run();
System.out.println(label + " -> 例外なし");
} catch (ClassCastException e) {
System.out.println(label + " -> " + e.getMessage());
}
}
public static void main(String[] args) {
List<Item> list = new ArrayList<>(List.of(new Item("b"), new Item("a")));
run("list.sort(null)", () -> list.sort(null));
run("Arrays.sort", () -> Arrays.sort(new Item[] {new Item("b"), new Item("a")}));
run("stream.sorted()", () -> list.stream().sorted().toList());
run("TreeSet.add", () -> new TreeSet<Item>().add(new Item("a")));
run("要素1個のlist.sort(null)", () -> new ArrayList<>(List.of(new Item("a"))).sort(null));
}
}
list.sort(null) -> class NoComparable$Item cannot be cast to class java.lang.Comparable (NoComparable$Item is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')
Arrays.sort -> class NoComparable$Item cannot be cast to class java.lang.Comparable ((同じ文))
stream.sorted() -> class NoComparable$Item cannot be cast to class java.lang.Comparable ((同じ文))
TreeSet.add -> class NoComparable$Item cannot be cast to class java.lang.Comparable ((同じ文))
要素1個のlist.sort(null) -> 例外なし
(2〜4行目のかっこ内は1行目と同じ文なので省略しています。)
| API | Comparable未実装のとき | 気づけるタイミング |
|---|---|---|
Collections.sort(list) |
コンパイルエラー(no suitable method found) | コンパイル時 |
list.sort(null) |
ClassCastException | 実行時 |
Arrays.sort(array) |
ClassCastException | 実行時 |
stream.sorted() |
ClassCastException | 実行時 |
new TreeSet<>().add(x) |
ClassCastException(1個目のaddで発生) | 実行時 |
「cannot be cast to class java.lang.Comparable」と出たら、要素のクラスにComparableを実装するか、Comparatorを渡す、のどちらかで解消できます。要素が1個のリストでは list.sort(null) が例外なしで通った点にも注目です。データが2件以上になって初めて表面化するので、テストデータは複数件で用意します。
型引数なしのComparableは実行時まで気づけない
implements Comparable と型引数を省くと、compareToの引数が Object になり、中でキャストが必要になります。
public class RawType {
// 型引数なしの Comparable(引数が Object になり、キャストが必要)
static class Item implements Comparable {
final int no;
Item(int no) { this.no = no; }
@Override
public int compareTo(Object o) {
Item other = (Item) o;
return Integer.compare(no, other.no);
}
}
public static void main(String[] args) {
Item item = new Item(1);
try {
item.compareTo("文字列"); // コンパイルは通り、実行時に止まる
} catch (ClassCastException e) {
System.out.println(e.getMessage());
}
}
}
$ javac -Xlint:rawtypes RawType.java
RawType.java:7: warning: [rawtypes] found raw type: Comparable
static class Item implements Comparable {
^
missing type arguments for generic class Comparable<T>
$ java RawType
class java.lang.String cannot be cast to class RawType$Item (java.lang.String is in module java.base of loader 'bootstrap'; RawType$Item is in unnamed module of loader 'app')
型引数を付けた Comparable<Item> なら、同じ呼び出しはコンパイルの時点で止まります。
Typed.java:7: error: incompatible types: String cannot be converted to Item
new Item(1).compareTo("文字列");
^
型引数を書くだけで、キャストが不要になり、型の取り違えをコンパイラが見つけてくれます。
compareToの実装テンプレート集
record、複数キー、降順、null、手書きの5パターンを1ファイルにまとめました。必要な部分をコピーして使えます。
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class Templates {
// 1) record + 1キー
record Point(int value) implements Comparable<Point> {
@Override
public int compareTo(Point o) {
return Integer.compare(value, o.value);
}
}
// 2) 複数キー(部署 → 入社日 → 番号)
record Staff(String dept, LocalDate joined, int no) implements Comparable<Staff> {
private static final Comparator<Staff> ORDER =
Comparator.comparing(Staff::dept)
.thenComparing(Staff::joined)
.thenComparingInt(Staff::no);
@Override
public int compareTo(Staff o) {
return ORDER.compare(this, o);
}
}
// 3) 降順(点数の高い順が自然順序)
record Score(String name, int point) implements Comparable<Score> {
private static final Comparator<Score> ORDER =
Comparator.comparingInt(Score::point).reversed()
.thenComparing(Score::name);
@Override
public int compareTo(Score o) {
return ORDER.compare(this, o);
}
}
// 4) null になり得るフィールド(期限なしは最後)
record Task(String title, LocalDate due) implements Comparable<Task> {
private static final Comparator<Task> ORDER =
Comparator.comparing(Task::due, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Task::title);
@Override
public int compareTo(Task o) {
return ORDER.compare(this, o);
}
}
// 5) Comparator を使わず手書きする形
record Version(int major, int minor) implements Comparable<Version> {
@Override
public int compareTo(Version o) {
int c = Integer.compare(major, o.major);
if (c != 0) return c;
return Integer.compare(minor, o.minor);
}
}
public static void main(String[] args) {
List<Point> points = new ArrayList<>(List.of(new Point(3), new Point(1), new Point(2)));
Collections.sort(points);
System.out.println("1) " + points);
List<Staff> staff = new ArrayList<>(List.of(
new Staff("開発", LocalDate.of(2024, 4, 1), 12),
new Staff("営業", LocalDate.of(2025, 4, 1), 3),
new Staff("開発", LocalDate.of(2023, 10, 1), 20),
new Staff("開発", LocalDate.of(2024, 4, 1), 7)));
Collections.sort(staff);
System.out.println("2)");
staff.forEach(s -> System.out.println(" " + s));
List<Score> scores = new ArrayList<>(List.of(
new Score("Abe", 70), new Score("Sato", 90), new Score("Kato", 90)));
Collections.sort(scores);
System.out.println("3) " + scores);
List<Task> tasks = new ArrayList<>(List.of(
new Task("資料作成", null),
new Task("見積もり", LocalDate.of(2026, 10, 10)),
new Task("会議準備", LocalDate.of(2026, 10, 5))));
Collections.sort(tasks);
System.out.println("4)");
tasks.forEach(t -> System.out.println(" " + t));
Version[] versions = {new Version(1, 10), new Version(1, 2), new Version(0, 9)};
Arrays.sort(versions);
System.out.println("5) " + Arrays.toString(versions));
// その場で順序を変える(Comparator)
List<Staff> byNo = new ArrayList<>(staff);
byNo.sort(Comparator.comparingInt(Staff::no));
System.out.println("番号順 : " + byNo.stream().map(Staff::no).toList());
byNo.sort(Comparator.reverseOrder());
System.out.println("自然順序の逆: " + byNo.stream().map(Staff::no).toList());
}
}
1) [Point[value=1], Point[value=2], Point[value=3]]
2)
Staff[dept=営業, joined=2025-04-01, no=3]
Staff[dept=開発, joined=2023-10-01, no=20]
Staff[dept=開発, joined=2024-04-01, no=7]
Staff[dept=開発, joined=2024-04-01, no=12]
3) [Score[name=Kato, point=90], Score[name=Sato, point=90], Score[name=Abe, point=70]]
4)
Task[title=会議準備, due=2026-10-05]
Task[title=見積もり, due=2026-10-10]
Task[title=資料作成, due=null]
5) [Version[major=0, minor=9], Version[major=1, minor=2], Version[major=1, minor=10]]
番号順 : [3, 7, 12, 20]
自然順序の逆: [12, 7, 20, 3]
| パターン | 使う部品 | ポイント |
|---|---|---|
| 1キー | Integer.compare、a.compareTo(b) |
型に合ったcompareを1回呼ぶだけ |
| 複数キー | Comparator.comparing(...).thenComparing(...) |
比較ルールを static final の定数にして、compareToから呼ぶ |
| 降順 | .reversed() |
reversedは「そこまでの比較全体」を逆にする。その後ろに足したキーは昇順のまま |
| nullを含む | Comparator.nullsLast(...)、Comparator.nullsFirst(...) |
フィールドがnullでも止まらず、位置を決められる |
| 手書き | if (c != 0) return c; |
先に比べたキーで差があれば、そこで結果を返す |
バージョンの例では 1.2 が 1.10 より前に来ています。文字列の "1.10" と "1.2" を比べると逆になるので、数値として持って比べるのがポイントです。
nullの扱い
ComparableのAPIドキュメントには、e.compareTo(null) は NullPointerException をスローすべき、と書かれています。標準クラスの動きを確かめます。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
public class NullDemo {
public static void main(String[] args) {
try {
"a".compareTo(null);
} catch (NullPointerException e) {
System.out.println("compareTo(null) -> " + e.getClass().getSimpleName());
}
List<String> list = new ArrayList<>(Arrays.asList("b", null, "a"));
try {
Collections.sort(list);
} catch (NullPointerException e) {
System.out.println("nullを含むリストのsort -> " + e.getClass().getSimpleName());
}
try {
new TreeSet<String>().add(null);
} catch (NullPointerException e) {
System.out.println("TreeSet.add(null) -> " + e.getClass().getSimpleName());
}
list.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println("nullsFirst: " + list);
list.sort(Comparator.nullsLast(Comparator.reverseOrder()));
System.out.println("nullsLast + 降順: " + list);
}
}
compareTo(null) -> NullPointerException
nullを含むリストのsort -> NullPointerException
TreeSet.add(null) -> NullPointerException
nullsFirst: [null, a, b]
nullsLast + 降順: [b, a, null]
リストの要素そのものがnullになり得るときは、自然順序ではなく Comparator.nullsFirst/nullsLast で包んだComparatorを渡します。 自分のcompareToの中では、引数がnullのときに特別な値を返す処理は入れず、NullPointerExceptionのままにしておくのがドキュメントの方針に沿った書き方です。
compareToが守る規約
APIドキュメントに書かれている規約を、言葉に直して整理します。
| 規約 | ドキュメントの記載 | 守れていないと起きること |
|---|---|---|
| 逆にすると符号が反転する | すべてのx、yで signum(x.compareTo(y)) == -signum(y.compareTo(x)) | 並び順が崩れる、IllegalArgumentException |
| 推移性 | x.compareTo(y) が正、かつ y.compareTo(z) が正なら、x.compareTo(z) も正 | 並び順が崩れる、IllegalArgumentException |
| nullとの比較 | e.compareTo(null) は NullPointerException をスローすべき | ― |
| equalsとの整合(強い推奨) | compareToが0になる組と、equalsがtrueになる組を一致させる | TreeSet・TreeMapで要素が減る、上書きされる |
引き算のcompareToは、オーバーフローしたときに1つ目と2つ目の規約を満たさなくなります。Integer.compare や Comparator.comparing で組み立てると、これらの規約を自然に満たせます。
標準クラスの自然順序一覧
よく使う標準クラスを実際に並べ替えた結果です。
String : [, 10, 9, Apple, apple, banana, あ, ア]
Integer : [-1, 0, 9, 10]
Double : [-1.5, -0.0, 0.0, Infinity, NaN]
Boolean : [false, true]
Character : [1, A, a, b]
BigDecimal : [1.5, 2.00, 2.0]
LocalDate : [2025-12-31, 2026-10-04]
LocalTime : [08:30, 09:00]
LocalDateTime : [2025-01-01T12:00, 2026-01-01T00:00]
Duration : [PT1H, PT1H30M]
enum : [MONDAY, FRIDAY, SUNDAY]
StringBuilder : [a, b]
UUID : [00000000-0000-0000-0000-000000000001, 00000000-0000-0000-0000-000000000002]
| クラス | 自然順序(実行結果から) | 気をつける点 |
|---|---|---|
| String | 文字コードの順。空文字 → 数字 → 大文字 → 小文字 → ひらがな → カタカナ | "10" が "9" より前。数値として並べたいときは数値に変換する。大文字小文字を区別しない比較は compareToIgnoreCase("Apple" と "apple" で0) |
| Integer・Long | 数値の小さい順 | ― |
| Double | 数値の小さい順。-0.0 は 0.0 より前、NaN は最後 | 0.0 == -0.0 は true だが、Double.compare(0.0, -0.0) は 1 |
| Boolean | false → true | ― |
| Character | 文字コードの順(数字 → 大文字 → 小文字) | ― |
| BigDecimal | 数値の小さい順。2.0 と 2.00 は同じ順位 | equalsでは2.0と2.00が別(前述) |
| LocalDate・LocalTime・LocalDateTime | 早い順 | ― |
| Duration | 短い順 | ― |
| enum | 定義した順(DayOfWeekはMONDAYが先頭) | 名前のアルファベット順ではない |
| StringBuilder | 内容の文字コード順 | ― |
| UUID | 実行結果では末尾が1のものが先 | ― |
ComparableとComparatorの使い分け
| こんなとき | 選ぶもの | 理由 |
|---|---|---|
| そのクラスに「誰が見ても同じ」順序が1つある(番号、日付、バージョン) | Comparable | sort・min・max・TreeSetが引数なしで使える |
| 同じクラスを、画面ごとに別の順序で並べたい | Comparator | 順序をいくつでも用意できる |
| 自分では変更できないクラス(ライブラリのクラス)を並べたい | Comparator | クラスに手を入れずに済む |
| 順序に使うキーが一部のフィールドだけで、equalsと食い違う | Comparator | 自然順序にするとTreeSetで要素が減るため、リストのソート用に留める |
| nullを含むリストを並べたい | Comparator | nullsFirst/nullsLast で位置を決められる |
| 一時的に逆順にしたい | Comparator | Comparator.reverseOrder() で自然順序を逆にできる |
| 自然順序も持たせつつ、別の順序も使いたい | 両方 | Comparableで標準を決め、必要な場面でComparatorを渡す |
迷ったときの目安は、「TreeSetに入れても困らない順序か」です。compareToが0になる組が「同じもの」として扱われても問題なければComparable、そうでなければComparatorにします。
実装前のチェックリスト
- [ ]
implements Comparable<自分のクラス>と型引数を書いた。 - [ ] 数値の比較に引き算を使わず、
Integer.compare・Long.compare・Double.compareを使った。 - [ ] compareToが0を返す組と、equalsがtrueになる組が一致している。
- [ ] 複数キーは
Comparator.comparing(...).thenComparing(...)の定数にまとめた。 - [ ] nullになり得るフィールドは
nullsFirst/nullsLastで位置を決めた。 - [ ] テストデータは2件以上、負の値や同じ順位の値も含めた。
よくある質問
Q. compareToは必ず -1・0・1 を返す必要がありますか?
A. いいえ。負の数・0・正の数であれば値は問いません。実際に "a".compareTo("d") は -3 を返します。呼び出す側は < 0、== 0、> 0 で判定します。
Q. recordでもComparableを実装できますか?
A. できます。record Point(int value) implements Comparable<Point> のように宣言し、本体にcompareToを書きます。recordはequalsが全フィールドで自動生成されるので、compareToも全フィールドを比べる形にすると、equalsと整合した自然順序になります。
Q. ComparableとComparatorを両方使うと、どちらが優先されますか?
A. Comparatorを渡したAPIでは、そのComparatorの順序が使われます。テンプレート集の実行でも、部署順の自然順序を持つStaffを sort(Comparator.comparingInt(Staff::no)) で番号順に並べ替えられました。
Q. 降順を自然順序にしてもよいですか?
A. 可能です(テンプレート3)。ただし「点数の高い順」のように用途がはっきりした順序は、自然順序は昇順のままにして、使う側で Comparator.reverseOrder() や .reversed() を指定するほうが、読む人にとって予想しやすくなります。
まとめ
- Comparableはクラス自身の自然順序、Comparatorは外から渡す比較ルール。 自然順序は1つ、Comparatorはいくつでも作れる。
- compareToは引き算で書かない。
-2000000000 - 2000000000が正の数になり順序が崩れる。Integer.compare・Long.compare・Double.compareを使う。 - compareToが0を返す組は、TreeSet・TreeMapで同じ要素として扱われる。 equalsと結果をそろえると、要素が減る・値が上書きされるといった動きを防げる。
- Comparable未実装のクラスを自然順序で並べ替えると、「cannot be cast to class java.lang.Comparable」というClassCastExceptionになる。
Collections.sort(list)ならコンパイル時に気づける。 - 複数キー・降順・nullは、Comparatorの定数を作ってcompareToから呼ぶと短く正確に書ける。
- 型引数(
Comparable<自分のクラス>)を必ず書く。 キャストが不要になり、型の取り違えをコンパイラが見つけてくれる。 - 標準クラスの自然順序にも特徴がある。 Stringは文字コード順で
"10"が"9"より前、enumは定義順、BigDecimalは2.0と2.00が同じ順位。
参考・出典
- Java SE 21 API ドキュメント Comparable https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Comparable.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java Comparableの実装とComparatorとの違い」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。