Effective Java 第三版读书笔记——条款14:考虑实现 Comparable 接口

与本章讨论的其他方法不同,compareTo 方法并没有在 Object 类中声明。相反,它是 Comparable 接口中的唯一方法。 通过实现 Comparable 接口,一个类表明它的实例有一个自然序( natural ordering )。对实现 Comparable 接口的对象所组成的数组排序非常简单,如下所示:

Arrays.sort(a);

通过实现 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 不一致”。

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

compareTo 约定的最后一段是一个强烈的建议,而不是一个真正的要求,只是声明 compareTo 方法执行的相等性测试,通常应该返回与 equals 方法相同的结果。如果遵守这个约定,则 compareTo 方法施加的顺序被认为与 equals 相一致。如果违反,则这个顺序关系被认为与 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 方法。可以编写自己的比较器或使用现有的比较器,如在条款 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
}

在本书前两版中曾经推荐如果比较整型基本类型的属性,使用关系运算符 < 和 >,对于浮点型基本类型的属性,使用 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 接口提供了一系列比较器方法,可以流畅地构建比较器。许多程序员更喜欢这种方法的简洁性,尽管它会牺牲一定地性能。在使用这种方法时,考虑使用 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 中提取区域(area)代码,并返回一个 Comparator<PhoneNumber> ,根据它们的区域代码来对电话号码排序。注意,lambda 表达式显式指定了其输入参数的类型 (PhoneNumber pn)。事实证明,在这种情况下,Java 的类型推断功能还不够强大,无法自行判断类型,因此我们不得不帮助它以使程序编译。

如果两个电话号码实例具有相同的区号,则需要进一步细化比较,这正是第二个比较器构建方法,即 thenComparingInt 方法做的。它是 Comparator 上的一个实例方法,接受一个 int 类型键提取器函数作为参数,并返回一个比较器,该比较器首先应用原始比较器,然后使用提取的键来打破连接。你可以按照喜欢的方式多次调用 thenComparingInt 方法,从而产生一个字典顺序。在上面的例子中,我们调用两个 thenComparingInt 方法来产生一个排序,它的二级键是 prefix,三级键是 lineNum。请注意,我们不必指定传递给 thenComparingInt 方法中键提取器函数的参数类型:Java 的类型推断足够聪明,可以自己推断出参数的类型。

Comparator 类具有完整的构建方法。对于 longdouble 基本类型,也有对应的类似于 comparingIntthenComparingInt的 的方法,int 版本的方法也可以应用在取值范围小于 int 的类型上,如 short 类型。double 版本的方法也可以用在 float 类型上。这提供了对所有 Java 基本数值类型的覆盖。

有时,你可能会看到 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浮点运算失真。应该使用静态 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());
posted @   LeeFire  阅读(226)  评论(0编辑  收藏  举报
编辑推荐:
· 时间轮在 Netty , Kafka 中的设计与实现
· MySQL 优化利器 SHOW PROFILE 的实现原理
· 在.NET Core中使用异步多线程高效率的处理大量数据
· 聊一聊 C#前台线程 如何阻塞程序退出
· 几种数据库优化技巧
阅读排行:
· 跟着 8.6k Star 的开源数据库,搞 RAG!
· .NET 9 中的 多级缓存 HybridCache
· 夜莺 v8 第一个版本来了,开始做有意思的功能了
· .NET 9 增强 OpenAPI 规范,不再内置swagger
· 推荐一个C#轻量级矢量图形库
点击右上角即可分享
微信分享提示