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))
));
}
}
我们用 new ArrayList<>(...) 包装是因为 List.of 返回不可变列表。对其调用 sort 会抛出 UnsupportedOperationException。
3. 使用 comparing 构建单条件 Comparator
3.1. comparing 配合方法引用
Comparator.comparing 接收一个键提取器函数。该函数返回一个 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. comparing 配合键的 comparator
comparing 的第二个重载接受一个用于比较键的 comparator。这样你可以只对一个条件进行降序排列:
orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));
这条语句会把最新的订单排到前面。这种写法在组合多个条件时非常有用,你将在下一节看到。
4. 使用 thenComparing 实现多条件排序
thenComparing 只在前一个 comparator 返回 0 时使用,即两个元素在前面的条件上相等时。回到最初的需求:
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)之前。
你注意到第二行:reversed() 被调用在一个单独的 comparator 上用于 total。这个命令不适用于整个链。这正是许多人出错的地方。
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() 被应用到了由两个条件组合而成的 comparator。
有两种正确的写法,选择对你来说更易读的:
// 方法 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()只有在你想要反转所有条件时才是正确的。如果只想反转一个条件,应该在该条件处反转。
6. 处理 null 值
6.1. 键可能是 null
Comparator.comparing(Customer::nickname) 如果遇到 nickname 为 null 会抛出 NullPointerException。你可以用 nullsFirst 或 nullsLast 包装键的 comparator:
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);
这两种情况不同。包装键不能保护你免受元素 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。
有三种修复方法:
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 正确排序。第二个测试记录了链末尾 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. Comparator 在 TreeSet/TreeMap 中与 equals 不一致
TreeSet 认为两个元素相同当 comparator 返回 0 时。它不使用 equals 来判断相等:
Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());
System.out.println(set.size());
2
五个订单只剩下两个,每个优先级一个。如果你为 TreeSet 或 TreeMap 使用 comparator,应该添加一个唯一性的最后条件,例如订单号。这样 comparator 只在元素真正相同时才返回 0。
9.3. 何时不应使用 comparing 链
- 条件相互依赖。 例如:“VIP 订单按总金额排序,普通订单按日期排序”。用 lambda 加
if写 comparator 会比被扭曲的thenComparing链更清楚。 - 键的计算成本高。 键提取器在每次比较时都被调用,大约
n log n次。如果键需要解析字符串或调用服务,应该先计算一次键,再排序。 - 排序可以在数据库中进行。 如果数据来自 SQL 查询,
ORDER BY通常更快,并避免将过多数据加载到内存。
10. 总结
在本文中,你已经学会了:
- 使用
comparing、comparingInt和comparingDouble从键创建 comparator。 - 使用
thenComparing组合多个条件,并使用reversed()或Comparator.reverseOrder()正确反转单个条件。 - 使用
nullsFirst和nullsLast处理null,既可以在键级别也可以在元素级别。 - 避免 lambda 的类型推断错误、减法溢出错误和
TreeSet中的元素丢失错误。
将业务 comparator 作为有明确名称的常量,并始终附带一个测试来验证预期的顺序。当排序规则改变时,测试会立即提醒你。