《Effective Java》学习笔记(二)——对于所有对象都通用的方法

《Effective Java》学习笔记

覆盖equals时请遵守通用约定

不覆盖equals方法,类的每个实例都只与它自身相等。如果满足了以下任何一个条件,就正是所期望的结果:

  • 类的每个实例本质上都是唯一的。
  • 不关心类是否提供了“逻辑相等”的测试功能。
  • 超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的。
  • 类是私有的或是包级私有的,可以确定它的equals方法永远不会被调用。

需要覆盖equals的情况:如果类具有自己特有的“逻辑相等”概念(不同于对象等同的概念),而且超类还没有覆盖equals以实现期望的行为。

覆盖equals方法的时候需要遵守的通用约定(等价关系):

  • 自反性:对于任何非null的引用值x,x.equals(x)必须返回true;
  • 对称性:对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true;
  • 传递性:对于任何非null的引用值x、y、z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true;
  • 一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false;
  • 非空性:对于任何非null的引用值,x.equals(null)必须返回false。

里氏替换原则:一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上也应该同样运行得很好。

结合这些要求,实现高质量equals方法的诀窍:

  1. 使用==操作符检查“参数是否为这个对象的引用”。如果是则返回true。这是一种性能优化,如果比较操作有可能更昂贵,就值得这么做。
  2. 使用instanceof操作符检查“参数是否为正确的类型”。如果不是,则返回false。“正确的类型”是指equals方法所在的那个类,有些情况下,是指该类所实现的某个接口。
  3. 把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。
  4. 对于该类中的每个“关键域”,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true,否则返回false;
    • 对于既不是float也不是double类型的基本类型域,使用==操作符进行比较;
    • 对于对象引用域,可以递归地调用equals方法;
    • 对于float域,可以使用Float.compare方法;
    • 对于double域,则使用Double.compare方法;对float和double域进行特殊的处理是必要的,因为存在着Float.NaN、-0.0f以及类似的double常量;
    • 对于数组域,则要把以上这些知道原则应用到每个元素上。
  5. 编写完成了equals方法之后,要问三个问题:它是否是对称的、传递的、一致的?还要编写单元测试来检验这些特性。

还有一些告诫:

  • 覆盖equals时总要覆盖hashCode;
  • 不要企图让equals方法过于智能;
  • 不要讲equals声明中的Object对象替换为其他的类型。

覆盖equals时总要覆盖hashCode

在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。

Object规范:

  • 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。
  • 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。
  • 如果两个对象根据equals(Obejct)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是给不相等的对象产生截然不同的整数结果,有可能提高散列表的性能。

如果hashCode方法为不相等的对象产生了很多相等的散列码,那么散列码相等的这些对象都被映射到同一个散列桶中,会是散列表退化成链表,极大的影响了散列表的性能。

一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”。

始终要覆盖toString

在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息。无论是否制定输出的格式,都应该在文档中明确地表明你的意图。无论是否指定格式,都为toString返回值中包含的所有信息,提供一种编程式的访问途径。

谨慎地覆盖clone

Cloneable接口的目的是作为对象的一个mixin接口,表明这样的对象允许克隆。但是它缺少一个clone方法,Object的clone方法是受保护的。Cloneable接口决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。

如果实现Cloneable接口是要对某个类起到作用,类和它的所有超类都必须遵守一个相当复杂、不可实施的,并且基本上没有文档说明的协议,由此得到一种语言之外的机制:无需调用构造器就可以创建对象。

拷贝对象往往会导致创建它的类的一个新实例,但它同时也会要求拷贝内部的数据结构,这个过程没有调用构造器。

如果你覆盖了非final类中的clone方法,则应该返回一个通过调用super.clone而得到的对象。如果类的所有超类都遵守这条规则,那么调用super.clone最终会调用Object的clone方法,从而创建出正确类的实例。

实际上,clone方法就是另一个构造器;你必须确保它不会伤害到原始的对象,并确保正确地创建被克隆对象中的约束条件。

如果专门为了继承而去设计一个clone方法,那就应该模拟Object.clone的行为:它应该被声明为protected,抛出CloneNotSupportedException,并且该类不应该实现Cloneable接口。这样可以使子类具有实现或者不实现Cloneable接口的自由。还有,如果决定用线程安全的类实现Cloneable接口,那么要记得它的clone方法必须实现很好的同步。

Cloneable具有上述这么多的问题,可以肯定的说,其他的接口都不应该扩展这个接口,为了继承而设计的类也不应该实现这个接口,对于一个为了继承而设计的类,如果你未能提供行为良好的受保护的clone方法,它的子类就不可能实现Cloneable接口。

考虑实现Comparable接口

compareTo方法是Comparable接口中唯一的方法,compareTo方法不但允许进行简单的等同性比较,而且允许执行顺序比较。类实现了Comparable接口,就表明它的实例具有内在的排序关系。

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

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

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

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