Effective Java 第三版——14.考虑实现Comparable接口

Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深刻的变化。
在这里第一时间翻译成中文版。供大家学习分享之用。

Effective Java, Third Edition

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约定的类可能会破坏依赖于比较的其他类。 依赖于比较的类,包括排序后的集合TreeSetTreeMap类,以及包含搜索和排序算法的实用程序类CollectionsArrays

我们来看看compareTo约定的规定。 第一条规定,如果反转两个对象引用之间的比较方向,则会发生预期的事情:如果第一个对象小于第二个对象,那么第二个对象必须大于第一个; 如果第一个对象等于第二个,那么第二个对象必须等于第一个; 如果第一个对象大于第二个,那么第二个必须小于第一个。 第二项约定说,如果一个对象大于第二个对象,而第二个对象大于第三个对象,则第一个对象必须大于第三个对象。 最后一条规定,所有比较相等的对象与任何其他对象相比,都必须得到相同的结果。

这三条规定的一个结果是,compareTo方法所实施的平等测试必须遵守equals方法约定所施加的相同限制:自反性,对称性和传递性。 因此,同样需要注意的是:除非你愿意放弃面向对象抽象(条目 10)的好处,否则无法在保留compareTo约定的情况下使用新的值组件继承可实例化的类。 同样的解决方法也适用。 如果要将值组件添加到实现Comparable的类中,请不要继承它;编写一个包含第一个类实例的不相关的类。 然后提供一个返回包含实例的“视图”方法。 这使你可以在包含类上实现任何compareTo方法,同时客户端在需要时,把包含类的实例视同以一个类的实例。

compareTo约定的最后一段是一个强烈的建议,而不是一个真正的要求,只是声明compareTo方法施加的相等性测试,通常应该返回与equals方法相同的结果。 如果遵守这个约定,则compareTo方法施加的顺序被认为与equals相一致。 如果违反,顺序关系被认为与equals不一致。 其compareTo方法施加与equals不一致顺序关系的类仍然有效,但包含该类元素的有序集合可能不服从相应集合接口(CollectionSetMap)的一般约定。 这是因为这些接口的通用约定是用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的静态导入,以便可以通过其简单名称来引用比较器静态方法,以使其清晰简洁。 以下是PhoneNumbercompareTo方法的使用方法:

// 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](http://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类具有完整的构建方法。对于longdouble基本类型,也有对应的类似于comparingIntthenComparingInt的方法,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接口中的构建方法。

posted @ 2018-01-05 20:48  林本托  阅读(2138)  评论(0编辑  收藏  举报