Comparator:comparing と thenComparing で複数基準ソート
1. 概要
実際のソート問題は、ほとんどの場合、単一の基準だけではありません。例えば、注文を優先度でソートして、優先度が同じ場合は合計金額の高い順にソートして、それでも同じ場合は古い順にソートするなど。
このセクションでは、Comparator.comparing と thenComparing を使ってそのような comparator を構築する方法を説明します。null の処理方法、reversed() の正しい使い方、そして comparator で起こりやすいエラーについても学びます。
2. データの準備
注文用の単純な record を使用します:
import java.time.LocalDate;
public record Order(String customer, int priority, double total, LocalDate createdAt) {
}
そしてサンプルリスト。初期順序はAn、Binh、Chi、Dung、Emです:
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class SampleData {
public static List<Order> orders() {
return new ArrayList<>(List.of(
new Order("An", 2, 150.0, LocalDate.of(2026, 10, 1)),
new Order("Binh", 1, 300.0, LocalDate.of(2026, 10, 3)),
new Order("Chi", 2, 300.0, LocalDate.of(2026, 10, 2)),
new Order("Dung", 1, 300.0, LocalDate.of(2026, 10, 1)),
new Order("Em", 2, 150.0, LocalDate.of(2026, 9, 30))
));
}
}
ArrayList<>(...) でラップしているのは、List.of は不変リストを返すためです。そのリストに対して sort を呼び出すと UnsupportedOperationException がスローされます。
3. 単一基準の Comparator と comparing
3.1. comparing とメソッド参照
Comparator.comparing はキー抽出関数(key extractor)を受け取ります。この関数は Comparable な値を返し、comparator がそれらのキーを比較します:
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparing(Order::customer));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Binh, Chi, Dung, Em]
Order::customer は String を返します。String は Comparable をすでに実装しているため、comparator は文字列の自然な順序を使用します。
3.2. comparingInt、comparingLong、comparingDouble
プリミティブ型のキーには、特化した変種を使用してください。毎回 Integer や Double にボックス化される必要がありません:
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparingDouble(Order::total));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Em, Binh, Chi, Dung]
AnとEmは両方とも合計が150です。List.sort が TimSort を使用しており、これは安定な(stable)ソートアルゴリズムであるため、初期順序を保持します。comparator で等しい要素は位置が入れ替わりません。
3.3. キー用 comparator を伴う comparing
comparing の2番目のオーバーロードは、キーを比較するための comparator をさらに受け取ります。これにより、単一の基準のみ降順でソートできます:
orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));
このコマンドは最新の注文を最初に並べます。この書き方は複数の基準を組み合わせるときに非常に便利です。次のセクションで見ることになります。
4. thenComparing で複数基準をソート
thenComparing は、前の comparator が 0 を返すとき、つまり2つの要素が前の基準で同じときにだけ使用されます。元の要件に戻りましょう:
priority昇順(1が最優先度)total降順createdAt昇順(古い注文が先)
Comparator<Order> byBusinessRule = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
List<Order> orders = SampleData.orders();
orders.sort(byBusinessRule);
System.out.println(orders.stream().map(Order::customer).toList());
[Dung, Binh, Chi, Em, An]
ステップバイステップは以下の通りです:
priority = 1のグループはBinhとDungで構成されています。両方ともtotal = 300なので、日付の基準で決まります。Dung(10/01)はBinh(10/03)の前に来ます。priority = 2のグループはAn、Chi、Emで構成されています。Chiは300なのでグループの最初に来ます。AnとEmは両方とも150なので、Em(09/30)はAn(10/01)の前に来ます。
2行目に注目してください。reversed() は total の独立した comparator に対して呼ばれています。このコマンドは全体のチェーンに適用されません。ここが多くの人が間違う場所です。
5. reversed() の落とし穴
reversed() は、それが呼び出された全体の comparator を反転させます。次のコードを見てください:
Comparator<Order> wrong = Comparator
.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed();
List<Order> orders = SampleData.orders();
orders.sort(wrong);
System.out.println(orders.stream().map(Order::customer).toList());
[Chi, An, Em, Binh, Dung]
著者は「priorityは昇順、totalは降順」と望んでいます。しかし結果は両方とも降順になります:priority = 2 のグループがトップに押し出されます。理由は、reversed() が2つの基準から組み合わせられた comparator 全体に適用されるからです。
正しい書き方は2つあります。自分にとって読みやすい方を選んでください:
// 方法1:キーの comparator を別に作り、その後反転する
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed());
// 方法2:キーの comparator を thenComparing に渡す
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
.thenComparing(Order::total, Comparator.reverseOrder());
方法2はより短いですが、double を Double にボックス化する必要があります。数千要素では、この違いは無視できます。大規模なデータを継続的にソートする場合は、方法1を優先してください。
注意: チェーンの末尾で
reversed()を呼び出すことは、本当にすべての基準を反転したい場合にのみ正しいです。1つの基準だけを反転したい場合は、その基準で直接反転してください。
6. null 値の処理
6.1. キーが null の可能性
Comparator.comparing(Customer::nickname) は、nickname が null の場合に NullPointerException をスローします。キーの comparator を nullsFirst または nullsLast でラップしてください:
public record Customer(String nickname) {
}
List<Customer> customers = new ArrayList<>(List.of(
new Customer("lan"),
new Customer(null),
new Customer("An"),
new Customer("binh"),
new Customer("Cuong")
));
customers.sort(Comparator.comparing(
Customer::nickname,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
));
System.out.println(customers.stream().map(Customer::nickname).toList());
[An, binh, Cuong, lan, null]
この例は String.CASE_INSENSITIVE_ORDER を使用しているため、大文字と小文字が同等に扱われます。Comparator.naturalOrder() を使用した場合、結果は [An, Cuong, binh, lan, null] になります。理由は、String の自然な順序が Unicode コード ポイントに従い、大文字が小文字の前に来るためです。
6.2. 要素そのものが null の可能性
リスト内の要素自体が null の可能性がある場合は、全体の comparator を最外層でラップしてください:
Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);
この2つのケースは異なります。キーのラップは要素の null から保護せず、要素のラップはキーの null から保護しません。
7. Lambda での型推論エラー
次のコードはコンパイルできません:
// エラー:cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
.thenComparing(Order::createdAt);
コンパイラはメソッド呼び出しチェーンの左から右に型推論を行います。comparing(...) の呼び出しでは、最終的な目標が Comparator<Order> であることをまだ知らないため、パラメータ o は Object と推論されます。
3つの修正方法があります:
Comparator.comparing(Order::customer) // メソッド参照
Comparator.comparing((Order o) -> o.customer()) // パラメータ型の宣言
Comparator.<Order, String>comparing(o -> o.customer()) // 型ウィットネス
メソッド参照が最も簡潔な方法です。可能な限りそれを使用してください。
8. Comparator のテスト作成
Comparator はビジネスロジックを含んでいるため、個別にテストする価値があります。依存関係を追加してください:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Comparator を固定の場所に配置して、簡単に再利用できるようにします:
import java.util.Comparator;
public final class OrderComparators {
public static final Comparator<Order> BY_BUSINESS_RULE = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
private OrderComparators() {
}
}
次にテストを書きます:
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderComparatorsTest {
@Test
void givenOrders_whenSortByBusinessRule_thenPriorityAscTotalDescDateAsc() {
List<Order> orders = SampleData.orders();
orders.sort(OrderComparators.BY_BUSINESS_RULE);
assertThat(orders)
.extracting(Order::customer)
.containsExactly("Dung", "Binh", "Chi", "Em", "An");
assertThat(orders).isSortedAccordingTo(OrderComparators.BY_BUSINESS_RULE);
}
@Test
void givenReversedAtEnd_whenSort_thenAllCriteriaAreReversed() {
List<Order> orders = SampleData.orders();
orders.sort(java.util.Comparator.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed());
assertThat(orders.getFirst().priority()).isEqualTo(2);
}
}
containsExactly は結果の正確な順序をチェックします。isSortedAccordingTo はリストが指定された comparator に従ってソートされていることを確認します。2番目のテストはチェーンの最後に reversed() を使う動作を記録しているため、他の人がこの間違いを繰り返さなくなります。getFirst() は Java 21 の List の新しいメソッドです。
9. よくあるエラーと使うべきでない場合
9.1. 減算による比較
// このようにしないでください
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();
減算はオーバーフローする可能性があります。例えば Integer.MIN_VALUE - 1 は大きな正の数になり、比較結果が間違ってしまいます。Comparator.comparingInt(...) または Integer.compare(a, b) を使用してください。
9.2. TreeSet/TreeMap の equals と矛盾する Comparator
TreeSet は、comparator が 0 を返すときに2つの要素を同じと見なします。equals を使って重複をテストしません:
Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());
System.out.println(set.size());
2
5つの注文はわずか2つになり、各 priority に1つのみになります。TreeSet または TreeMap に comparator を使用する場合は、最後の基準として注文番号などのユニークな基準を追加してください。その場合、comparator は実際に同一の要素に対してのみ 0 を返します。
9.3. comparing チェーンを使うべきでない場合
- 基準が相互に依存している。 例えば、「VIP注文は合計金額で比較し、通常の注文は日付で比較」。
ifを使う lambda 形式の comparator を書く方が、複雑に曲げられたthenComparingチェーンより明確です。 - キーの計算がコストがかかる。 キー抽出器は比較のたびに、約
n log n回呼び出されます。キーが文字列をパースする必要がある場合や、サービスを呼び出す場合は、1回だけキーを計算してからソートしてください。 - データベースでソートできる。 データが SQL クエリから来た場合、
ORDER BYは通常より高速で、メモリに過度なデータを読み込むことを回避できます。
10. まとめ
このセクションでは、以下の方法を学びました:
- キーから comparator を作成するために
comparing、comparingInt、comparingDoubleを使用する方法。 thenComparingを使って複数の基準を組み合わせ、reversed()またはComparator.reverseOrder()で1つの基準の方向だけを反転する方法。- キーレベルと要素レベルの両方で、
nullsFirstとnullsLastを使ってnullを処理する方法。 - Lambda での型推論エラー、減算でのオーバーフローエラー、および
TreeSetでの要素の喪失エラーを回避する方法。
ビジネス comparator を明確に名前の付いた定数に配置し、常に期待される順序を確認するテストを含めてください。ソートルールが変わると、テストがすぐに知らせてくれます。