Comparable 与 Comparator 比较器 | JavaSE

Comparable 与 Comparator 比较器

一、学习目标

完成本章后,你应该能够:

  • 能够解释为什么 TreeSet、TreeMap 和排序操作必须获得明确的比较规则。
  • 能够区分自然排序(Natural Ordering)与比较器排序(Comparator Ordering)。
  • 能够独立让自定义类实现 Comparable<T> 并正确重写 compareTo()
  • 能够使用 Comparator<T> 为同一种对象定义不同排序规则。
  • 能够准确解释比较结果为负数、0、正数的语义。
  • 能够设计“主条件 + 次条件 + 第三条件”的多字段排序规则。
  • 能够解释为什么 TreeSet 中 compare(...) == 0 会直接影响元素去重。
  • 能够避免直接使用整数相减实现比较所产生的溢出风险。
  • 能够判断何时应该使用 Comparable,何时更适合 Comparator。

二、核心知识

2.1 为什么需要比较规则

上一章已经发现:

TreeSet<Student> students = new TreeSet<>();

如果 Student 没有任何比较能力,那么 TreeSet 会面临一个问题:

张三应该放在李四前面还是后面?

这不是 Java 能够自动决定的。

例如 Student:

class Student {
    private String name;
    private int age;
    private double score;
}

至少可以设计出很多排序方法:

按年龄升序
按年龄降序
按成绩升序
按成绩降序
按姓名字典序
按成绩降序,成绩相同再按年龄升序

所以:

排序的本质,是先定义对象之间的大小关系。

Java 为此提供两套核心机制:

Comparable
Comparator

2.2 Comparable:对象自己的自然排序规则

Comparable<T> 位于:

java.lang

因此不需要手动导包。

它的核心方法:

int compareTo(T other);

如果:

class Student implements Comparable<Student>

就意味着 Student 类在告诉 Java:

“Student 对象自己知道 Student 与 Student 应该怎样比较。”

这套规则称为:

自然排序(Natural Ordering)


2.3 Comparable 的基本结构

例如规定:

Student 默认按照年龄升序排列。

public class Student implements Comparable<Student> {

    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.age, other.age);
    }
}

之后:

TreeSet<Student> students = new TreeSet<>();

TreeSet 就能够调用:

student1.compareTo(student2)

判断两个 Student 的先后顺序。


2.4 compareTo 返回值是什么意思

不要死记必须返回:

-1
0
1

Java 真正规定的是:

负整数
0
正整数

含义如下:

| 返回结果 | 含义 | | -------- | ---------------------------- | | < 0 | 当前对象小于参数对象 | | == 0 | 两个对象在这套比较规则下等价 | | > 0 | 当前对象大于参数对象 |

例如:

Integer.compare(this.age, other.age);

如果:

this.age = 18
other.age = 20

得到负数,说明:

当前对象应排在 other 前面

2.5 为什么不推荐直接相减

初学代码经常写:

return this.age - other.age;

对于年龄这种小范围数据,通常看不出问题。

但是假设:

int a = Integer.MAX_VALUE;
int b = -1;

执行:

a - b

就可能产生整数溢出。

因此正式代码更推荐:

Integer.compare(a, b);

对于:

long

使用:

Long.compare(a, b);

对于:

double

使用:

Double.compare(a, b);

这样更加可靠。


2.6 Comparator:从对象外部提供比较规则

Comparable 的问题在于:

比较规则被定义在类自己内部。

但是现实业务中,同一种对象可能存在很多种排序方式。

例如 Student:

学生列表页面
→ 按年龄排序

成绩排行榜
→ 按成绩排序

学生通讯录
→ 按姓名排序

如果只能在 Student 中定义一个:

compareTo()

显然不够灵活。

于是 Java 提供:

Comparator<T>

Comparator 位于:

java.util

核心方法:

int compare(T o1, T o2);

它表达的是:

由集合、排序操作或调用方从对象外部提供一套比较规则。


2.7 Comparator 基本写法

例如 Student 本身没有实现 Comparable:

public class Student {
    private String name;
    private int age;
    private double score;

    // 构造器、getter、toString...
}

创建 TreeSet 时指定:

Comparator<Student> comparator = new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return Double.compare(o2.getScore(), o1.getScore());
    }
};

TreeSet<Student> students = new TreeSet<>(comparator);

这里表示:

按照成绩降序

因为比较顺序反过来写成:

Double.compare(o2.getScore(), o1.getScore())

2.8 Comparable 与 Comparator 的核心区别

| 对比项 | Comparable | Comparator | | -------------------------- | ---------------- | --------------------- | | 包 | java.lang | java.util | | 核心方法 | compareTo(T o) | compare(T o1, T o2) | | 比较规则位置 | 类内部 | 类外部 | | 表达含义 | 自然排序 | 外部指定排序 | | 是否修改原类 | 通常需要 | 不需要 | | 一个类能否方便拥有多种规则 | 不方便 | 非常适合 | | TreeSet 使用方式 | 无参构造即可 | 构造器传入 Comparator |

可以简单理解成:

Comparable
= 我自己规定“我天生应该怎样排序”

Comparator
= 别人规定“这次我要怎样排序”

2.9 如果两种规则同时存在怎么办

假设:

class Student implements Comparable<Student>

Student 自己规定:

年龄升序

但创建 TreeSet 时又传:

Comparator<Student>

规定:

成绩降序

那么:

TreeSet<Student> students =
        new TreeSet<>(scoreComparator);

此时使用的是:

TreeSet 构造时显式提供的 Comparator。

可以理解成:

显式 Comparator
优先于
元素自己的自然排序

而:

new TreeSet<>()

没有 Comparator 时,才使用元素的自然排序。


三、使用方法

3.1 Comparable:按年龄升序

public class Student implements Comparable<Student> {

    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.age, other.age);
    }
}

测试:

TreeSet<Student> students = new TreeSet<>();

students.add(new Student("张三", 20));
students.add(new Student("李四", 18));
students.add(new Student("王五", 22));

TreeSet 使用:

compareTo()

维护自然顺序。


3.2 一个非常严重的问题:年龄相同怎么办

假设:

张三 20
李四 20

而:

compareTo()

只有:

return Integer.compare(this.age, other.age);

那么:

20 与 20
↓
compareTo == 0

TreeSet 会认为:

两个元素在当前排序规则中等价。

于是第二个学生可能无法进入集合。

这就是:

排序规则同时决定 TreeSet 的元素唯一性。


3.3 多条件排序

如果需求是:

年龄升序
年龄相同时按姓名排序

可以:

@Override
public int compareTo(Student other) {

    int result = Integer.compare(this.age, other.age);

    if (result != 0) {
        return result;
    }

    return this.name.compareTo(other.name);
}

逻辑是:

先比较年龄
    ↓
年龄不同
→ 直接得到结果

年龄相同
    ↓
继续比较姓名

这就是:

多级排序(Multi-level Ordering)


3.4 三个排序条件

例如需求:

成绩降序
成绩相同 → 年龄升序
年龄相同 → 姓名字典序

可以写:

Comparator<Student> comparator = new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {

        int result =
                Double.compare(o2.getScore(), o1.getScore());

        if (result != 0) {
            return result;
        }

        result =
                Integer.compare(o1.getAge(), o2.getAge());

        if (result != 0) {
            return result;
        }

        return o1.getName().compareTo(o2.getName());
    }
};

然后:

TreeSet<Student> students =
        new TreeSet<>(comparator);

这是你必须真正掌握的比较器思维。


3.5 Comparator 的 Lambda 写法

因为 Comparator 是函数式接口,所以可以写:

Comparator<Student> comparator =
        (o1, o2) ->
                Double.compare(
                        o2.getScore(),
                        o1.getScore()
                );

本章重点是理解:

Comparator 在表达什么比较规则

Lambda 的系统语法、省略规则和进一步实战会在后面的 Lambda 专章继续学习。


3.6 Comparator 的现代组合能力

Java 提供了一系列 Comparator 工具方法。

例如:

Comparator<Student> comparator =
        Comparator.comparingDouble(
                student -> student.getScore()
        );

还可以继续组合:

Comparator<Student> comparator =
        Comparator
                .comparingDouble(
                        student -> student.getScore()
                )
                .thenComparingInt(
                        student -> student.getAge()
                );

其中:

thenComparing(...)

表示:

前面的比较条件无法区分两个对象时,再使用下一条件。

这和我们手写:

if (result != 0) {
    return result;
}

本质上解决同一个问题。


3.7 reversed()

Comparator 还可以:

comparator.reversed();

得到相反顺序的 Comparator。

例如:

Comparator<Integer> comparator =
        Integer::compare;

反向后就是对应的逆序。

这里先理解 API 能力即可,方法引用的完整语法将在后面专门学习。


四、原理与进阶

4.1 Comparable 表达的是全序关系

Comparable 并不是:

随便返回一个正负数就结束

一个正确的比较规则应该保持逻辑稳定。

例如:

A > B
B > C

那么正常应该有:

A > C

这称为:

传递性(Transitivity)

否则 TreeSet、排序算法等结构就无法可靠工作。


4.2 反向比较必须保持一致

如果:

compare(A, B) > 0

那么:

compare(B, A)

应该具有相反符号。

不能出现:

A > B
同时
B > A

这种自相矛盾的比较规则。


4.3 compare == 0 的含义非常重要

如果:

compare(a, b) == 0

就表示:

a 和 b 在当前比较关系中属于同一个排序等价类。

对于:

TreeSet
TreeMap

这个结果会直接影响:

是否认为元素/键已经存在

所以 Comparator 不能只考虑:

“排出来看着顺眼”

还必须考虑:

“返回 0 会不会错误吞掉合法对象”

4.4 排序最好与 equals 保持一致

Java 官方建议:

compare(a, b) == 0

最好和:

a.equals(b)

表达相同的逻辑相等关系。

也就是:

比较认为相同
≈
equals 也认为相同

否则:

HashSet

与:

TreeSet

对同一组对象的“重复”判断可能出现不同结果。

这并不意味着所有不一致设计都无法运行,而是这种设计容易导致违反一般 Set 语义直觉的行为。


4.5 排序字段必须谨慎设计

例如 User:

id
name
score

如果 Comparator 只有:

Double.compare(u2.score, u1.score)

那么所有分数相同的用户都会:

compare == 0

放进 TreeSet 时就可能只保留其中一个。

如果业务要求:

同分用户必须全部存在。

就要增加可以继续区分用户的字段,例如:

score
↓
name
↓
id

最终保证真正不同的用户不会因为排序条件过少而被错误视为相同。


4.6 Comparable 更适合稳定的自然顺序

例如:

日期
数字
字符串
版本号

这些对象通常存在一个相对自然、稳定的默认顺序。

此时 Comparable 很合理。

但是:

Student
Product
User
Order

这类业务对象可能需要:

年龄排序
成绩排序
价格排序
创建时间排序
销量排序
名称排序

这种情况下 Comparator 通常更灵活。


五、实践应用

5.1 成绩排行榜

需求:

成绩降序
成绩相同按学号升序

非常适合:

Comparator<Student>

因为“成绩排名”通常只是 Student 的某一个业务视图,而不一定代表 Student 的天然顺序。


5.2 商品列表

Product:

name
price
sales
rating

前端可能要求:

价格升序
价格降序
销量降序
评分降序

这些本质上就是:

同一种对象对应多套 Comparator。


5.3 Comparable 与 Comparator 选型

可以先使用下面的判断法:

这个顺序是不是对象稳定、天然、默认的顺序?
        ↓
       是
        ↓
   Comparable

需要临时排序?
需要多套排序?
不能修改原类?
        ↓
       是
        ↓
   Comparator

六、常见问题

6.1 compareTo 必须只返回 -1、0、1 吗?

不是。

只要求:

负数
0
正数

分别表达:

小于
等于
大于

6.2 为什么不推荐直接 o1.age - o2.age

因为整数相减可能发生:

overflow

更安全:

Integer.compare(o1.getAge(), o2.getAge());

6.3 Comparable 与 Comparator 谁更高级?

不存在谁“更高级”。

它们解决的问题不同:

Comparable
→ 自然顺序

Comparator
→ 外部指定顺序

6.4 一个类可以有几个 Comparable 规则?

从:

compareTo()

这个自然比较入口来看,一个类通常只能定义一套自然排序。

但可以创建任意多套:

Comparator

6.5 TreeSet 同时存在 Comparable 和 Comparator 时用谁?

如果创建 TreeSet 时显式传入 Comparator:

new TreeSet<>(comparator)

使用 Comparator。

如果没有:

new TreeSet<>()

则使用元素的自然排序。


6.6 为什么我的 TreeSet 少了几个对象?

第一件事就检查:

compareTo()

或者:

Comparator.compare()

是不是对两个业务上不同的对象返回了:

0

因为 TreeSet 会因此认为它们在排序意义上相同。


6.7 Comparator 能用于 List 吗?

可以。

Comparator 并不是 TreeSet 专属。

它也可以用于:

List 排序
数组排序
TreeSet
TreeMap
其他需要排序规则的 API

本章先围绕集合排序建立模型即可。


七、练习与验收

7.1 知识问答

  1. Comparable 解决什么问题?
  2. Comparator 解决什么问题?
  3. 什么叫自然排序?
  4. compareTo() 返回负数、0、正数分别代表什么?
  5. compare()compareTo() 的参数数量为什么不同?
  6. Comparable 与 Comparator 的核心区别是什么?
  7. TreeSet 同时存在 Comparator 和 Comparable 时使用哪一个?
  8. 为什么不推荐直接使用整数相减完成比较?
  9. 什么叫多级排序?
  10. 为什么比较结果为 0 会影响 TreeSet 去重?
  11. 为什么排序关系最好与 equals 保持一致?
  12. 什么场景更适合 Comparable?
  13. 什么场景更适合 Comparator?
  14. Comparator 为什么特别适合“一种对象,多种排序”?

7.2 代码阅读

阅读:

@Override
public int compareTo(Student other) {

    int result =
            Integer.compare(this.age, other.age);

    if (result != 0) {
        return result;
    }

    return this.name.compareTo(other.name);
}

回答:

  1. 第一排序条件是什么?
  2. 升序还是降序?
  3. 什么情况下继续比较姓名?
  4. 如果年龄和姓名都相同,会返回什么?
  5. 放入 TreeSet 后意味着什么?

7.3 手写代码

任务一:Comparable

定义:

Student
name
age

要求:

年龄升序
年龄相同按姓名排序

让 Student 自己实现 Comparable。


任务二:Comparator

Student:

name
age
score

要求创建 Comparator:

成绩降序
成绩相同时年龄升序

任务三:三级排序

实现:

成绩降序
↓
年龄升序
↓
姓名字典序

至少构造:

  • 两个成绩相同对象;
  • 两个年龄也相同对象;

验证比较规则能够继续向下区分。


7.4 Debug

下面代码:

Comparator<Student> comparator =
        (s1, s2) -> (int) (s2.getScore() - s1.getScore());

回答:

  1. 为什么这种写法存在问题?
  2. 当两个成绩只差 0.5 时,强转成 int 可能发生什么?
  3. 为什么可能错误返回 0?
  4. TreeSet 收到错误的 0 后会发生什么?
  5. 应该使用什么 API 改写?

7.5 综合训练

设计学生排行榜。

Student:

studentNo
name
age
score

规则:

成绩降序
成绩相同 → 年龄升序
年龄相同 → 姓名字典序
姓名相同 → 学号升序

要求:

  1. 使用 Comparator。
  2. 使用 TreeSet。
  3. 添加至少 8 个学生。
  4. 故意设计多个同分学生。
  5. 保证不同学生不会因为 Comparator 返回 0 被误删。
  6. 分析如果删除最后的学号比较条件,会产生什么风险。

7.6 本章验收

  • [ ] 能独立解释 Comparable 与 Comparator。
  • [ ] 能从零实现 compareTo()
  • [ ] 能从零实现 Comparator.compare()
  • [ ] 能解释负数、0、正数的语义。
  • [ ] 不再使用简单减法作为默认整数比较方案。
  • [ ] 能写两字段比较规则。
  • [ ] 能写三字段以上比较规则。
  • [ ] 能解释 compare == 0 对 TreeSet 的影响。
  • [ ] 能判断 Comparable 与 Comparator 的使用场景。
  • [ ] 能解释显式 Comparator 为什么覆盖自然排序规则。