第12条:考虑实现Comparable
接口
与本章中讨论的其他方法不同,compareTo
方法并没有在Object
中声明。相反,它是Comparable
接口中唯一的方法。compareTo
方法不但允许进行简单的等同行比较,而且允许执行顺序比较,除此之外,它与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<String>();
Collections.addAll(s, args);
System.out.println(s);
}
}
一旦类实现了Comparable
接口,它就可以跟许多泛型算法(generic algorithm)以及依赖于该接口的集合实现(collection implementation)进行写作。你付出很小的努力就可以获得非常强大的功能。事实上,Java平台类库中的所有值类(value classes)都实现了Comparable
接口。如果你正在编写一个值类,它具有非常明显的内在排序关系,比如按字母排序、按数值顺序或者按年代顺序,那你就应该坚决考虑实现这个接口:
public interface Comparable<T> {
int compareTo(T t);
}
compareTo
方法的通用约定与equals
方法的相似:
将这个对象与指定的对象进行比较。当该对象小于、等于或大于指定对象的时候,分别返回一个负整数、零或者正整数。如果由于指定对象的类型而无法与该对象进行比较,则抛出ClassCastException
异常。
在下面的说明中,符号sgn
(表达式)表示数学中的signum
函数,它根据表达式(expression)的值为负值、零和正值,分别返回-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
。 - 最后,实现者必须确保
x.compareTo(y) == 0
暗示着所有的z
都满足sgn(x.compareTo(z)) == sgn(y.compareTo(z))
。 - 强烈建议
(x.compareTo(y) == 0) == (x.equals(y))
,但这并非绝对必要。一般说来,任何实现了Comparable
接口的类,若违反了这个条件,都应该明确予以说明。推荐使用这样的说法:“注意,该类具有内在的排序功能,但是与equals
不一致。”
千万不要被上述约定中的数学关系所迷惑。如同equals
约定(见第8条)一样,compareTo
约定并没有它看起来的那么复杂。在类的内部,任何合理的顺序关系都可以满足compareTo
约定。与equals
不同的是,在跨越不同类的时候,compareTo
可以不做比较:如果两个被比较的对象引用不同类的对象,compareTo
可以抛出ClassCastException
异常。通常,这正是compareTo
在这种情况下应该做的事情,如果类设置了正确的参数,这也正是它所要做的事情。虽然以上约定并没有把跨类之间的比较排除在外,但是从Java 1.6发行版本开始,Java平台类库中就没有哪个类有支持这种特性了。
就好像违反了hashCode
约定的类会破坏其他依赖于散列做法的类一样,违反compareTo
约定的类也会破坏其他依赖于比较关系的类。依赖于比较关系的类包括有序集合类TreeSet
和TreeMap
,以及工具类Collections
和Arrays
,它们内部包含有搜索和排序算法。
现在我们来回顾一下compareTo
约定中的条款。第一条指出,如果颠倒了两个对象引用之间的比较方向,就会发生下面的情况:如果第一个对象小于第二个对象,则第二个对象一定大于第一个对象;如果第一个对象等于第二个对象,则第二个对象一定等于第一个对象;如果第一个对象大于第二个对象,则第二个对象一定小于第一个对象。第二条指出,如果一个对象大于第二个对象,并且第二个对象又大于第三个对象,那么第一个对象一定大于第三个对象。最后一条指出,在比较时被认为相等的所有对象,它们跟别的对象做比较时一定会产生同样的结果。
这三个条款的一个直接结果是,由compareTo
方法施加的等同性测试(equality set),也一定遵守相同于equals
约定所施加的限制条件:自反性、对称性和传递性。因此,下面的告诫也同样试用:无法在用新的值组件扩展可实例化的类时,同时保持compareTo
约定,除非愿意放弃面向对象的抽象优势(见第8条)。针对equals
的权益之计也同样适用于compareTo
方法。如果你想为一个实现了Comparable
接口的类增加值组件,请不要扩展这个类;而是要编写一个不相关的类,其中包含第一个类的一个实例。然后提供一个“视图(view)”方法返回这个实例。这样既可以让你自由地在第二个类上实现compareTo
方法,同时也允许它的客户端在必要的时候,把第二个类的实例视同第一个类的实例。
compareTo
约定的最后一段是一个强烈的建议,而不是真正的规则,只是说明了compareTo
方法施加的等同性测试,在通常情况下应该返回与equals
方法同样的结果。如果遵守了这一条,那么由compareTo
方法所施加的顺序关系就被认为“与equals
一致(consistent with equals
)”。如果违反了这条规则,顺序关系就被认为“与equals
不一致(inconsistent with equals
)”。如果一个类的compareTo
方法施加了一个与equals
方法不一致的顺序关系,它仍然能够正常工作,但是,如果一个有序集合(sorted collection)包含了该类的元素,这个集合就可能无法遵守相应结合接口(Collection
、Set
或Map
)的通用约定。这是因为,对于这些接口的通用约定是按照equals
方法来定义的,但是有序集合使用了由compareTo
方法而不是equals
方法所施加的等同性测试。尽管出现这种情况不会造成灾难性的后果,但是应该有所了解。
例如,考虑BigDecimal
类,它的compareTo
方法与equals
不一致。如果你创建了一个HashSet
实例,并且添加new BigDecimal("1.0")
和new BigDecimal("1.0")
,这个集合就将包含两个元素,因为新增到集合中的两个BigDecimal
实例,通过equals
方法来比较时是不相等的。然而,如果你使用TreeSet
而不是HashSet
来执行同样的过程,集合中将只包含一个元素,因为这两个BigDecimal
实例在通过compareTo
方法进行比较时是相等的。(详情请参阅BigDecimal
的文档。)
编写compareTo
方法与编写equals
方法非常相似,但也存在几处重大的差别。因为Comparable
接口是参数化的,而且comparable
方法是静态的类型,因此不必进行类型检查,也不必对它的参数进行转型。如果参数的类型不合适,这个调用甚至无法编译。如果参数为null
,这个调用应该抛出NullPointerException
异常,并且一旦该方法试图访问它的成员时就应该抛出。
CompareTo
方法中域的比较是有顺序的比较,而不是等同性的比较。比较对象引用域可以是递归地调用compareTo
方法来实现。如果一个域并没有实现Comparable
接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的Comparator
来代替。或者编写自己的Comparator
,或者使用已有的Comparator
,譬如针对第8条中CaseInsensitiveString
类的这个compareTo
方法使用一个已有的Comparator
:
public final class CaseInsensitiveString
implements Comparable<CaseInsensitiveString> {
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
... // Remainder omitted
}
注意CaseInsensitiveString
类实现了Comparable<CaseInsensitiveString>
接口。由此可见,CaseInsensitiveString
引用只能与其他的Comparable<CaseInsensitiveString>
引用进行比较。在声明类去实现Comparable
接口时,这是常用的模式。还要注意compareTo
方法的参数是CaseInsensitiveString
,而不是Object
,这是上述的类声明所要求的。
比较整数型基本类型的域,可以使用关系操作符<
和>
。例如,浮点域用Double.compare
或者Float.compare
,而不用关系操作符,当应用到浮点值时,它们没有遵守compareTo
的通用约定。对于数组,则要把这些指导原则应用到每个元素上。
如果一个类有多个关键域,那么,按照什么样的顺序来比较这些域是非常关键的。你必须从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果(零代表相等),则整个比较操作结束,并返回该结果。如果最关键的域是相等的,则进一步比较次最关键的域,以此类推。如果所有的域都是相等的,则对象就是相等的,并返回零。下面通过第9条中的PhoneNumber
类的compareTo
方法来说明这种方法:
public int compareTo(PhoneNumber pn) {
// Compare area codes
if (areaCode < pn.areaCode)
return -1;
if (areaCode > pn.areaCode)
return 1;
// Area codes are equal, compare prefixes
if (prefix < pn.prefix)
return -1;
if (prefix > pn.prefix)
return 1;
// Area codes and prefixes are equal, compare line numbers
if (lineNumber < pn.lineNumber)
return -1;
if (lineNumber > pn.lineNumber)
return 1;
return 0; // All fields are equal
}
虽然这个方法可行,但它还可以进行改进。回想一下,compareTo
方法的约定并没有指定返回值的大小(magnitude),而只是指定了返回值的符号。你可以利用这一点来简化代码,或许还能提高它的运行速度:
public int compareTo(PhoneNumber pn) {
// Compare area codes
int areaCodeDiff = areaCode - pn.areaCode;
if (areaCodeDiff != 0)
return areaCodeDiff;
// Area codes are equal, compare prefixes
int prefixDiff = prefix - pn.prefix;
if (prefixDiff != 0)
return prefixDiff;
// Area codes and prefixes are equal, compare line numbers
return lineNumber - pn.lineNumber;
}
这项技巧在这里能够工作得很好,但是用起来要非常小心。除非你确信相关的域不会为负值,或者更一般的情况:最小和最大的可能域值之差小于或等于INTEGER.MAX_VALUE
($2^{23}-1$),否则就不要使用这种方法。这项技巧有时不能正常工作的原因在于,一个有符号的32位的整数还没有大道足以表达任意两个32位整数的差。如果i
是一个很大的正整数(int
类型),而j
是一个很大的负整数(int
类型),那么(i - j)
将会溢出,并返回一个负值。这样就使得compareTo
方法将对某些参数返回错误的结果,违反了compareTo
约定的第一条和第二条。这不是一个纯粹的理论问题:它已经在实际的系统中导致了失败,这些失败可能非常难以调试,因为这样的compareTo
方法对大多数的输入值都能正常工作。