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つあります。

  1. implements Comparable<Member> と、型引数に自分自身のクラスを書く。
  2. compareTo(Member other) を実装し、負・0・正のいずれかを返す。
  3. 数値の比較には 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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?