14 KiB
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, pn);
}
此实现在类初始化时构建比较器,使用两个比较器构建方法。第一个是 comparingInt
方法。它是一个静态方法,它使用一个键提取器函数式接口( key extractor function)作为参数,将对象引用映射为 int 类型的键,并返回一个根据该键排序的实例的比较器。在前面的示例中,comparingInt
方法使用 lambda 表达式,它从 PhoneNumber
中提取区域代码,并返回一个 Comparator<PhoneNumber>
,根据它们的区域代码来排序电话号码。注意,lambda 表达式显式指定了其输入参数的类型 (PhoneNumber pn)。事实证明,在这种情况下,Java 的类型推断功能不够强大,无法自行判断类型,因此我们不得不帮助它以使程序编译。
如果两个电话号码实例具有相同的区号,则需要进一步细化比较,这正是第二个比较器构建方法,即 thenComparingInt
方法做的。 它是 Comparator
上的一个实例方法,接受一个 int 类型键提取器函数式接口( key extractor function)作为参数,并返回一个比较器,该比较器首先应用原始比较器,然后使用提取的键来打破连接。 你可以按照喜欢的方式多次调用 thenComparingInt
方法,从而产生一个字典顺序。 在上面的例子中,我们将两个调用叠加到 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
接口中的构建方法。