带你读《Effective Java中文版》之三:对于所有对象都通用的方法-阿里云开发者社区

开发者社区> 华章出版社> 正文

带你读《Effective Java中文版》之三:对于所有对象都通用的方法

简介: 本书旨在帮助读者更加有效地使用Java编程语言及其基本类库java.lang、java.util和java.io,以及子包java.util.concurrent和java.util.function等全书以一种比较松散的方式将这些条目规则组织成12章,每一章都涉及软件设计的一个主要方面。

点击这里查看第一章
点击这里查看第二章

第3章

对于所有对象都通用的方法
尽管Object是一个具体类,但设计它主要是为了扩展。它所有的非f?inal方法(equals、
hashCode、toString、clone和finalize)都有明确的通用约定(general contract),因为它们设计成是要被覆盖(override)的。任何一个类,它在覆盖这些方法的时候,都有责任遵守这些通用约定;如果不能做到这一点,其他依赖于这些约定的类(例如HashMap和HashSet)就无法结合该类一起正常运作。
本章将讲述何时以及如何覆盖这些非f?inal的Object方法。本章不再讨论finalize方法,因为第8条已经讨论过这个方法了。而Comparable.compareTo虽然不是Object方法,但是本章也将对它进行讨论,因为它具有类似的特征。

第10条:覆盖equals时请遵守通用约定

覆盖equals方法看起来似乎很简单,但是有许多覆盖方式会导致错误,并且后果非常严重。最容易避免这类问题的办法就是不覆盖equals方法,在这种情况下,类的每个实例都只与它自身相等。如果满足了以下任何一个条件,这就正是所期望的结果:
□类的每个实例本质上都是唯一的。对于代表活动实体而不是值(value)的类来说确实如此,例如Thread。Object提供的equals实现对于这些类来说正是正确的行为。
□类没有必要提供“逻辑相等”(logical equality)的测试功能。例如,java.util.regex.Pattern可以覆盖equals,以检查两个Pattern实例是否代表同一个正则表达式,但是设计者并不认为客户需要或者期望这样的功能。在这类情况之下,从Object继承得到的equals实现已经足够了。
□超类已经覆盖了equals,超类的行为对于这个类也是合适的。例如,大多数的Set实现都从AbstractSet继承equals实现,List实现从AbstractList继承equals实现,Map实现从AbstractMap继承equals实现。
□类是私有的,或者是包级私有的,可以确定它的equals方法永远不会被调用。如果你非常想要规避风险,可以覆盖equals方法,以确保它不会被意外调用:
 image.png

那么,什么时候应该覆盖equals方法呢?如果类具有自己特有的“逻辑相等”(logical equality)概念(不同于对象等同的概念),而且超类还没有覆盖equals。这通常属于“值类”(value class)的情形。值类仅仅是一个表示值的类,例如Integer或者String。程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。为了满足程序员的要求,不仅必须覆盖equals方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key),或者集合(set)的元素,使映射或者集合表现出预期的行为。
有一种“值类”不需要覆盖equals方法,即用实例受控(详见第1条)确保“每个值至多只存在一个对象”的类。枚举类型(详见第34条)就属于这种类。对于这样的类而言,逻辑相同与对象等同是一回事,因此Object的equals方法等同于逻辑意义上的equals方法。
在覆盖equals方法的时候,必须要遵守它的通用约定。下面是约定的内容,来自Object的规范。
equals方法实现了等价关系(equivalence relation),其属性如下:
□自反性(ref?lexive):对于任何非null的引用值x,x.equals(x)必须返回true。
□对称性(symmetric):对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
□传递性(transitive):对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
□一致性(consistent):对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false。
□对于任何非null的引用值x,x.equals(null)必须返回false。
除非你对数学特别感兴趣,否则这些规定看起来可能有点让人感到恐惧,但是绝对不要忽视这些规定!如果违反了,就会发现程序将会表现得不正常,甚至崩溃,而且很难找到失败的根源。用John Donne的话说,没有哪个类是孤立的。一个类的实例通常会被频繁地传递给另一个类的实例。有许多类,包括所有的集合类(collection class)在内,都依赖于传递给它们的对象是否遵守了equals约定。
现在你已经知道了违反equals约定有多么可怕,下面将更细致地讨论这些约定。值得欣慰的是,这些约定虽然看起来很吓人,实际上并不十分复杂。一旦理解了这些约定,要遵守它们并不困难。
那么什么是等价关系呢?不严格地说,它是一个操作符,将一组元素划分到其元素与另一个元素等价的分组中。这些分组被称作等价类(equivalence class)。从用户的角度来看,对于有用的equals方法,每个等价类中的所有元素都必须是可交换的。现在我们按照顺序逐一查看以下5个要求。
自反性(Reflexivity)—第一个要求仅仅说明对象必须等于其自身。很难想象会无意识地违反这一条。假如违背了这一条,然后把该类的实例添加到集合中,该集合的contains
方法将果断地告诉你,该集合不包含你刚刚添加的实例。
对称性(Symmetry)—第二个要求是说,任何两个对象对于“它们是否相等”的问题都必须保持一致。与第一个要求不同,若无意中违反这一条,这种情形倒是不难想象。例如下面的类,它实现了一个区分大小写的字符串。字符串由toString保存,但在equals操作中被忽略。
image.png

在这个类中,equals方法的意图非常好,它企图与普通的字符串对象进行互操作。假设我们有一个不区分大小写的字符串和一个普通的字符串:
image.png

不出所料,cis.equals(s)返回true。问题在于,虽然CaseInsensitiveString
类中的equals方法知道普通的字符串对象,但是,String类中的equals方法却并不知道不区分大小写的字符串。因此,s.equals(cis)返回false,显然违反了对称性。假设你把不区分大小写的字符串对象放到一个集合中:
image.png

此时list.contains(s)会返回什么结果呢?没人知道。在当前的OpenJDK实现中,它碰巧返回false,但这只是这个特定实现得出的结果而已。在其他的实现中,它有可能返回true,或者抛出一个运行时异常。一旦违反了equals约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎么样。
为了解决这个问题,只需把企图与String互操作的这段代码从equals方法中去掉就可以了。这样做之后,就可以重构该方法,使它变成一条单独的返回语句:
image.png

传递性(Transitivity)—equals约定的第三个要求是,如果一个对象等于第二个对象,而第二个对象又等于第三个对象,则第一个对象一定等于第三个对象。同样地,无意识地违反这条规则的情形也不难想象。用子类举个例子。假设它将一个新的值组件(value component)添加到了超类中。换句话说,子类增加的信息会影响equals的比较结果。我们首先以一个简单的不可变的二维整数型Point类作为开始:
image.png

假设你想要扩展这个类,为一个点添加颜色信息:
image.png

equals方法会是什么样的呢?如果完全不提供equals方法,而是直接从Point继承过来,在equals做比较的时候颜色信息就被忽略掉了。虽然这样做不会违反equals约定,但很明显这是无法接受的。假设编写了一个equals方法,只有当它的参数是另一个有色点,并且具有同样的位置和颜色时,它才会返回true:
image.png

这个方法的问题在于,在比较普通点和有色点,以及相反的情形时,可能会得到不同的结果。前一种比较忽略了颜色信息,而后一种比较则总是返回false,因为参数的类型不正确。为了直观地说明问题所在,我们创建一个普通点和一个有色点:
image.png

然后,p.equals(cp)返回true,cp.equals(p)则返回false。你可以做这样的尝试来修正这个问题,让ColorPoint.equals在进行“混合比较”时忽略颜色信息:
image.png

这种方法确实提供了对称性,但是却牺牲了传递性:
image.png

此时,p1.equals(p2)和p2.equals(p3)都返回true,但是p1.equals(p3)则返回false,很显然这违反了传递性。前两种比较不考虑颜色信息(“色盲”),而第三种比较则考虑了颜色信息。
此外,这种方法还可能导致无限递归问题:假设Point有两个子类,如ColorPoint和SmellPoint,它们各自都带有这种equals方法。那么对myColorPoint.equals
(mySmellPoint)的调用将会抛出StackOverflowError异常。
那该怎么解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。
你可能听说过,在equals方法中用getClass测试代替instanceof测试,可以扩展可实例化的类和增加新的值组件,同时保留equals约定:
image.png

这段程序只有当对象具有相同的实现类时,才能使对象等同。虽然这样也不算太糟糕,但结果却是无法接受的:Point子类的实例仍然是一个Point,它仍然需要发挥作用,但是如果采用了这种方法,它就无法完成任务!假设我们要编写一个方法,以检验某个点是否处在单位圆中。下面是可以采用的其中一种方法:
image.png

虽然这可能不是实现这种功能的最快方式,不过它的效果很好。但是假设你通过某种不添加值组件的方式扩展了Point,例如让它的构造器记录创建了多少个实例:
image.png

里氏替换原则(Liskov substitution principle)认为,一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上也应该同样运行得很好[Liskov87]。针对上述Point的子类(如CounterPoint)仍然是Point,并且必须发挥作用的例子,这个就是它的正式语句。但是假设我们将CounterPoint实例传给了onUnitCircle方法。如果Point类使用了基于getClass的equals方法,无论CounterPoint实例的x和y值是什么,onUnitCircle方法都会返回false。这是因为像onUnitCircle方法所用的HashSet这样的集合,利用equals方法检验包含条件,没有任何CounterPoint实例与任何Point对应。但是,如果在Point上使用适当的基于instanceof的equals方法,当遇到CounterPoint时,相同的onUnitCircle方法就会工作得很好。
虽然没有一种令人满意的办法可以既扩展不可实例化的类,又增加值组件,但还是有一种不错的权宜之计:遵从第18条“复合优先于继承”的建议。我们不再让ColorPoint扩展Point,而是在ColorPoint中加入一个私有的Point域,以及一个公有的视图(view)方法(详见第6条),此方法返回一个与该有色点处在相同位置的普通Point对象:
image.png

在Java平台类库中,有一些类扩展了可实例化的类,并添加了新的值组件。例如,java.
sql.Timestamp对java.util.Date进行了扩展,并增加了nanoseconds域。Times-
tamp的equals实现确实违反了对称性,如果Timestamp和Date对象用于同一个集合中,或者以其他方式被混合在一起,则会引起不正确的行为。Timestamp类有一个免责声明,告诫程序员不要混合使用Date和Timestamp对象。只要你不把它们混合在一起,就不会有麻烦,除此之外没有其他的措施可以防止你这么做,而且结果导致的错误将很难调试。Timestamp类的这种行为是个错误,不值得仿效。
注意,你可以在一个抽象(abstract)类的子类中增加新的值组件且不违反equals约定。对于根据第23条的建议而得到的那种类层次结构来说,这一点非常重要。例如,你可能有一个抽象的Shape类,它没有任何值组件,Circle子类添加了一个radius域,Rectangle子类添加了length和width域。只要不可能直接创建超类的实例,前面所述的种种问题就都不会发生。
一致性(Consistency)—equals约定的第四个要求是,如果两个对象相等,它们就必须始终保持相等,除非它们中有一个对象(或者两个都)被修改了。换句话说,可变对象在不同的时候可以与不同的对象相等,而不可变对象则不会这样。当你在写一个类的时候,应该仔细考虑它是否应该是不可变的(详见第17条)。如果认为它应该是不可变的,就必须保证equals方法满足这样的限制条件:相等的对象永远相等,不相等的对象永远不相等。
无论类是否是不可变的,都不要使equals方法依赖于不可靠的资源。如果违反了这条禁令,要想满足一致性的要求就十分困难了。例如,java.net.URL的equals方法依赖于对URL中主机IP地址的比较。将一个主机名转变成IP地址可能需要访问网络,随着时间的推移,就不能确保会产生相同的结果,即有可能IP地址发生了改变。这样会导致URL equals方法违反equals约定,在实践中有可能引发一些问题。URL equals方法的行为是一个大错误并且不应被模仿。遗憾的是,因为兼容性的要求,这一行为无法被改变。为了避免发生这种问题,equals方法应该对驻留在内存中的对象执行确定性的计算。
非空性(Non-nullity)—最后一个要求没有正式名称,我姑且称它为“非空性”,意思是指所有的对象都不能等于null。尽管很难想象在什么情况下o.equals(null)调用会意外地返回true,但是意外抛出NullPointerException异常的情形却不难想象。通用约定不允许抛出NullPointerException异常。许多类的equals方法都通过一个显式的null测试来防止这种情况:
image.png

这项测试是不必要的。为了测试其参数的等同性,equals方法必须先把参数转换成适当的类型,以便可以调用它的访问方法,或者访问它的域。在进行转换之前,equals方法必须使用instanceof操作符,检查其参数的类型是否正确:
image.png

如果漏掉了这一步的类型检查,并且传递给equals方法的参数又是错误的类型,那么equals方法将会抛出ClassCastException异常,这就违反了equals约定。但是,如果instanceof的第一个操作数为null,那么,不管第二个操作数是哪种类型,instanceof操作符都指定应该返回false[JLS,15.20.2]。因此,如果把null传给equals方法,类型检查就会返回false,所以不需要显式的null检查。
结合所有这些要求,得出了以下实现高质量equals方法的诀窍:
1.使用==操作符检查“参数是否为这个对象的引用”。如果是,则返回true。这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
2.使用instanceof操作符检查“参数是否为正确的类型”。如果不是,则返回false。
一般说来,所谓“正确的类型”是指equals方法所在的那个类。某些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如Set、List、Map和Map.Entry具有这样的特性。
3.把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。
4.对于该类中的每个“关键”(significant)域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true;否则返回false。如果第2步中的类型是个接口,就必须通过接口方法访问参数中的域;如果该类型是个类,也许就能够直接访问参数中的域,这要取决于它们的可访问性。
对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用静态Float.
compare(float,float)方法;对于double域,则使用Double.compare(double,double)。
对float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量;详细信息请参考JLS 15.21.1或者Float.equals的文档。虽然可以用静态方法Float.equals和Double.equals对float和double域进行比较,但是每次比较都要进行自动装箱,这会导致性能下降。对于数组域,则要把以上这些指导原则应用到每一个元素上。如果数组域中的每个元素都很重要,就可以使用其中一个Arrays.equals方法。
有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointer
Exception异常,则使用静态方法Objects.equals(Object,Object)来检查这类域的等同性。
对于有些类,比如前面提到的CaseInsensitiveString类,域的比较要比简单的等同性测试复杂得多。如果是这种情况,可能希望保存该域的一个“范式”(canonical form),这样equals方法就可以根据这些范式进行低开销的精确比较,而不是高开销的非精确比较。这种方法对于不可变类(详见第17条)是最为合适的;如果对象可能发生变化,就必须使其范式保持最新。
域的比较顺序可能会影响equals方法的性能。为了获得最佳的性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域。不应该比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域。也不需要比较衍生域(derived f?ield),因为这些域可以由“关键域”(signif?icant f?ield)计算获得,但是这样做有可能提高equals方法的性能。如果衍生域代表了整个对象的综合描述,比较这个域可以节省在比较失败时去比较实际数据所需要的开销。例如,假设有一个Polygon类,并缓存了该面积。如果两个多边形有着不同的面积,就没有必要去比较它们的边和顶点。
在编写完equals方法之后,应该问自己三个问题:它是否是对称的、传递的、一致的?并且不要只是自问,还要编写单元测试来检验这些特性,除非用AutoValue(后面会讲到)生成equals方法,在这种情况下就可以放心地省略测试。如果答案是否定的,就要找出原因,再相应地修改equals方法的代码。当然,equals方法也必须满足其他两个特性(自反性和非空性),但是这两种特性通常会自动满足。
根据上面的诀窍构建equals方法的具体例子,请看下面这个简单的PhoneNumber类:
image.png

下面是最后的一些告诫:
□覆盖equals时总要覆盖hashCode(详见第11条)。
□不要企图让equals方法过于智能。如果只是简单地测试域中的值是否相等,则不难做到遵守equals约定。如果想过度地去寻求各种等价关系,则很容易陷入麻烦之中。把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。例如,File类不应该试图把指向同一个文件的符号链接(symbolic link)当作相等的对象来看待。所幸File类没有这样做。
□不要将equals声明中的Object对象替换为其他的类型。程序员编写出下面这样的equals方法并不鲜见,这会使程序员花上数个小时都搞不清为什么它不能正常工作:
image.png

问题在于,这个方法并没有覆盖(override)Object.equals,因为它的参数应该是Object类型,相反,它重载(overload)了Object.equals(详见第52条)。在正常equals方法的基础上,再提供一个“强类型”(strongly typed)的equals方法,这是无法接受的,因为会导致子类中的Override注解产生错误的正值,带来错误的安全感。
@Override注解的用法一致,就如本条目中所示,可以防止犯这种错误(详见第40条)。这个equals方法不能编译,错误消息会告诉你到底哪里出了问题:
image.png

编写和测试equals(及hashCode)方法都是十分烦琐的,得到的代码也很琐碎。代替手工编写和测试这些方法的最佳途径,是使用Google开源的AutoValue框架,它会自动替你生成这些方法,通过类中的单个注解就能触发。在大多数情况下,AutoValue生成的方法本质上与你亲自编写的方法是一样的。
IDE也有工具可以生成equals和hashCode方法,但得到的源代码比使用Auto-
Value的更加冗长,可读性也更差,它无法自动追踪类中的变化,因此需要进行测试。也就是说,让IDE生成equals(及hashCode)方法,通常优于手工实现它们,因为IDE不会犯粗心的错误,但是程序员会犯错。
总而言之,不要轻易覆盖equals方法,除非迫不得已。因为在许多情况下,从Object处继承的实现正是你想要的。如果覆盖equals,一定要比较这个类的所有关键域,并且查看它们是否遵守equals合约的所有五个条款。

第11条:覆盖equals时总要覆盖hashCode

在每个覆盖了equals方法的类中,都必须覆盖hashCode方法。如果不这样做的话,就会违反hashCode的通用约定,从而导致该类无法结合所有基于散列的集合一起正常运作,这类集合包括HashMap和HashSet。下面是约定的内容,摘自Object规范:
□在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对同一个对象的多次调用,hashCode方法都必须始终返回同一个值。在一个应用程序与另一个程序的执行过程中,执行hashCode方法所返回的值可以不一致。
□如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生同样的整数结果。
□如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中的hashCode方法,则不一定要求hashCode方法必须产生不同的结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。
因没有覆盖hashCode而违反的关键约定是第二条:相等的对象必须具有相等的散列码(hash code)。根据类的equals方法,两个截然不同的实例在逻辑上有可能是相等的,但是根据Object类的hashCode方法,它们仅仅是两个没有任何共同之处的对象。因此,对象的hashCode方法返回两个看起来是随机的整数,而不是根据第二个约定所要求的那样,返回两个相等的整数。
假设在HashMap中用第10条中出现过的PhoneNumber类的实例作为键:
image.png

此时,你可能期望m.get(new PhoneNumber(707, 867, 5309))会返回“Jenny”,但它实际上返回的是null。注意,这里涉及两个PhoneNumber实例:第一个被插入HashMap
中,第二个实例与第一个相等,用于从Map中根据PhoneNumber去获取用户名字。由于PhoneNumber类没有覆盖hashCode方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode的约定。因此,put方法把电话号码对象存放在一个散列桶(hash bucket)中,get方法却在另一个散列桶中查找这个电话号码。即使这两个实例正好被放到同一个散列桶中,get方法也必定会返回null,因为HashMap有一项优化,可以将与每个项相关联的散列码缓存起来,如果散列码不匹配,也就不再去检验对象的等同性。
修正这个问题非常简单,只需为PhoneNumber类提供一个适当的hashCode方法即可。那么,hashCode方法应该是什么样的呢?编写一个合法但并不好用的hashCode方法没有任何价值。例如,下面这个方法总是合法的,但是它永远都不应该被正式使用:
image.png

上面这个hashCode方法是合法的,因为它确保了相等的对象总是具有同样的散列码。但它也极为恶劣,因为它使得每个对象都具有同样的散列码。因此,每个对象都被映射到同一个散列桶中,使散列表退化为链表(linked list)。它使得本该线性时间运行的程序变成了以平方级时间在运行。对于规模很大的散列表而言,这会关系到散列表能否正常工作。
一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”。这正是hashCode约定中第三条的含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的int值上。要想完全达到这种理想的情形是非常困难的。幸运的是,相对接近这种理想情形则并不太困难。下面给出一种简单的解决办法:

  1. 声明一个int变量并命名为result,将它初始化为对象中第一个关键域的散列码c,如步骤2.a中计算所示(如第10条所述,关键域是指影响equals比较的域)。
  2. 对象中剩下的每一个关键域f都完成以下步骤:
    a.为该域计算int类型的散列码c:

    Ⅰ.    如果该域是基本类型,则计算Type.hashCode(f),这里的Type是装箱基本类型的类,与f的类型相对应。
    Ⅱ.    如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals的方式来比较这个域,则同样为这个域递归地调用hashCode。如果需要更复杂的比较,则为这个域计算一个“范式”(canonical representation),然后针对这个范式调用hashCode。如果这个域的值为null,则返回0(或者其他某个常数,但通常是0)。
    Ⅲ.    如果该域是一个数组,则要把每一个元素当作单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组域中没有重要的元素,可以使用一个常量,但最好不要用0。如果数组域中的所有元素都很重要,可以使用Arrays.hashCode方法。

    b.按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:

        ![image.png](https://ucc.alicdn.com/pic/developer-ecology/a0350564f2cb4e97b88852c52479b9cb.png)
    
  3. 返回result。
    写完了hashCode方法之后,问问自己“相等的实例是否都具有相等的散列码”。要编写单元测试来验证你的推断(除非利用AutoValue生成equals和hashCode方法,这样你就可以放心地省略这些测试)。如果相等的实例有着不相等的散列码,则要找出原因,并修正错误。

在散列码的计算过程中,可以把衍生域(derived f?ield)排除在外。换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。必须排除equals比较计算中没有用到的任何域,否则很有可能违反hashCode约定的第二条。
步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串将都会有相同的散列码。之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i == (i << 5) - i。
现代的虚拟机可以自动完成这种优化。
现在我们要把上述解决办法用到PhoneNumber类中:
image.png

因为这个方法返回的结果是一个简单、确定的计算结果,它的输入只是PhoneNumber实例中的三个关键域,因此相等的PhoneNumber实例显然都会有相等的散列码。实际上,对于PhoneNumber的hashCode实现而言,上面这个方法是非常合理的,相当于Java平台类库中的实现。它的做法非常简单,也相当快捷,恰当地把不相等的电话号码分散到不同的散列桶中。
虽然本条目中前面给出的hashCode实现方法能够获得相当好的散列函数,但它们并不是最先进的。它们的质量堪比Java平台类库的值类型中提供的散列函数,这些方法对于绝大多数应用程序而言已经足够了。如果执意想让散列函数尽可能地不会造成冲突,请参阅Guava’s com.google.common.hash.Hashing [Guava]。
Objects类有一个静态方法,它带有任意数量的对象,并为它们返回一个散列码。这个方法名为hash,是让你只需要编写一行代码的hashCode方法,与根据本条目前面介绍过的解决方案编写出来的相比,它的质量是与之相当的。遗憾的是,运行速度更慢一些,因为它们会引发数组的创建,以便传入数目可变的参数,如果参数中有基本类型,还需要装箱和拆箱。建议只将这类散列函数用于不太注重性能的情况。下面就是用这种方法为PhoneNumber编写的散列函数:
image.png

如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用作散列键(hash keys),就应该在创建实例的时候计算散列码。否则,可以选择“延迟初始化”(lazily initialize)散列码,即一直到hashCode被第一次调用的时候才初始化(见第83条)。虽然我们的PhoneNumber类不值得这样处理,但是可以通过它来说明这种方法该如何实现。注意hashCode域的初始值(在本例中是0)一般不能成为创建的实例的散列码:
image.png

不要试图从散列码计算中排除掉一个对象的关键域来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。特别是在实践中,散列函数可能面临大量的实例,在你选择忽略的区域之中,这些实例仍然区别非常大。如果是这样,散列函数就会把所有这些实例映射到极少数的散列码上,原本应该以线性级时间运行的程序,将会以平方级的时间运行。
这不只是一个理论问题。在Java 2发行版本之前,一个String散列函数最多只能使用16个字符,若长度少于16个字符就计算所有的字符,否则就从第一个字符开始,在整个字符串中间隔均匀地选取样本进行计算。对于像URL这种层次状名称的大型集合,该散列函数正好表现出了这里所提到的病态行为。
不要对hashCode方法的返回值做出具体的规定,因此客户端无法理所当然地依赖它;这样可以为修改提供灵活性。Java类库中的许多类,比如String和Integer,都可以把它们的hashCode方法返回的确切值规定为该实例值的一个函数。一般来说,这并不是个好主意,因为这样做严格地限制了在未来的版本中改进散列函数的能力。如果没有规定散列函数的细节,那么当你发现了它的内部缺陷时,或者发现了更好的散列函数时,就可以在后面的发行版本中修正它。
总而言之,每当覆盖equals方法时都必须覆盖hashCode,否则程序将无法正确运行。hashCode方法必须遵守Object规定的通用约定,并且必须完成一定的工作,将不相等的散列码分配给不相等的实例。这个很容易实现,但是如果不想那么费力,也可以使用前文建议的解决方法。如第10条所述,AutoValue框架提供了很好的替代方法,可以不必手工编写equals和hashCode方法,并且现在的集成开发环境IDE也提供了类似的部分功能。

第12条:始终要覆盖toString

虽然Object提供了toString方法的一个实现,但它返回的字符串通常并不是类的用户所期望看到的。它包含类的名称,以及一个“@”符号,接着是散列码的无符号十六进制表示法,例如PhoneNumber@163b91。toString的通用约定指出,被返回的字符串应该是一个“简洁的但信息丰富,并且易于阅读的表达形式”。尽管有人认为PhoneNumber@163b91算得上是简洁和易于阅读了,但是与707-867-5309比较起来,它还算不上是信息丰富的。toString约定进一步指出,“建议所有的子类都覆盖这个方法。”这是一个很好的建议,真的!
遵守toString约定并不像遵守equals和hashCode的约定(见第10条和第11条)那么重要,但是,提供好的toString实现可以使类用起来更加舒适,使用了这个类的系统也更易于调试。当对象被传递给println、printf、字符串联操作符(+)以及assert,或者被调试器打印出来时,toString方法会被自动调用。即使你永远不调用对象的toString方法,但是其他人也许可能需要。例如,带有对象引用的一个组件,在它记录的错误消息中,可能包含该对象的字符串表示法。如果你没有覆盖toString,这条消息可能就毫无用处。
如果为PhoneNumber提供了好的toString方法,那么要产生有用的诊断消息会非常容易:
image.png

不管是否覆盖了toString方法,程序员都将以这种方式来产生诊断消息,但是如果没有覆盖toString方法,产生的消息将难以理解。提供好的toString方法,不仅有益于这个类的实例,同样也有益于那些包含这些实例的引用的对象,特别是集合对象。打印Map时会看到消息{Jenny = PhoneNumber@163b91}或{Jenny = 707-867-5309},你更愿意看到哪一个?
在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息,例如上述电话号码例子那样。如果对象太大,或者对象中包含的状态信息难以用字符串来表达,这样做就有点不切实际。在这种情况下,toString应该返回一个摘要信息,例如“Manhattan residential phone directory (1487536 listings)”或者“Thread[main, 5, main]”。理想情况下,字符串应该是自描述的(self-explanatory)。(Thread例子不满足这样的要求。)如果对象的字符串表示法中没有包含对象的所有必要信息,测试失败时得到的报告将会像下面这样:
image.png

在实现toString的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。对于值类(value class),比如电话号码类、矩阵类,建议这么做。指定格式的好处是,它可以被用作一种标准的、明确的、适合人阅读的对象表示法。这种表示法可以用于输入和输出,以及用在永久适合人类阅读的数据对象中,例如CSV文档。如果你指定了格式,通常最好再提供一个相匹配的静态工厂或者构造器,以便程序员可以很容易地在对象及其字符串表示法之间来回转换。Java平台类库中的许多值类都采用了这种做法,包括BigInteger、BigDecimal和绝大多数的基本类型包装类(boxed primitive class)。
指定toString返回值的格式也有不足之处:如果这个类已经被广泛使用,一旦指定格式,就必须始终如一地坚持这种格式。程序员将会编写出相应的代码来解析这种字符串表示法、产生字符串表示法,以及把字符串表示法嵌入持久的数据中。如果将来的发行版本中改变了这种表示法,就会破坏他们的代码和数据,他们当然会抱怨。如果不指定格式,就可以保留灵活性,便于在将来的发行版本中增加信息,或者改进格式。
无论是否决定指定格式,都应该在文档中明确地表明你的意图。如果要指定格式,则应该严格地这样去做。例如,下面是第11条中PhoneNumber类的toString方法:
image.png

如果你决定不指定格式,那么文档注释部分也应该有如下所示的指示信息:
image.png

对于那些依赖于格式的细节进行编程或者产生永久数据的程序员,在读到这段注释之后,一旦格式被改变,则只能自己承担后果。
无论是否指定格式,都为toString返回值中包含的所有信息提供一种可以通过编程访问之的途径。例如,PhoneNumber类应该包含针对area code、pref?ix和line number的访问方法。如果不这么做,就会迫使需要这些信息的程序员不得不自己去解析这些字符串。除了降低了程序的性能,使得程序员们去做这些不必要的工作之外,这个解析过程也很容易出错,会导致系统不稳定,如果格式发生变化,还会导致系统崩溃。如果没有提供这些访问方法,即使你已经指明了字符串的格式是会变化的,这个字符串格式也成了事实上的API。
在静态工具类(详见第4条)中编写toString方法是没有意义的。也不要在大多数枚举类型(详见第34条)中编写toString方法,因为Java已经为你提供了非常完美的方法。但是,在所有其子类共享通用字符串表示法的抽象类中,一定要编写一个toString方法。例如,大多数集合实现中的toString方法都是继承自抽象的集合类。
在第10条中讨论过的Google公司开源的AutoValue工具,会替你生成toString方法,大多数集成开发环境IDE也有这样的功能。这些方法都能很好地告诉你每个域的内容,但是并不特定于该类的意义(meaning)。因此,比如对于上述PhoneNumber类就不适合用自动生成的toString方法(因为电话号码有标准的字符串表示法),但是我们的Potion类就非常适合。也就是说,自动生成的toString方法要远远优先于继承自Object的方法,因为它无法告诉你任何关于对象值的信息。
总而言之,要在你编写的每一个可实例化的类中覆盖Object的toString实现,除非已经在超类中这么做了。这样会使类使用起来更加舒适,也更易于调试。toString方法应该以美观的格式返回一个关于对象的简洁、有用的描述。

第13条:谨慎地覆盖clone

Cloneable接口的目的是作为对象的一个mixin接口(mixin interface)(详见第20条),表明这样的对象允许克隆(clone)。遗憾的是,它并没有成功地达到这个目的。它的主要缺陷在于缺少一个clone方法,而Object的clone方法是受保护的。如果不借助于反射(ref?lection)(详见第65条),就不能仅仅因为一个对象实现了Cloneable,就调用clone方法。即使是反射调用也可能会失败,因为不能保证该对象一定具有可访问的clone方法。尽管存在这样或那样的缺陷,这项设施仍然被广泛使用,因此值得我们进一步了解。本条目将告诉你如何实现一个行为良好的clone方法,并讨论何时适合这样做,同时也简单地讨论了其他的可替代做法。
既然Cloneable接口并没有包含任何方法,那么它到底有什么作用呢?它决定了Object
中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。这是接口的一种极端非典型的用法,也不值得仿效。通常情况下,实现接口是为了表明类可以为它的客户做些什么。然而,对于Cloneable接口,它改变了超类中受保护的方法的行为。
虽然规范中没有明确指出,事实上,实现Cloneable接口的类是为了提供一个功能适当的公有的clone方法。为了达到这个目的,类及其所有超类都必须遵守一个相当复杂的、不可实施的,并且基本上没有文档说明的协议。由此得到一种语言之外的(extralinguistic)
机制:它无须调用构造器就可以创建对象。
clone方法的通用约定是非常弱的,下面是来自Object规范中的约定内容:
创建和返回该对象的一个拷贝。这个“拷贝”的精确含义取决于该对象的类。一般的含义是,对于任何对象x,表达式
image.png

将会返回结果true,并且表达式
image.png

将会返回结果true,但这些都不是绝对的要求。虽然通常情况下,表达式
image.png

将会返回结果true,但是,这也不是一个绝对的要求。
按照约定,这个方法返回的对象应该通过调用super.clone获得。如果类及其超类(Object除外)遵守这一约定,那么:
image.png

按照约定,返回的对象应该不依赖于被克隆的对象。为了成功地实现这种独立性,可能需要在super.clone返回对象之前,修改对象的一个或更多个域。
这种机制大体上类似于自动的构造器调用链,只不过它不是强制要求的:如果类的clone
方法返回的实例不是通过调用super.clone方法获得,而是通过调用构造器获得,编译器就不会发出警告,但是该类的子类调用了super.clone方法,得到的对象就会拥有错误的类,并阻止了clone方法的子类正常工作。如果f?inal类覆盖了clone方法,那么这个约定可以被安全地忽略,因为没有子类需要担心它。如果f?i?nal类的clone方法没有调用super.clone方法,这个类就没有理由去实现Cloneable接口了,因为它不依赖于Object克隆实现的行为。
假设你希望在一个类中实现Cloneable接口,并且它的超类都提供了行为良好的clone
方法。首先,调用super.clone方法。由此得到的对象将是原始对象功能完整的克隆(clone)。
在这个类中声明的域将等同于被克隆对象中相应的域。如果每个域包含一个基本类型的值,或者包含一个指向不可变对象的引用,那么被返回的对象则可能正是你所需要的对象,在这种情况下不需要再做进一步处理。例如,第11条中的PhoneNumber类正是如此,但要注意,不可变的类永远都不应该提供clone方法,因为它只会激发不必要的克隆。因此,PhoneNumber的clone方法应该是这样的:
image.png

为了让这个方法生效,应该修改PhoneNumber的类声明为实现Cloneable接口。虽然Object的clone方法返回的是Object,但这个clone方法返回的却是PhoneNumber。这么做是合法的,也是我们所期望的,因为Java支持协变返回类型(covariant return type)。换句话说,目前覆盖方法的返回类型可以是被覆盖方法的返回类型的子类了。这样在客户端中就不必进行转换了。我们必须在返回结果之前,先将super.clone从Object转换成PhoneNumber,当然这种转换是一定会成功的。
对super.clone方法的调用应当包含在一个try-catch块中。这是因为Object声明其clone方法抛出CloneNotSupportedException,这是一个受检异常(checked exception)。由于PhoneNumber实现了Cloneable接口,我们知道调用super.clone方法一定会成功。对于这个样板代码的需求表明,CloneNotSupportedException应该还没有被检查到(详见第71条)。
如果对象中包含的域引用了可变的对象,使用上述这种简单的clone实现可能会导致灾难性的后果。例如第7条中的Stack类:
image.png
image.png

假设你希望把这个类做成可克隆的(cloneable)。如果它的clone方法仅仅返回super.
clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。修改原始的实例会破坏被克隆对象中的约束条件,反之亦然。很快你就会发现,这个程序将产生毫无意义的结果,或者抛出Null-PointerException异常。
如果调用Stack类中唯一的构造器,这种情况就永远不会发生。实际上,clone方法就是另一个构造器;必须确保它不会伤害到原始的对象,并确保正确地创建被克隆对象中的约束条件(invariant)。为了使Stack类中的clone方法正常工作,它必须要拷贝栈的内部信息。最容易的做法是,在elements数组中递归地调用clone:
image.png

注意,我们不一定要将elements.clone()的结果转换成Object[]。在数组上调用clone返回的数组,其编译时的类型与被克隆数组的类型相同。这是复制数组的最佳习惯做法。事实上,数组是clone方法唯一吸引人的用法。
还要注意如果elements域是final的,上述方案就不能正常工作,因为clone方法是被禁止给final域赋新值的。这是个根本的问题:就像序列化一样,Cloneable架构与引用可变对象的final域的正常用法是不相兼容的,除非在原始对象和克隆对象之间可以安全地共享此可变对象。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符。
递归地调用clone有时还不够。例如,假设你正在为一个散列表编写clone方法,它的内部数据包含一个散列桶数组,每个散列桶都指向“键-值”对链表的第一项。出于性能方面的考虑,该类实现了它自己的轻量级单向链表,而没有使用Java内部的java.util.LinkedList:
image.png

假设你仅仅递归地克隆这个散列桶数组,就像我们对Stack类所做的那样:
image.png

虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表与原始对象是一样的,从而很容易引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独地拷贝并组成每个桶的链表。下面是一种常见的做法:
image.png
image.png

私有类HashTable.Entry被加强了,它支持一个“深度拷贝”(deep copy)方法。HashTable上的clone方法分配了一个大小适中的、新的buckets数组,并且遍历原始的buckets数组,对每一个非空散列桶进行深度拷贝。Entry类中的深度拷贝方法递归地调用它自身,以便拷贝整个链表(它是链表的头节点)。虽然这种方法很灵活,如果散列桶不是很长,也会工作得很好,但是,这样克隆一个链表并不是一种好办法,因为针对列表中的每个元素,它都要消耗一段栈空间。如果链表比较长,这很容易导致栈溢出。为了避免发生这种情况,你可以在deepCopy方法中用迭代(iteration)代替递归(recursion):
image.png

克隆复杂对象的最后一种办法是,先调用super.clone方法,然后把结果对象中的所有域都设置成它们的初始状态(initial state),然后调用高层(higher-level)的方法来重新产生对象的状态。在我们的HashTable例子中,buckets域将被初始化为一个新的散列桶数组,然后,对于正在被克隆的散列表中的每一个键-值映射,都调用put(key, value)方法(上面没有给出其代码)。这种做法往往会产生一个简单、合理且相当优美的clone方法,但是它运行起来通常没有“直接操作对象及其克隆对象的内部状态的clone方法”快。虽然这种方法干脆利落,但它与整个Cloneable架构是对立的,因为它完全抛弃了Cloneable架构基础的逐域对象复制的机制。
像构造器一样,clone方法也不应该在构造的过程中,调用可以覆盖的方法(详见第19条)。如果clone调用了一个在子类中被覆盖的方法,那么在该方法所在的子类有机会修正它在克隆对象中的状态之前,该方法就会先被执行,这样很有可能会导致克隆对象和原始对象之间的不一致。因此,上一段中讨论到的put(key, value)方法要么应是f?inal的,要么应是私有的。(如果是私有的,它应该算是非f?inal公有方法的“辅助方法”。)
Object的clone方法被声明为可抛出CloneNotSupportedException异常,但是,覆盖版本的clone方法可以忽略这个声明。公有的clone方法应该省略throws声明,因为不会抛出受检异常的方法使用起来更加轻松(详见第71条)。
为继承(详见第19条)设计类有两种选择,但是无论选择其中的哪一种方法,这个类都不应该实现Cloneable接口。你可以选择模拟Object的行为:实现一个功能适当的受保护的clone方法,它应该被声明抛出CloneNotSupportedException异常。这样可以使子类具有实现或不实现Cloneable接口的自由,就仿佛它们直接扩展了Object一样。或者,也可以选择不去实现一个有效的clone方法,并防止子类去实现它,只需要提供下列退化了的clone实现即可:
image.png

还有一点值得注意。如果你编写线程安c全的类准备实现Cloneable接口,要记住它的clone方法必须得到严格的同步,就像任何其他方法一样(详见第78条)。Object的clone方法没有同步,即使很满意可能也必须编写同步的clone方法来调用super.clone(),即实现synchronized clone()方法。
简而言之,所有实现了Cloneable接口的类都应该覆盖clone方法,并且是公有的方法,它的返回类型为类本身。该方法应该先调用super.clone方法,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部“深层结构”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。虽然,这些内部拷贝操作往往可以通过递归地调用clone来完成,但这通常并不是最佳方法。如果该类只包含基本类型的域,或者指向不可变对象的引用,那么多半的情况是没有域需要修正。这条规则也有例外。例如,代表序列号或其他唯一ID值的域,不管这些域是基本类型还是不可变的,它们也都需要被修正。
真的有必要这么复杂吗?很少有这种必要。如果你扩展一个实现了Cloneable接口的类,那么你除了实现一个行为良好的clone方法外,没有别的选择。否则,最好提供某些其他的途径来代替对象拷贝。对象拷贝的更好的办法是提供一个拷贝构造器(copy constructor)或拷贝工厂(copy factory)。拷贝构造器只是一个构造器,它唯一的参数类型是包含该构造器的类,例如:
image.png

拷贝工厂是类似于拷贝构造器的静态工厂(详见第1条):
image.png

拷贝构造器的做法,及其静态工厂方法的变形,都比Cloneable/clone方法具有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;它们不会与f?inal域的正常使用发生冲突;它们不会抛出不必要的受检异常;它们不需要进行类型转换。
甚至,拷贝构造器或者拷贝工厂可以带一个参数,参数类型是该类所实现的接口。例如,按照惯例所有通用集合实现都提供了一个拷贝构造器,其参数类型为Collection或者Map接口。基于接口的拷贝构造器和拷贝工厂(更准确的叫法应该是转换构造器(conversion
constructor)和转换工厂(conversion factory)),允许客户选择拷贝的实现类型,而不是强迫客户接受原始的实现类型。例如,假设你有一个HashSet:s,并且希望把它拷贝成一个TreeSet。clone方法无法提供这样的功能,但是用转换构造器很容易实现:new TreeSet<>(s)。
既然所有的问题都与Cloneable接口有关,新的接口就不应该扩展这个接口,新的可扩展的类也不应该实现这个接口。虽然f?inal类实现Cloneable接口没有太大的危害,这个应该被视同性能优化,留到少数必要的情况下才使用(详见第67条)。总之,复制功能最好由构造器或者工厂提供。这条规则最绝对的例外是数组,最好利用clone方法复制数组。

第14条:考虑实现Comparable接口

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

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

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

compareTo方法的通用约定与equals方法的约定相似:
将这个对象与指定的对象进行比较。当该对象小于、等于或大于指定对象的时候,分别返回一个负整数、零或者正整数。如果由于指定对象的类型而无法与该对象进行比较,则抛出ClassCastException异常。
在下面的说明中,符号sgn(expression)表示数学中的signum函数,它根据表达式(expression)的值为负值、零和正值,分别返回-1、0或1。
□实现者必须确保所有的x和y都满足sgn(x.compareTo(y)) == -sgn (y.com-
pareTo(x))。(这也暗示着,当且仅当y.compareTo(x)抛出异常时,x.com-pareTo(y)才必须抛出异常。)
□实现者还必须确保这个比较关系是可传递的:(x.compareTo(y) > 0 && y.compareTo
(z)> 0)暗示着x.compareTo(z) > 0。
□最后,实现者必须确保x.compareTo(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约定的类也会破坏其他依赖于比较关系的类。依赖于比较关系的类包括有序集合类Tree-
Set和TreeMap,以及工具类Collections和Arrays,它们内部包含有搜索和排序算法。
现在我们来回顾一下compareTo约定中的条款。第一条指出,如果颠倒了两个对象引用之间的比较方向,就会发生下面的情况:如果第一个对象小于第二个对象,则第二个对象一定大于第一个对象;如果第一个对象等于第二个对象,则第二个对象一定等于第一个对象;如果第一个对象大于第二个对象,则第二个对象一定小于第一个对象。第二条指出,如果一个对象大于第二个对象,并且第二个对象又大于第三个对象,那么第一个对象一定大于第三个对象。最后一条指出,在比较时被认为相等的所有对象,它们跟别的对象做比较时一定会产生同样的结果。
这三个条款的一个直接结果是,由compareTo方法施加的等同性测试,也必须遵守相同于equals约定所施加的限制条件:自反性、对称性和传递性。因此,下面的告诫也同样适用:无法在用新的值组件扩展可实例化的类时,同时保持compareTo约定,除非愿意放弃面向对象的抽象优势(详见第10条)。针对equals的权宜之计也同样适用于compareTo方法。如果你想为一个实现了Comparable接口的类增加值组件,请不要扩展这个类;而是要编写一个不相关的类,其中包含第一个类的一个实例。然后提供一个“视图”(view)方法返回这个实例。这样既可以让你自由地在第二个类上实现compareTo方法,同时也允许它的客户端在必要的时候,把第二个类的实例视同第一个类的实例。
compareTo约定的最后一段是一条强烈的建议,而不是真正的规则,它只是说明了compareTo方法施加的等同性测试,在通常情况下应该返回与equals方法同样的结果。如果遵守了这一条,那么由compareTo方法所施加的顺序关系就被认为与equals一致。如果违反了这条规则,顺序关系就被认为与equals不一致。如果一个类的compareTo方法施加了一个与equals方法不一致的顺序关系,它仍然能够正常工作,但是如果一个有序集合(sorted collection)包含了该类的元素,这个集合就可能无法遵守相应集合接口(Collection、Set或Map)的通用约定。因为对于这些接口的通用约定是按照equals方法来定义的,但是有序集合使用了由compareTo方法而不是equals方法所施加的等同性测试。尽管出现这种情况不会造成灾难性的后果,但是应该有所了解。
例如,以BigDecimal类为例,它的compareTo方法与equals不一致。如果你创建了一个空的HashSet实例,并且添加new BigDecimal(“1.0”)和new BigDecimal(“1.00”),这个集合就将包含两个元素,因为新增到集合中的两个BigDecimal实例,通过equals方法来比较时是不相等的。然而,如果你使用TreeSet而不是HashSet来执行同样的过程,集合中将只包含一个元素,因为这两个BigDecimal实例在通过compareTo方法进行比较时是相等的。(详情请参阅BigDecimal的文档。)
编写compareTo方法与编写equals方法非常相似,但也存在几处重大的差别。因为Comparable接口是参数化的,而且comparable方法是静态的类型,因此不必进行类型检查,也不必对它的参数进行类型转换。如果参数的类型不合适,这个调用甚至无法编译。如果参数为null,这个调用应该抛出NullPointerException异常,并且一旦该方法试图访问它的成员时就应该抛出异常。
CompareTo方法中域的比较是顺序的比较,而不是等同性的比较。比较对象引用域可以通过递归地调用compareTo方法来实现。如果一个域并没有实现Comparable接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的Comparator来代替。或者编写自己的比较器,或者使用已有的比较器,例如针对第10条中的CaseInsensitive-
String类的这个compareTo方法使用一个已有的比较器:
image.png

注意CaseInsensitiveString类实现了Comparable接口。这意味着CaseInsensitiveString引用只能与另一个CaseInsensitiveString引用进行比较。在声明类去实现Comparable接口时,这是常用的模式。
本书的前两个版本建议compareTo方法可以利用关系操作符<和>去比

版权声明:如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:developerteam@list.alibaba-inc.com 进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接