高效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。 实现类必须确保所有 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。 最后,对于所有的 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 类,以及包含搜索和排序算法的实用程序类 CollectionsArrays

  我们来看看 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, cis.s);
    }
    ... // Remainder omitted
   }

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

  在本书第二版中,曾经推荐如果比较整型基本类型的属性,使用关系运算符「<」和 「>」,对于浮点类型基本类型的属性,使用 Double.compareFloat.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 接口提供了一系列比较器方法,可以使比较器流畅地构建。 这些比较器可以用来实现 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 方法有三种重载。第一个重载只需要一个比较器,并使用它来提供一个二级排序。第二次重载只需要一个键提取器函数式接口,并使用键的自然顺序作为二级排序。最后的重载方法同时使用一个键提取器函数式接口和一个比较器来用在提取的键上。

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

   // 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 接口中的构建方法。

文章列表

更多推荐

更多
  • Spark编程-结构化流式编程指南 概述,简单例子,编程模型,使用 Dataset 和 DataFrame 的API,连续处理,额外信息,基本概念,处理 Eventtime 和 Late Data,faulttolerance 语义,创建流式 DataFrame 和流式
  • Spark编程-20 Spark 配置Spark 属性,Environment Variables环境变量,Configuring Logging配置 Logging,Overriding configuration directory覆盖配置目录,Inhe
  • Spark编程-在Mesos上运行Spark 运行原理,安装 Mesos,连接 Spark 到 Mesos,Mesos 运行模式,Mesos Docker 支持,集成 Hadoop 运行,使用 Mesos 动态分配资源,配置,故障排查和调试,从源码安装,第三方软件包,验证,上传 S
  • Spark编程-Running Spark on YARN 启动 Spark on YARN,准备,配置,调试应用,在安全集群中运行,添加其他的 JARs,配置外部的 Shuffle Service,用 Apache Oozie 来运行应用程序,Kerberos 故障排查,使用 Spark Hi
  • Spark编程-Spark 调优 数据序列化,内存调优,其它考虑,,内存管理概论,确定内存消耗,优化数据结构,序列化 RDD 存储,GC优化,并行级别,Reduce任务内存使用,广播大变量,数据局部性, 由于大多数Spark计算都在内存中,所以集群中的任何资源(C
  • Spark编程-Spark Standalone模式 安装 Spark Standalone 集群,手动启动一个集群,集群启动脚本,提交应用程序到集群中,启动 Spark 应用程序,Resource Scheduling资源调度,监控和日志,与 Hadoop 集成,配置网络安全端口,高可用
  • Spark编程-Monitoring and Instrumentation Web 界面,Metrics,高级工具,事后查看,REST API,环境变量,Spark配置选项,API 版本控制策略, 有几种方法来监视 Spark 应用程序:Web UI,metrics 和外部工具。 Web 界面每
  • Spark编程-Spark提交任务Submitting Applications 打包应用依赖,用 sparksubmit 启动应用,Master URLs,从文件中加载配置,高级的依赖管理,更多信息, 在 script in Spark的 `bin` 目录中的`spark-submit` 脚本用与在集群上启动
  • Spark编程-作业调度 概述,跨应用调度,应用内调度,动态资源分配,公平调度资源池,资源池默认行为,配置资源池属性,配置和部署,资源分配策略,优雅的关闭Executor(执行器),概述Spark 有好几计算资源调度的方式。首先,回忆一下 [集群
  • Spark编程-Spark 概述 安全,下载,运行示例和 Shell,在集群上运行,进一步学习链接, Apache Spark 是一个快速的,通用的集群计算系统。它对 Java,Scala,Python 和 R 提供了的高层 API,并有一个经优化的支持通用执行图
  • 近期文章

    更多
    文章目录

      推荐作者

      更多