美文网首页
第十四条:考虑实现Comparable接口【对于所有对象都通用的

第十四条:考虑实现Comparable接口【对于所有对象都通用的

作者: Js_Gavin | 来源:发表于2021-02-03 08:48 被阅读0次

    与本章中讨论的其他方法不同,compareTo方法没有在Object类中声明。相反,它是Comparable接口中唯一的方法。compareTo方法不但允许进行简单的等同比较,而且允许执行顺序比较,除此之外,它与Object的equals方法具有相似的特征,它还是个泛型(generic)。类实现Comparable接口的对象数组进行排序就这么简单。

    Arrays.sort(a);
    

    对存储在集合中的Comparable对象进行搜索、计算极限值以及自动维护也同样简单。例如,下面的程序依赖于实现了Comparable接口的String类,它去掉了命令行参数列表中的重复参数,并按照字母顺序打印出来。

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

    一旦类实现了Comparable接口,它就可以跟许多泛型算法以及依赖于该接口的集合实现进行协作。你付出很小的努力就可以获得非常强大的功能。事实上,Java平台类库中的所有值类,以及所有的枚举类型都实现了Comparable接口。如果你正在编写一个值类,它具有非常明显的内在排序关系。比如按字母排序、按数值顺序或者按年代顺序,那你就应该坚决考虑实现Comparable接口

    public interface Comparable<T> {
        int compareTo(T t);
    }
    

    compareTo方法的通用约定equals方法的约定相似:
    将这个对象与指定的对象进行比较,当该对象小于、等于或者大于指定对象的时候,分别返回一个负数、零或者正数。如果由于指定对象的类型而无法与该对象进行比较,则抛出ClassCastException异常

    在下面的说明中,符号sgn(expression)表示数学中的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.comapreTo(y) == 0暗示着所有的z都满足sgn(x.compareTo(z)) == sgn(y.compareTo(z))

    四、强烈建议(x.compareTo(y) == 0) == (x.equals(y)),但这并非绝对必要的。一般来说,任何实现了Comparable接口的类,若违反了这个条件,都应该明确予以说明。推荐使用这样的说法:“注意:该类具有内在的排序功能,但是与equals不一致“

    千万不要被上述约定中的数学关系所迷惑。如同equals约定(详见第10条)一样,compareTo约定并没有看起来那么复杂。与equals方法不同的是,它对所有的对象强行施加了一种通用的等同关系。compareTo不能跨越不同的对象进行比较:在比较不同类型的对象时,compareTo可以抛出ClassCastException异常。通常,这正是compareTo在这种情况下应该做的事情。合约确实允许进行跨类型之间的比较,这一般是在被比较对象实现的接口中进行定义

    就好像违反了hashCode约定的类会破坏其他依赖于散列的类一样,违反compareTo约定的类也会破坏其他依赖于比较关系的类。依赖于比较的类包括有序集合类TreeSet和TreeMap,以及工具类Collections和Arrays,它们内部包含有搜索和排序算法。

    现在我们来回顾一下compareTo约定中的条款。
    第一条指出,如果颠倒了两个对象引用之间的比较方向,就会发生下面的情况:
    (1)如果第一个对象小于第二个对象,则第二个对象一定大于第一个对象;
    (2)如果第一个对象等于第二个对象,则第二个对象一定等于第一个对象;
    (3)如果第一个对象大于第二个对象,则第二个对象一定小于第一个对象。
    第二条指出,如果一个对象大于第二个对象,并且第二个对象又大于第三个对象,那么第一个对象一定大于第第三个对象。
    最后一条指出,在比较时被认为相等的所有对象,它们跟别的对象作比较时一定会产生同样的结果。

    这三个条款的一个直接结果是,由compareTo方法施加的等同性测试。也必须遵守相同于equals约定所施加的限制条件:自发性、对称性和传递性。因此,下面的告诫也同样适用:无法在新的值组件扩展可实例化的类时,同时保持compareTo约定,除非愿意放弃面向对象的抽象优势(详见第10条)。针对equals的权宜之计也同样适用于compareTo方法。如果你想为一个实现了Comparable接口的类增加值组件,请不要扩展这个类;而是要编写一个不相关的类,其中包含第一类的一个实例。然后提供一个”视图“(view)方法返回这个实例。这样既可以让你自由的在第二个类上实现compareTo方法,同时也允许它的客户端在必要的时候,把第二个类的实例视同第一个类的实例。

    compareTo约定的最后一段是一条强烈的建议,而不是真正的规则,它只是说明了compareTo方法施加的等同性测试,在通常情况下应该返回与equals方法同样的结果。如果遵守了这一条,那么由compareTo方法所施加的顺序关系就被认为与equals一致。如果违反了这条规则,顺序关系就被认为与equals不一致。如果一个类的compareTo方法施加了一个与equals方法不一致的顺序关系,他仍然能够正常工作,但是如果一个有序集合(sortrd collection)包含了该类的元素,这个集合就可能无法遵守相应集合接口(Collection、Set或Map)的通用约定。因为对于这些接口的通用约定是按照equals方法来定义的,但是有序集合使用了由compareTo方法而不是equals方法所施加的等同性测试,尽管出现这种情况不会造成灾难性的后果,但是应该有所了解。

    例如,以BigDecimal类为例,它的compareTo方法与equals方法不一致。如果你创建了一个空的HashSet实例,并且添加new BigDecimal("1.0")和并且添加new BigDecimal("1"),这个集合就将包含两个元素,因为新增到集合中的两个BigDecimal实例,通过equals方法来比较是不相等的。然而,如果你使用TreeSet而不是HashSet来执行同样的过程,集合中将只包含一个元素,因为这两个BigDecimal实例在通过compareTo方法进行比较时是相等的。

    编写compareTo方法与编写equals方法非常相似,但也存在几处重大的差别。因为Comparable接口是参数化的,而且compareTo方法是静态的类型,因此不必进行类型检查,也不必对它的参数进行类型转换。如果参数类型不适合,这个调用甚至无法编译。如果参数为null,这个调用应该抛出NullPointerExecption异常,并且一旦该方法试图访问它的成员时就应该抛出异常。

    CompareTo方法中的域的比较是顺序的比较,而不是等同性的比较。比较对象引用域可以通过递归的调用compareTo方法来实现。如果一个域并没有实现Comparable接口,或者你需要使用一个非标准的排序关系,就可以使用一个显示的Comparator来替代。或者编写自己的比较器,或者使用已有的比较器,例如针对第10条中的CaseInsensitiveString类的这个compareTo方法使用一个已有的比较器:

    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接口时,这是常用的模式。

    本书的前两个版本建议compareTo方法可以利用好关系操作符<和>去比较整数型基本类型的域,用静态方法Double.compare和Float.compare去比较浮点基本类型域。在Java7版本中,已经在Java的所有装箱基本类型的类中增加了静态的compare方法。在compareTo方法中使用关系操作符<和>是非常繁琐的,并且容易出错,因此不再建议使用

    如果一个类有多个关键域,那么,按什么样的顺序来比较这些域是非常关键的。你必须从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果(零代表相等),则整个比较操作结束,并返回该结果。如果最关键的域都是相等的,则进一步比较次关键的域,以此类推。如果所有的域都是相等的,则对象就是相等的,并返回零。下面通过第11条中的PhoneNumer类和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;
    }
    

    在Java8中,Compartor接口配置了一组比较构造器,使得比较器的构造工作变得非常流程。之后,按照Comparable接口的要求,这些比较器可以用来实现一个compareTo方法。许多程序员都喜欢这种方法的简洁性,虽然它要付出一定的性能成本,但是为了简洁起见,可以考虑使用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。这是一个静态方法,带有一个键提取器函数,它将一个对象引用映射到一个类型为int的键上,并根据这个键返回一个对实例进行排序的比较器。在上一个例子中,comparingInt带有一个lambda(),它从PhoneNumber提取区号,并返回一个按区号对电话号码进行排序的Comparator<PhoneNumber>。注意,lambda显式定义了其输入参数的类型。事实证明,在这种情况下,Java的类型推导还没有强大到足以自己找出类型,因此我们不得不帮助它直接进行指定,以使程序能够成功的进行编译。

    如果两个电话号码的区号相同,就需要进一步细化比较,这正是第二个比较器构造方法thenComoaringInt要完成的任务。这是Compartor上的一个实例方法,带有一个类型为int的键提取器函数,它会返回一个最先运用原始比较器的比较器,然后利用提取到的键继续比较。还可以随意的叠加多个thenComparingInt调用,按照第二个键为前缀且第三个键为行数的顺序进行比较。注意,并不一定要指定传入thenComparingInt调用的键提取器函数的参数类型:Java的类型推导十分智能,它足以为自己找到正确的类型。

    Comparator类具备全套的构造方法。对于基本类型long和double都有对应的comparingInt和thenComparingInt,int版本也可以用于更狭义的整数型类型,如PhoneNumber例子中的short,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();
        }
    };
    

    千万不要使用这种方法。它很容易造成整数溢出,同时违反IEEE754浮点算术标准,甚至,与利用本条目讲到的方法编写的那些方法相比,最终得到的方法并没有明显变快。因此,要么使用一个静态方法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 based on Comparator construction method
    static Comparator<Object> hashCodeOrder =
            Comparator.comparingInt(o -> o.hashCode());
    

    总而言之,每当实现一个对排序敏感的类时,都应该让这个类实现Comparable接口,以便使其实例可以轻松的被分类、搜索,以及用在基于比较的集合中。每当在compareTo方法的实现中比较域值时,都要避免使用< 和 > 操作符,而应该在装箱基本类型的类中使用静态的compare方法,或者在Comparator接口中使用比较器构造方法。

    相关文章

      网友评论

          本文标题:第十四条:考虑实现Comparable接口【对于所有对象都通用的

          本文链接:https://www.haomeiwen.com/subject/fchrtltx.html