Comparator: sắp xếp nhiều tiêu chí với comparing và thenComparing

1. Tổng quan

Bài toán sắp xếp thực tế hiếm khi chỉ có một tiêu chí. Ví dụ: sắp xếp đơn hàng theo độ ưu tiên, cùng độ ưu tiên thì tổng tiền cao lên trước, nếu vẫn bằng nhau thì đơn cũ hơn lên trước.

Bài này hướng dẫn bạn dựng các comparator như vậy bằng Comparator.comparing và thenComparing. Bạn cũng sẽ biết cách xử lý null, cách dùng reversed() cho đúng và những lỗi hay gặp với comparator.

2. Chuẩn bị dữ liệu

Ta dùng một record đơn giản cho đơn hàng:

import java.time.LocalDate;

public record Order(String customer, int priority, double total, LocalDate createdAt) {
}

Và một danh sách mẫu. Thứ tự ban đầu là 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))
        ));
    }
}

Ta bọc trong new ArrayList<>(...) vì List.of trả về list bất biến. Gọi sort trên list đó sẽ ném UnsupportedOperationException.

3. Comparator một tiêu chí với comparing

3.1. comparing với method reference

Comparator.comparing nhận một hàm trích khóa (key extractor). Hàm này trả về một giá trị Comparable, rồi comparator so sánh các khóa đó:

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 trả về String. String đã implement Comparable, nên comparator dùng thứ tự tự nhiên của chuỗi.

3.2. comparingInt, comparingLong, comparingDouble

Với khóa kiểu nguyên thủy, hãy dùng các biến thể chuyên biệt. Chúng không phải boxing sang Integer hay Double ở mỗi lần so sánh:

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 và Em cùng có tổng 150. Chúng giữ nguyên thứ tự ban đầu vì List.sort dùng TimSort, một thuật toán sắp xếp ổn định (stable). Phần tử bằng nhau theo comparator không bị đảo vị trí.

3.3. comparing với comparator cho khóa

Overload thứ hai của comparing nhận thêm một comparator dùng để so sánh khóa. Nhờ đó bạn có thể sắp xếp giảm dần chỉ cho một tiêu chí:

orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));

Lệnh này sắp đơn mới nhất lên đầu. Cách viết này rất hữu ích khi kết hợp nhiều tiêu chí, như bạn sẽ thấy ở mục tiếp theo.

4. Nhiều tiêu chí với thenComparing

thenComparing chỉ được dùng khi comparator trước đó trả về 0, tức là hai phần tử hòa nhau ở các tiêu chí trước. Quay lại yêu cầu ban đầu:

  1. priority tăng dần (1 là ưu tiên cao nhất).
  2. total giảm dần.
  3. createdAt tăng dần (đơn cũ hơn lên trước).
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]

Từng bước diễn ra như sau:

  • Nhóm priority = 1 gồm Binh và Dung. Cả hai có total = 300, nên tiêu chí ngày quyết định. Dung (01/10) đứng trước Binh (03/10).
  • Nhóm priority = 2 gồm An, Chi, Em. Chi có 300 nên đứng đầu nhóm. An và Em cùng 150, nên Em (30/09) đứng trước An (01/10).

Bạn để ý dòng thứ hai: reversed() được gọi trên một comparator riêng cho total. Lệnh đó không áp dụng lên cả chuỗi. Đây chính là chỗ nhiều người viết sai.

5. Cái bẫy của reversed()

reversed() đảo ngược toàn bộ comparator mà nó được gọi lên. Xem đoạn code sau:

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]

Người viết muốn “priority tăng dần, total giảm dần”. Kết quả lại là cả hai đều giảm dần: nhóm priority = 2 bị đẩy lên đầu. Lý do là reversed() được áp lên comparator đã ghép từ hai tiêu chí.

Có hai cách viết đúng, chọn cách nào dễ đọc hơn với bạn:

// Cách 1: comparator riêng cho khóa, rồi đảo
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
    .thenComparing(Comparator.comparingDouble(Order::total).reversed());

// Cách 2: truyền comparator của khóa vào thenComparing
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
    .thenComparing(Order::total, Comparator.reverseOrder());

Cách 2 ngắn hơn nhưng phải boxing double sang Double. Với vài nghìn phần tử, khác biệt này không đáng kể. Với dữ liệu lớn sắp xếp liên tục, hãy ưu tiên cách 1.

Lưu ý: Gọi reversed() ở cuối chuỗi chỉ đúng khi bạn thật sự muốn đảo mọi tiêu chí. Nếu chỉ muốn đảo một tiêu chí, hãy đảo ngay tại tiêu chí đó.

6. Xử lý giá trị null

6.1. Khóa có thể null

Comparator.comparing(Customer::nickname) sẽ ném NullPointerException nếu gặp nickname bằng null. Bạn bọc comparator của khóa bằng nullsFirst hoặc 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]

Ví dụ này dùng String.CASE_INSENSITIVE_ORDER, nên chữ hoa và chữ thường được so ngang nhau. Nếu bạn dùng Comparator.naturalOrder(), kết quả sẽ là [An, Cuong, binh, lan, null]. Lý do là thứ tự tự nhiên của String so theo mã Unicode, và chữ hoa đứng trước chữ thường.

6.2. Chính phần tử là null

Nếu bản thân phần tử trong list có thể là null, hãy bọc cả comparator ở ngoài cùng:

Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);

Hai trường hợp này khác nhau. Bọc khóa không bảo vệ bạn khỏi phần tử null, và bọc phần tử cũng không bảo vệ bạn khỏi khóa null.

7. Lỗi suy luận kiểu với lambda

Đoạn code sau không biên dịch được:

// Lỗi: cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
    .thenComparing(Order::createdAt);

Compiler suy luận kiểu từ trái sang phải trong chuỗi gọi phương thức. Ở lời gọi comparing(...), nó chưa biết đích cuối là Comparator<Order>, nên tham số o bị suy ra là Object.

Có ba cách sửa:

Comparator.comparing(Order::customer)                    // method reference
Comparator.comparing((Order o) -> o.customer())          // khai báo kiểu tham số
Comparator.<Order, String>comparing(o -> o.customer())   // type witness

Method reference là cách gọn nhất. Hãy dùng nó bất cứ khi nào có thể.

8. Viết test cho comparator

Comparator chứa quy tắc nghiệp vụ, nên nó đáng được test riêng. Hãy thêm dependency:

<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>

Bạn đặt comparator ở một chỗ cố định để dễ tái sử dụng:

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() {
    }
}

Rồi viết test:

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 kiểm tra thứ tự chính xác của kết quả. isSortedAccordingTo kiểm tra rằng list đã được sắp theo đúng comparator đó. Test thứ hai ghi lại hành vi của reversed() ở cuối chuỗi, để người sau không lặp lại lỗi này. getFirst() là phương thức mới của List trong Java 21.

9. Lỗi hay gặp và khi nào không nên dùng

9.1. So sánh bằng phép trừ

// Đừng làm thế này
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();

Phép trừ có thể tràn số. Ví dụ Integer.MIN_VALUE - 1 cho ra một số dương lớn, nên kết quả so sánh bị sai. Hãy dùng Comparator.comparingInt(...) hoặc Integer.compare(a, b).

9.2. Comparator không nhất quán với equals trong TreeSet/TreeMap

TreeSet coi hai phần tử là trùng nhau khi comparator trả về 0. Nó không dùng equals để xét trùng:

Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());

System.out.println(set.size());
2

Năm đơn hàng chỉ còn lại hai, mỗi priority một đơn. Nếu bạn dùng comparator cho TreeSet hoặc TreeMap, hãy thêm tiêu chí cuối cùng mang tính duy nhất, chẳng hạn mã đơn hàng. Khi đó comparator chỉ trả về 0 với các phần tử thật sự trùng nhau.

9.3. Khi nào không nên dùng chuỗi comparing

  • Tiêu chí phụ thuộc vào nhau. Ví dụ: “đơn VIP so theo tổng tiền, đơn thường so theo ngày”. Viết comparator dạng lambda với if sẽ rõ ràng hơn một chuỗi thenComparing bị ép uốn.
  • Khóa tốn kém khi tính. Key extractor được gọi ở mỗi lần so sánh, khoảng n log n lần. Nếu khóa phải parse chuỗi hoặc gọi service, hãy tính khóa trước một lần rồi mới sắp xếp.
  • Sắp xếp có thể làm ở database. Nếu dữ liệu đến từ truy vấn SQL, ORDER BY thường nhanh hơn và giúp tránh tải dư dữ liệu về bộ nhớ.

10. Kết luận

Trong bài này, bạn đã biết cách:

  • Dùng comparing, comparingInt và comparingDouble để tạo comparator từ một khóa.
  • Ghép nhiều tiêu chí bằng thenComparing, và đảo chiều đúng một tiêu chí bằng reversed() hoặc Comparator.reverseOrder().
  • Xử lý null bằng nullsFirst và nullsLast, ở cả cấp khóa lẫn cấp phần tử.
  • Tránh lỗi suy luận kiểu với lambda, lỗi tràn số khi trừ và lỗi mất phần tử trong TreeSet.

Hãy đặt các comparator nghiệp vụ thành hằng số có tên rõ ràng, và luôn kèm một test kiểm tra thứ tự mong đợi. Khi quy tắc sắp xếp thay đổi, test sẽ báo cho bạn ngay.

javacomparatorsortingstreamcollections