Effective Java 第三版——14.考虑实现Comparable接口
Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深刻的变化。
在这里第一时间翻译成中文版。供大家学习分享之用。
14.考虑实现Comparable接口
与本章讨论的其他方法不同,compareTo
方法并没有在Object
类中声明。 相反,它是Comparable
接口中的唯一方法。 它与Object
类的equals
方法在性质上是相似的,除了它允许在简单的相等比较之外的顺序比较,它是泛型的。 通过实现Comparable
接口,一个类表明它的实例有一个自然顺序( natural ordering)。 对实现Comparable
接口的对象数组排序非常简单,如下所示:
Arrays.sort(a);
它很容易查找,计算极端数值,以及维护Comparable
对象集合的自动排序。例如,在下面的代码中,依赖于String
类实现了Comparable
接口,去除命令行参数输入重复的字符串,并按照字母顺序排序:
public class WordList {
public static void main(String[] args) {
Set<String> s = new TreeSet<>();
Collections.addAll(s, args);
System.out.println(s);
}
}
通过实现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
不一致”。
与equals
方法一样,不要被上述约定的数学特性所退缩。这个约定并不像看起来那么复杂。 与equals
方法不同,equals
方法在所有对象上施加了全局等价关系,compareTo
不必跨越不同类型的对象:当遇到不同类型的对象时,compareTo
被允许抛出ClassCastException
异常。 通常,这正是它所做的。 约定确实允许进行不同类型间比较,这种比较通常在由被比较的对象实现的接口中定义。
正如一个违反hashCode
约定的类可能会破坏依赖于哈希的其他类一样,违反compareTo
约定的类可能会破坏依赖于比较的其他类。 依赖于比较的类,包括排序后的集合TreeSet
和TreeMap
类,以及包含搜索和排序算法的实用程序类Collections
和Arrays
。
我们来看看compareTo
约定的规定。 第一条规定,如果反转两个对象引用之间的比较方向,则会发生预期的事情:如果第一个对象小于第二个对象,那么第二个对象必须大于第一个; 如果第一个对象等于第二个,那么第二个对象必须等于第一个; 如果第一个对象大于第二个,那么第二个必须小于第一个。 第二项约定说,如果一个对象大于第二个对象,而第二个对象大于第三个对象,则第一个对象必须大于第三个对象。 最后一条规定,所有比较相等的对象与任何其他对象相比,都必须得到相同的结果。
这三条规定的一个结果是,compareTo
方法所实施的平等测试必须遵守equals
方法约定所施加的相同限制:自反性,对称性和传递性。 因此,同样需要注意的是:除非你愿意放弃面向对象抽象(条目 10)的好处,否则无法在保留compareTo
约定的情况下使用新的值组件继承可实例化的类。 同样的解决方法也适用。 如果要将值组件添加到实现Comparable
的类中,请不要继承它;编写一个包含第一个类实例的不相关的类。 然后提供一个返回包含实例的“视图”方法。 这使你可以在包含类上实现任何compareTo
方法,同时客户端在需要时,把包含类的实例视同以一个类的实例。
compareTo
约定的最后一段是一个强烈的建议,而不是一个真正的要求,只是声明compareTo
方法施加的相等性测试,通常应该返回与equals
方法相同的结果。 如果遵守这个约定,则compareTo
方法施加的顺序被认为与equals
相一致。 如果违反,顺序关系被认为与equals
不一致。 其compareTo
方法施加与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
方法。 如果一个属性没有实现Comparable
,或者你需要一个非标准的顺序,那么使用Comparator
接口。 可以编写自己的比较器或使用现有的比较器,如在条目 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](http://ORDER.compare(s), cis.s);
}
... // Remainder omitted
}
请注意,CaseInsensitiveString
类实现了Comparable <CaseInsensitiveString>
接口。 这意味着CaseInsensitiveString
引用只能与另一个CaseInsensitiveString
引用进行比较。 当声明一个类来实现Comparable
接口时,这是正常模式。
在本书第二版中,曾经推荐如果比较整型基本类型的属性,使用关系运算符“<” 和 “>”,对于浮点类型基本类型的属性,使用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](http://Short.compare(areaCode), pn.areaCode);
if (result == 0) {
result = [Short.compare(prefix](http://Short.compare(prefix), pn.prefix);
if (result == 0)
result = [Short.compare(lineNum](http://Short.compare(lineNum), pn.lineNum);
}
return result;
}
在Java 8中Comparator
接口提供了一系列比较器方法,可以使比较器流畅地构建。 这些比较器可以用来实现compareTo
方法,就像Comparable
接口所要求的那样。 许多程序员更喜欢这种方法的简洁性,尽管它的性能并不出众:在我的机器上排序PhoneNumber
实例的数组速度慢了大约10%。 在使用这种方法时,考虑使用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](http://COMPARATOR.compare(this), pn);
}
此实现在类初始化时构建比较器,使用两个比较器构建方法。第一个是comparingInt
方法。它是一个静态方法,它使用一个键提取器函数式接口( key extractor function)作为参数,将对象引用映射为int类型的键,并返回一个根据该键排序的实例的比较器。在前面的示例中,comparingInt
方法使用lambda表达式,它从PhoneNumber
中提取区域代码,并返回一个Comparator<PhoneNumber>
,根据它们的区域代码来排序电话号码。注意,lambda表达式显式指定了其输入参数的类型(PhoneNumber pn)
。事实证明,在这种情况下,Java的类型推断功能不够强大,无法自行判断类型,因此我们不得不帮助它以使程序编译。
如果两个电话号码实例具有相同的区号,则需要进一步细化比较,这正是第二个比较器构建方法,即thenComparingInt
方法做的。 它是Comparator
上的一个实例方法,接受一个int类型键提取器函数式接口( key extractor function)作为参数,并返回一个比较器,该比较器首先应用原始比较器,然后使用提取的键来打破连接。 你可以按照喜欢的方式多次调用thenComparingIn
t方法,从而产生一个字典顺序。 在上面的例子中,我们将两个调用叠加到thenComparingInt
,产生一个排序,它的二级键是prefix
,而其三级键是lineNum
。 请注意,我们不必指定传递给thenComparingInt
的任何一个调用的键提取器函数式接口的参数类型:Java的类型推断足够聪明,可以自己推断出参数的类型。
Comparator
类具有完整的构建方法。对于long
和double
基本类型,也有对应的类似于comparingInt
和thenComparingInt的
方法,int
版本的方法也可以应用于取值范围小于 int
的类型上,如short
类型,如PhoneNumber
实例中所示。对于double
版本的方法也可以用在float
类型上。这提供了所有Java的基本数字类型的覆盖。
也有对象引用类型的比较器构建方法。静态方法comparing
有两个重载方式。第一个方法使用键提取器函数式接口并按键的自然顺序。第二种方法是键提取器函数式接口和比较器,用于键的排序。thenComparing
方法有三种重载。第一个重载只需要一个比较器,并使用它来提供一个二级排序。第二次重载只需要一个键提取器函数式接口,并使用键的自然顺序作为二级排序。最后的重载方法同时使用一个键提取器函数式接口和一个比较器来用在提取的键上。
有时,你可能会看到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浮点运算失真的危险[JLS 15.20.1,15.21.1]。 此外,由此产生的方法不可能比使用上述技术编写的方法快得多。 使用静态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());
总而言之,无论何时实现具有合理排序的值类,你都应该让该类实现Comparable
接口,以便在基于比较的集合中轻松对其实例进行排序,搜索和使用。 比较compareTo
方法的实现中的字段值时,请避免使用"<"和">"运算符。 相反,使用包装类中的静态compare
方法或Comparator
接口中的构建方法。