14. 考虑实现 Comparable 接口

      它很容易查找,计算极端数值,以及维护 Comparable 对象集合的自动排序。例如,在下面的代码中,依赖于 String 类实现了 Comparable 接口,去除命令行参数输入重复的字符串,并按照字母顺序排序:

    1. public class WordList {
    2. public static void main(String[] args) {
    3. Set<String> s = new TreeSet<>();
    4. Collections.addAll(s, args);
    5. System.out.println(s);
    6. }
    7. }

      通过实现 Comparable 接口,可以让你的类与所有依赖此接口的通用算法和集合实现进行互操作。 只需少量的努力就可以获得巨大的能量。 几乎 Java 平台类库中的所有值类以及所有枚举类型(详见第 34 条)都实现了 Comparable 接口。 如果你正在编写具有明显自然顺序(如字母顺序,数字顺序或时间顺序)的值类,则应该实现 Comparable 接口:

    1. public interface Comparable<T> {
    2. int compareTo(T t);
    3. }

      compareTo 方法的通用约定与 equals 相似:

      将此对象与指定的对象按照排序进行比较。 返回值可能为负整数,零或正整数,因为此对象对应小于,等于或大于指定的对象。 如果指定对象的类型与此对象不能进行比较,则引发 ClassCastException 异常。

      下面的描述中,符号 sgn(expression) 表示数学中的 signum 函数,它根据表达式的值为负数、零、正数,对应返回-1、0 和 1。

    • 实现类必须确保所有 xy 都满足 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) == (x.equals(y)),但不是必需的。 一般来说,任何实现了 Comparable 接口的类违反了这个条件都应该清楚地说明这个事实。 推荐的语言是「注意:这个类有一个自然顺序,与 equals 不一致」。

      与 equals 方法一样,不要被上述约定的数学特性所退缩。这个约定并不像看起来那么复杂。 与 equals 方法不同,equals 方法在所有对象上施加了全局等价关系,compareTo 不必跨越不同类型的对象:当遇到不同类型的对象时,compareTo 被允许抛出 ClassCastException 异常。 通常,这正是它所做的。 约定确实允许进行不同类型间比较,这种比较通常在由被比较的对象实现的接口中定义。

      正如一个违反 hashCode 约定的类可能会破坏依赖于哈希的其他类一样,违反 compareTo 约定的类可能会破坏依赖于比较的其他类。 依赖于比较的类,包括排序后的集合 TreeSet 和 TreeMap 类,以及包含搜索和排序算法的实用程序类 CollectionsArrays

      这三条规定的一个结果是,compareTo 方法所实施的平等测试必须遵守 equals 方法约定所施加的相同限制:自反性,对称性和传递性。 因此,同样需要注意的是:除非你愿意放弃面向对象抽象(详见第 10 条)的好处,否则无法在保留 约定的情况下使用新的值组件继承可实例化的类。 同样的解决方法也适用。 如果要将值组件添加到实现 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 方法中:

      请注意,CaseInsensitiveString 类实现了 Comparable<CaseInsensitiveString> 接口。 这意味着 CaseInsensitiveString 引用只能与另一个 CaseInsensitiveString 引用进行比较。 当声明一个类来实现 Comparable 接口时,这是正常模式。

      在本书第二版中,曾经推荐如果比较整型基本类型的属性,使用关系运算符「<」和 「>」,对于浮点类型基本类型的属性,使用 Double.compareFloat.compare 静态方法。在 Java 7 中,静态比较方法被添加到 Java 的所有包装类中。 在 compareTo 方法中使用关系运算符「<」和「>」是冗长且容易出错的,不再推荐。

    1. // Multiple-field `Comparable` with primitive fields
    2. public int compareTo(PhoneNumber pn) {
    3. int result = Short.compare(areaCode, pn.areaCode);
    4. if (result == 0) {
    5. if (result == 0)
    6. result = Short.compare(lineNum, pn.lineNum);
    7. }
    8. return result;
    9. }

      在 Java 8 中 Comparator 接口提供了一系列比较器方法,可以使比较器流畅地构建。 这些比较器可以用来实现 compareTo 方法,就像 Comparable 接口所要求的那样。 许多程序员更喜欢这种方法的简洁性,尽管它的性能并不出众:在我的机器上排序 PhoneNumber 实例的数组速度慢了大约 10%。 在使用这种方法时,考虑使用 Java 的静态导入,以便可以通过其简单名称来引用比较器静态方法,以使其清晰简洁。 以下是 PhoneNumber 的 compareTo 方法的使用方法:

    1. // Comparable with comparator construction methods
    2. private static final Comparator<PhoneNumber> COMPARATOR =
    3. .thenComparingInt(pn -> pn.prefix)
    4. .thenComparingInt(pn -> pn.lineNum);
    5. public int compareTo(PhoneNumber pn) {
    6. return COMPARATOR.compare(this, pn);
    7. }

      此实现在类初始化时构建比较器,使用两个比较器构建方法。第一个是 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 方法有三种重载。第一个重载只需要一个比较器,并使用它来提供一个二级排序。第二次重载只需要一个键提取器函数式接口,并使用键的自然顺序作为二级排序。最后的重载方法同时使用一个键提取器函数式接口和一个比较器来用在提取的键上。

      有时,你可能会看到 compareTocompare 方法依赖于两个值之间的差值,如果第一个值小于第二个值,则为负;如果两个值相等则为零,如果第一个值大于,则为正值。这是一个例子:

      不要使用这种技术!它可能会导致整数最大长度溢出和 IEEE 754 浮点运算失真的危险[JLS 15.20.1,15.21.1]。 此外,由此产生的方法不可能比使用上述技术编写的方法快得多。 使用静态 compare 方法:

    1. // Comparator based on static compare method
    2. static Comparator<Object> hashCodeOrder = new Comparator<>() {
    3. public int compare(Object o1, Object o2) {
    4. return Integer.compare(o1.hashCode(), o2.hashCode());
    5. }
    6. };
    1. // Comparator based on Comparator construction method
    2. Comparator.comparingInt(o -> o.hashCode());

      总而言之,无论何时实现具有合理排序的值类,你都应该让该类实现 Comparable 接口,以便在基于比较的集合中轻松对其实例进行排序,搜索和使用。 比较 compareTo 方法的实现中的字段值时,请避免使用「<」和「>」运算符。 相反,使用包装类中的静态 compare 方法或 接口中的构建方法。