Effective Java 第三版读书笔记——条款14:考虑实现 Comparable 接口
与本章讨论的其他方法不同,compareTo
方法并没有在 Object
类中声明。相反,它是 Comparable
接口中的唯一方法。 通过实现 Comparable
接口,一个类表明它的实例有一个自然序( natural ordering )。对实现 Comparable
接口的对象所组成的数组排序非常简单,如下所示:
Arrays.sort(a);
通过实现 Comparable
接口,可以让你的类与所有依赖此接口的泛型算法和集合实现进行交互操作。Java 平台类库中几乎所有值类以及所有枚举类型(条款 34)都实现了 Comparable
接口。如果你正在编写具有明显自然序(如字母顺序、数字顺序或时间顺序)的值类,则应该实现 Comparable
接口:
public interface Comparable<T> {
int compareTo(T t);
}
compareTo
方法的通用约定与 equals
相似:
将此对象与指定的对象按照排序进行比较。返回值可能为负整数,零或正整数,对应此对象小于,等于或大于指定的对象。如果指定对象的类型与此对象不能进行比较,则抛出
ClassCastException
异常。下面的描述中,符号 sgn(expression) 表示数学中的 signum 函数,它根据表达式的值为负数、零、正数,对应返回 -1、0 和 1。
- 实现类必须确保所有
x
和y
都满足sgn(x.compareTo(y)) == -sgn(y. compareTo(x))
。 (这意味着当且仅当y.compareTo(x)
抛出异常时,x.compareTo(y)
必须抛出异常。)- 实现类还必须确保该关系是可传递的:
(x. compareTo(y) > 0 && y.compareTo(z) > 0)
意味着x.compareTo(z) > 0
。- 最后,对于所有的z,实现类必须确保
x.compareTo(y) == 0
意味着sgn(x.compareTo(z)) == sgn(y.compareTo(z))
。- 强烈推荐
x.compareTo(y) == 0) == (x.equals(y))
,但不是必需的。一般来说,任何实现了Comparable
接口并且违反了这个条件的类都应该清楚地说明这个事实。推荐的说明语言是“注意:这个类有一个自然顺序,但与equals
不一致”。
我们来仔细看一下 compareTo
约定的内容。第一条约定,如果反转两个对象引用之间的比较方向,则会发生预期的事情:如果第一个对象小于第二个对象,那么第二个对象必须大于第一个;如果第一个对象等于第二个,那么第二个对象必须等于第一个;如果第一个对象大于第二个,那么第二个必须小于第一个。第二条约定说,如果一个对象大于第二个对象,而第二个对象大于第三个对象,则第一个对象必须大于第三个对象。最后一条约定,所有比较相等的对象与任何其他对象相比,都必须得到相同的结果。
compareTo
约定的最后一段是一个强烈的建议,而不是一个真正的要求,只是声明 compareTo
方法执行的相等性测试,通常应该返回与 equals
方法相同的结果。如果遵守这个约定,则 compareTo
方法施加的顺序被认为与 equals
相一致。如果违反,则这个顺序关系被认为与 equals
不一致。违反这个约定的类仍然可以工作,但包含该类元素的有序集合可能不服从相应集合接口(Collection
,Set
或 Map
)的一般约定。 这是因为这些接口的通用约定是用 equals
方法定义的,但是有序集合使用 compareTo
施加的相等性测试来代替 equals
。
例如,考虑 BigDecimal
类,其 compareTo
方法与 equals
不一致。如果你创建一个空的 HashSet
实例,然后添加 new BigDecimal("1.0")
和 new BigDecimal("1.00")
,则该集合将包含两个元素,因为用 equals
方法进行比较时,添加到集合的两个 BigDecimal
实例是不相等的。但是,如果使用 TreeSet
而不是 HashSet
执行相同的过程,则该集合将只包含一个元素,因为使用 compareTo
方法进行比较时,两个 BigDecimal
实例是相等的。(详细信息请参阅 BigDecimal
的文档)
编写 compareTo
方法与编写 equals
方法类似,但是有一些关键的区别。因为 Comparable
接口是参数化的,compareTo
方法是静态类型的,所以你不需要输入检查或者转换它的参数。如果参数是错误的类型,那么编译会报错。如果参数为 null,则调用会抛出 NullPointerException
异常。
在 compareTo
方法中,比较属性的顺序而不是相等性。要比较对象引用属性,递归调用 compareTo
方法。可以编写自己的比较器或使用现有的比较器,如在条款 10 中的 CaseInsensitiveString
类的 compareTo
方法中:
// Single-field Comparable with object reference field
public final class CaseInsensitiveString
implements Comparable<CaseInsensitiveString> {
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
... // Remainder omitted
}
在本书前两版中曾经推荐如果比较整型基本类型的属性,使用关系运算符 < 和 >,对于浮点型基本类型的属性,使用 Double.compare
和 Float.compare
静态方法。在 Java 7 中,静态比较方法被添加到 Java 的所有包装类中。在 compareTo
方法中使用关系运算符 < 和 > 是冗长且容易出错的,不再推荐。
如果一个类有多个重要的属性,那么比较它们的顺序是至关重要的。从最重要的属性开始,逐步比较所有的重要属性。如果比较结果不是零(零表示相等),则表示比较完成,返回结果即可。 如果最重要的字段是相等的,比较下一个重要的属性,依此类推,直到找到不相等的属性。以下是条款 11 中 PhoneNumber
类中的 compareTo
方法:
// Multiple-field Comparable with primitive fields
public int compareTo(PhoneNumber pn) {
int result = Short.compare(areaCode, pn.areaCode);
if (result == 0) {
result = Short.compare(prefix, pn.prefix);
if (result == 0)
result = Short.compare(lineNum, pn.lineNum);
}
return result;
}
在 Java 8 中 Comparator
接口提供了一系列比较器方法,可以流畅地构建比较器。许多程序员更喜欢这种方法的简洁性,尽管它会牺牲一定地性能。在使用这种方法时,考虑使用 Java 的静态导入,以便可以通过其简单名称来引用比较器静态方法。下面是 PhoneNumber
类中使用这种技术的 compareTo
方法:
// Comparable with comparator construction methods
private static final Comparator<PhoneNumber> COMPARATOR =
comparingInt((PhoneNumber pn) -> pn.areaCode)
.thenComparingInt(pn -> pn.prefix)
.thenComparingInt(pn -> pn.lineNum);
public int compareTo(PhoneNumber pn) {
return COMPARATOR.compare(this, pn);
}
此实现在类初始化时构建比较器,使用了两个比较器构建方法。第一个是 comparingInt
方法。它是一个静态方法,使用一个键提取器函数( key extractor function)作为参数,将对象引用映射为 int 类型的键,并返回一个根据该键对实例进行排序的比较器。在前面的示例中,comparingInt
方法使用 lambda 表达式,它从 PhoneNumber
中提取区域(area)代码,并返回一个 Comparator<PhoneNumber>
,根据它们的区域代码来对电话号码排序。注意,lambda 表达式显式指定了其输入参数的类型 (PhoneNumber pn)
。事实证明,在这种情况下,Java 的类型推断功能还不够强大,无法自行判断类型,因此我们不得不帮助它以使程序编译。
如果两个电话号码实例具有相同的区号,则需要进一步细化比较,这正是第二个比较器构建方法,即 thenComparingInt
方法做的。它是 Comparator
上的一个实例方法,接受一个 int 类型键提取器函数作为参数,并返回一个比较器,该比较器首先应用原始比较器,然后使用提取的键来打破连接。你可以按照喜欢的方式多次调用 thenComparingInt
方法,从而产生一个字典顺序。在上面的例子中,我们调用两个 thenComparingInt
方法来产生一个排序,它的二级键是 prefix
,三级键是 lineNum
。请注意,我们不必指定传递给 thenComparingInt
方法中键提取器函数的参数类型:Java 的类型推断足够聪明,可以自己推断出参数的类型。
Comparator
类具有完整的构建方法。对于 long
和 double
基本类型,也有对应的类似于 comparingInt
和 thenComparingInt的
的方法,int
版本的方法也可以应用在取值范围小于 int
的类型上,如 short
类型。double
版本的方法也可以用在 float
类型上。这提供了对所有 Java 基本数值类型的覆盖。
有时,你可能会看到 compareTo
或 compare
方法依赖于两个值之间的差值,如果第一个值小于第二个值,则为负;如果两个值相等则为零;如果第一个值大于第二个值,则为正。这是一个例子:
// BROKEN difference-based comparator - violates transitivity!
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return o1.hashCode() - o2.hashCode();
}
};
不要使用这种技术!它可能会导致整数最大长度溢出和 IEEE 754浮点运算失真。应该使用静态 compare
方法:
// Comparator based on static compare method
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
}
};
或者使用 Comparator
的构建方法:
// Comparator based on Comparator construction method
static Comparator<Object> hashCodeOrder =
Comparator.comparingInt(o -> o.hashCode());
【推荐】中国电信天翼云云端翼购节,2核2G云服务器一口价38元/年
【推荐】博客园携手 AI 驱动开发工具商 Chat2DB 推出联合终身会员
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 时间轮在 Netty , Kafka 中的设计与实现
· MySQL 优化利器 SHOW PROFILE 的实现原理
· 在.NET Core中使用异步多线程高效率的处理大量数据
· 聊一聊 C#前台线程 如何阻塞程序退出
· 几种数据库优化技巧
· 跟着 8.6k Star 的开源数据库,搞 RAG!
· .NET 9 中的 多级缓存 HybridCache
· 夜莺 v8 第一个版本来了,开始做有意思的功能了
· .NET 9 增强 OpenAPI 规范,不再内置swagger
· 推荐一个C#轻量级矢量图形库