Effective Java:Ch3_Methods:Item8_重写equals方法时遵循通用约定_JAVA_编程开发_程序员俱乐部

中国优秀的程序员网站程序员频道CXYCLUB技术地图
热搜:
更多>>
 
您所在的位置: 程序员俱乐部 > 编程开发 > JAVA > Effective Java:Ch3_Methods:Item8_重写equals方法时遵循通用约定

Effective Java:Ch3_Methods:Item8_重写equals方法时遵循通用约定

 2014/9/3 12:41:07  sunnyghost  程序员俱乐部  我要评论(0)
  • 摘要:Ch3_Methods:Item8_重写equals方法时遵循通用约定:虽然Object类是一个具体类,但它主要还是用于扩展。因为其所有nonfinal方法(equals、hashCode、toString、clone以及finalize)都是为重写设计的,所以这些方法都有显式的通用约定。任何重写这些方法的类都有责任遵循这些通用约定;若未做到这一点,则其他依赖于这些约定的类就不能与之一起正常运作。本章讲述何时如何覆盖Object中的nonfinal方法。由于Item7已逃离了finalize
  • 标签:方法 item Java
Ch3_Methods:Item8_重写equals方法时遵循通用约定:

虽然Object类是一个具体类,但它主要还是用于扩展。因为其所有nonfinal方法(equals、hashCode、toString、clone以及finalize)都是为重写设计的,所以这些方法都有显式的通用约定。任何重写这些方法的类都有责任遵循这些通用约定;若未做到这一点,则其他依赖于这些约定的类就不能与之一起正常运作
        本章讲述何时如何覆盖Object中的nonfinal方法。由于Item7已逃离了finalize,所以本章不再赘述。Comparable.compareTo虽然不是Object中的方法,但是由于它有类似的特性,所以也在本章讨论。

        重写equals方法看起来很简单,但是许多重写方式会导致错误,并且后果很严重。如果类的实例只与其自身equals,那么避免这种问题的最简单办法就是不去重写equals方法。只要满足以下任一条件,就可以这样做。
类的每个实例本质上都是唯一的。【例】例如Thread这样的表示活动实体,而非表示值的类。Object类提供的equals实现对于这些类来说恰好拥有正确的行为。
不关心类是否提供“逻辑相等”的测试功能。【例】例如java.util.Random本可以重写equals来检查两个Random实例是否生产了同样的随机数字序列,但是设计者不认为客户端会需要这样的功能。在这些情况下,从Object继承来的equals实现就已经足够了。
父类已经重写equals,并且父类的行为对于子类也是合适的。【例】例如大多数Set的实现类都从AbstractSet继承了equals方法,List的实现类从AbstractList继承了equals方法,Map的实现类从AbstractMap继承了equals方法。
类是私有的,或包级私有,并且确信其equals方法永远不会被调用。Arguably,其equals方法是应该重写成如下形式,以防止被意外调用:
[java] 
@Override  
public boolean equals(Object o) { 
    throw new AssertionError(); // Method is never called 


        那么何时应该重写Object.equals呢?当类拥有“逻辑相等”的概念,并且其父类并未重写equals以实现期望的行为,这时就需要重写Object.equals。对于值类(value classes)通常就是这种情况,所谓值类是指表示“值”的类,例如Integer或Date。程序员通过equals方法判断值类的实例是否逻辑相等,而不是判断他们是否指向相同的对象。重写equals方法不仅是为了满足程序员的要求,同时也使得这些实例可以作为map的key,或者是set的元素,并拥有可预见的、期望中的行为。
        有一种值类却不需要重写equals方法:实例受控的类(Item1),其每个值至多存在一个对象。【例】Enum类型就是这种值类。对于这些类,逻辑相等就等同于对象相等,所以Object.equals方法的功能就等同于逻辑equals方法。

当你重写equals方法时,必须遵守其通用约定。如下是从Object的规范中拷贝来的约定内容:
equals方法实现了等价关系(equivalence relation):
自反性(Reflexive):对于任何非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。
非空性(Non-Nullity):对于任何非null的引用x,x.equals(null)必须返回false。
1)自反性(Reflexive)
        第一个要求是说对象必须等于其本身。很难想象会无意中违反这一条约定。如果你违反该约定,把实例添加到集合后,集合的contains方法会告诉你该集合不包含你刚刚添加的实例。
2)对称性(Symmetric)
        第二个要求是说两个对象必须对于“他们是否相等”保持一致。与第一条不同,不难想象无意违反此条要求的情形。【例】例如下面这个类,实现了一个大小写敏感的字符串。字符串的大小写在toString中保存,但在比较时却忽略了。
[java] 
// Broken - violates symmetry! 
public final class CaseInsensitiveString { 
    private final String s; 
    public CaseInsensitiveString(String s) { 
        if (s == null) 
            throw new NullPointerException(); 
        this.s = s; 

// Broken - violates symmetry! 
@Override  
public boolean equals(Object o) { 
    if (o instanceof CaseInsensitiveString) 
        return s.equalsIgnoreCase(((CaseInsensitiveString) o).s); 
    if (o instanceof String)  // One-way interoperability! 
        return s.equalsIgnoreCase((String) o); 
    return false; 

        equals方法的意图很好,天真地试图与普通字符串进行互操作。假设有如下两个对象:
[java] 
CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); 
String s = "polish"; 
        cis.equals(s)返回true,但是s.equals(cis)却返回false,显然违反了对称性。假设你将其放入集合中:
[java] 
List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>(); 
list.add(cis); 
        list.contains(s)会返回什么呢?没人知道!在Sun的当前实现中,碰巧返回false,但是在其他实现中,也可能返回true或者抛出一个运行时异常。一旦你违反equals的约定,当其他对象面对你的对象时,你就无法知道这些对象的行为会是怎么样的。
【解决办法】       
        为了消除这个问题,只需在equals方法中去掉对String的不良互操作即可:——为避免违反对称性,equals()参数仅考虑当前类的情况
[java] 
@Override  
public boolean equals(Object o) { 
  return o instanceof CaseInsensitiveString && 
      ((CaseInsensitiveString) o).s.equalsIgnoreCase(s); 

3)传递性(Transitive)
        同样,不难想象无意违反此条要求的情形。【例】如果子类在父类基础上增加了一个新的值组件(value component),也就是说子类增加了信息会影响equals比较。我们以一个简单的不可变的二维整数point类作为开始。
[java]
class Point{ 
    private final int x; 
    private final int y; 
    public Point(int x, int y){ 
        this.x = x; 
        this.y = y; 
    } 
    @Override 
    public boolean equals(Object o){ 
        if(o == null || ! (o instanceof Point)){ 
            return false; 
        } 
        Point p = (Point) o; 
        return p.x == x && p.y == y; 
    } 

然后扩展这个类,加入颜色的概念:
[java] 
class ColorPoint extends Point{ 
    private final Color color; 
    public ColorPoint(int x, int y, Color c) { 
        super(x, y); 
        this.color = c; 
    } 
     

        子类的equals应该如何编写的?如果不编写,则从Point类继承,则equals比较时会忽略颜色信息。虽然这样并不违反equals约定,但是显然是无法接受的。
        【尝试1】假定仅当equals的参数是另一个ColorPoint,并且拥有相同的位置和颜色信息时才返回true。
[java] 
//    与父类违反对称性!! 
    @Override 
    public boolean equals(Object o){         
        if(!(o instanceof ColorPoint)){ 
            return false; 
        } 
        ColorPoint p = (ColorPoint)o; 
        return super.equals(p) && p.color == color; 
    } 

        Q:问题是违反了对称性!
[java] 
Point p = new Point(1, 2); 
ColorPoint cp = new ColorPoint(1, 2, Color.RED); 
        p.equals(cp)返回true,而cp.equals(c)则返回false。
        【尝试2】为了解决这个问题,你或许会尝试在进行ColorPoint.equals进行混合比较时忽略颜色信息:
[java] 
@Override 
public boolean equals(Object o){     
    if(! (o instanceof Point)){ 
        return false; 
    } 
    if(!(o instanceof ColorPoint2)){ //与Point实例比较时,不考虑Color 
        return super.equals(o); 
    } 
    ColorPoint2 p = (ColorPoint2)o; 
    return super.equals(p) && p.color.equals(color); 

       Q:这种方式确实提供了对称性,但是却牺牲了传递性。
[java]
ColorPoint p1 = new ColorPoint(1, 2, Color.RED); 
Point p2 = new Point(1, 2); 
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE); 
        p1.equals(p2)、p2.equals(p3)均返回true,而p1.equals(p3)却返回false!前两个比较忽略了颜色信息,第三个比较却又考虑颜色信息。

        【尝试3】  
        那么究竟该如何解决呢? 事实上这是OO语言中等价关系的一个基本问题。无法在扩展可实例化类的时候,增加一个值组件,同时又保证equals约定。
        你可能听说过,在equals方法中使用getCalss来代替instanceof,就可以在扩展可实例化类的时候增加值组价,并同时保证equals约定。例如:
[java]
//问题:违反里氏替换原则 
@Override 
public boolean equals(Object o){ 
    if(o==null || o.getClass()!= getClass()){//如果o是其子类,则永远范围false 
        return false; 
    } 
    Point p = (Point)o; 
    return p.x == x && p.y == y; 

        这样做的效果是,只有当两个对象是相同的实现类时,才会进行比较。看起来还不错,但是后果却是不可接受的。
        假设我们要编写一个方法,判断某个整数点是否在单位圆上:
[java] 
// Initialize UnitCircle to contain all Points on the unit circle 
private static final Set<Point> unitCircle; 
static { 
unitCircle = new HashSet<Point>(); 
unitCircle.add(new Point( 1,  0)); 
unitCircle.add(new Point( 0,  1)); 
unitCircle.add(new Point(-1,  0)); 
unitCircle.add(new Point( 0, -1)); 

public static boolean onUnitCircle(Point p) { 
    return unitCircle.contains(p); 


        现在假设你扩展了Point但不增加值组件,例如在构造方法中记录一共创建了多少个实例:
[java] 
public class CounterPoint extends Point { 
    private static final AtomicInteger counter = new AtomicInteger(); 
    public CounterPoint(int x, int y) { 
        super(x, y); 
        counter.incrementAndGet(); 
    } 
    public int numberCreated() {  
        return counter.get(); } 
    } 
        然后我们将CounterPoint实例传入onUnitCircle方法中,则不管CounterPoint中x值和y值是什么,永远都只会返回false。因为CounterPoint中使用的集合框架(例如HashSet),使用equals方法来判断是否包含;而CounterPoint和Point是永远不会等价的。——不满足里氏替换原则!
        但是,如果在Point的equals方法中使用instanceof,同样的onUnitCircle方法就会正常工作。

        【解决办法1】 
        虽然没有令人满意的方法可以既扩展一个可实例化类,又增加一个值组件,不过有一个规避方法。根据Item16,“组合优于继承”,我们可以在ColorPoint中增加一个private的Point域,而不是扩展Point;同时提供一个public的视图方法,返回与该color point位置相同的Point对象:
[java] 
// Adds a value component without violating the equals contract 
public class ColorPoint { 
    private final Point point; 
    private final Color color; 
    public ColorPoint(int x, int y, Color color) { 
        if (color == null) 
            throw new NullPointerException(); 
        point = new Point(x, y); 
        this.color = color; 
    } 
    /**
    * Returns the point-view of this color point.
    */ 
    public Point asPoint() { 
        return point; 
    } 
    @Override  
    public boolean equals(Object o) { 
        if (!(o instanceof ColorPoint)) 
            return false; 
        ColorPoint cp = (ColorPoint) o; 
        return cp.point.equals(point) && cp.color.equals(color); 
    } 
    ... // Remainder omitted 


        在Java平台库中,有一些类扩展了可实例化类,并增加了值组件。【例】例如java.sql.Timestamp扩展了java.util.Date类并增加了nanoseconds域。Timestamp的equals方法就违反了对称性,如果Timestamp和Date被用于同一个集合中,或以其他什么方式混在一起使用,则会引起错误的行为。
        Timestamp有一个免责声明,提醒程序员不要混用Date和Timestamp。虽然只要不混用他们就不会有麻烦,但是谁都不能阻止你混用他们,而结果导致的错误将会很难调试。Timestamp的这种行为是个错误,不值得效仿。
[java] 
     /**
     * Note: This method is not symmetric with respect to the 
     * <code>equals(Object)</code> method in the base class.
     */ 
   public boolean equals(java.lang.Object ts) { 
      if (ts instanceof Timestamp) { 
    return this.equals((Timestamp)ts); 
      } else { 
    return false; 
      } 
    } 

        注意,你可以在抽象类的子类中增加值组件,同时不违反equals约定。对于根据Item20(用类层次来替代标签类,Prefer class hierarchies to
tagged classes)而得到的类层次结构而言,这是非常重要的。【例】例如你可能有一个抽象类Shape,其中没有任何值组件;一个子类Circle,增加了一个radius域;一个子类Rectangle,增加了length和width域。上述问题就不会发生,因为不可能直接创建一个父类实例。
4)一致性(Consistent)
        第四个要求是说如果两个对象相等,则他们在任何时候都必须相等,除非其中一个(或两个)对象被修改了。换句话说,可变对象在不同的时候可以和不同的对象相等,而不可变对象则不行。当你编写一个类时,仔细考虑考虑它是否应该是不可变的(Item15)?如果结论是应该不可变,则需要保证equals方法满足:相等的对象永远都相等,不等的对象永远都不等。
        不论类是否可变,都不能让equals方法依赖于不可靠的资源。如果你违反了这个限制,那就很难满足一致性要求了。【例】例如java.net.URL的equals方法依赖于对URL中主机的IP地址的比较,而将主机名转译成IP地址需要访问网络,随着时间推移,并不保证能范围相同的结果。这就会导致URL的equals方法违反约定,并且已经在实践中引起问题了。不幸的是,由于兼容性需求,这一行为无法改变。除了少数例外情况,equals方法必须对驻留在内存中的对象进行确定性计算。
5)非空性(Non-Nullity)
        最后一条要求的意思是所有对象都与null不相等。虽然很难想象什么情况下o.equals(null)会返回true,但却不难想象意外抛出NullPointException的情况,通用约定不允许出现这种情况。许多类的euqals方法使用null判断来防止这种情况:
[java] 
@Override 
public boolean equals(Object o){ 
    if(o == null) 
        return false; 

        这种测试是不必要的。为了测试相等性,equals方法首先必须将参数强制转换为适合的类型,然后才能调用其访问器或域。在做强制转换之前,需要用instanceof方法来检查其参数是否是合适的类型:
[java] 
@Override  
public boolean equals(Object o) { 
    if (!(o instanceof MyType)) 
        return false; 
    MyType mt = (MyType) o; 

        如果没有类型检查,当equals的参数传入了一个错误的类型,则equals会抛出ClassCastException,违反约定。但是如果instanceof的第一个caozuofu.html" target="_blank">操作符是null,则一定返回false。也就是说,如果传入null,则类型检查就会返回false,不需要单独的null检查。

总结
        总而言之,实现高质量的equals方法有如下诀窍:
        1)使用==操作符来判断参数是否为该对象的一个引用。如果是,则返回true。这只不过是一种性能优化,当比较操作开销很大时,就值得这么做。
        2)使用instanceof操作符来判断参数是否是正确的类型。如果不是,则返回false。一般来说,正确的类型就是equals方法所在的类。偶尔情况下,是该类实现的某个接口。如果类使用的接口优化了equals约定,允许在实现该接口的不同类间进行比较,那就使用接口。集合接口Set、List、Map、Map.Entry都有这种特性。

[java] 
public interface Set<E>{ 
    /**
     * Compares the specified object with this set for equality.  Returns
     * <tt>true</tt> if the specified object is also a set, the two sets
     * have the same size, and every member of the specified set is
     * contained in this set (or equivalently, every member of this set is
     * contained in the specified set).  
       >>>>>>>>>
       This definition ensures that the
     * equals method works properly across different implementations of the
     * set interface.
     *
     * @param o object to be compared for equality with this set
     * @return <tt>true</tt> if the specified object is equal to this set
     */ 
    boolean equals(Object o); 
 
 
public abstract class AbstractSet<E>  implements Set<E> { 
    public boolean equals(Object o) { 
    if (o == this) 
        return true; 
 
    if (!(o instanceof Set)) 
        return false; 
    Collection c = (Collection) o; 
    if (c.size() != size()) 
        return false; 
        try { 
            return containsAll(c); 
        } catch (ClassCastException unused)   { 
            return false; 
        } catch (NullPointerException unused) { 
            return false; 
        } 
    } 
 


        3)将参数强制转换为正确的类型。因为之前进行了instanceof测试,这里的强制转换能确保成功。
        4)遍历类中的每个关键域,检查参数中的域与对象本身的域是否匹配。如果测试成功返回true,否则返回false。如果Step2中的类型是一个接口,则需要通过接口方法来访问参数中的域;如果是类,则也许能直接访问参数的域,这要取决于域的可见性。
        对于不是float和double的基本类型,用==操作符进行比较;对于对象引用,则递归调用equals;对于float域,使用Float.compare方法;对于double域,调用Double.compare方法。对float和double域的特殊处理是有必要的,因为存在Float.NaN、-0.0f,以及类似的double常量,可参考Float.equals方法。
[java] 
//Float.equals 
/*
注意,在大多数情况下,对于 Float 类的两个实例 f1 和 f2,让 f1.equals(f2) 的值为 true 的条件是当且仅当
    f1.floatValue() == f2.floatValue()
的值也为 true。但是也有下列两种例外:
1) 如果 f1 和 f2 都表示 Float.NaN,那么即使 Float.NaN==Float.NaN 的值为 false,equals 方法也将返回 true。
2) 如果 f1 表示 +0.0f,而 f2 表示 -0.0f,或相反的情况,则 equal 测试返回的值是 false,即使 0.0f==-0.0f 的值为 true 也是如此。
*/ 
   public boolean equals(Object obj) { 
    return (obj instanceof Float) 
           && (floatToIntBits(((Float)obj).value) == floatToIntBits(value)); 
    } 
        对于数组域,则对每个元素进行比较。如果数组中的每个元素都重要,可以使用JDK1.5的Arrays.equals方法。
[java]
//Arrays.equals 
 
    public static boolean equals(Object[] a, Object[] a2) { 
        if (a==a2) 
            return true; 
        if (a==null || a2==null) 
            return false; 
        int length = a.length; 
        if (a2.length != length) 
            return false; 
        for (int i=0; i<length; i++) { 
            Object o1 = a[i]; 
            Object o2 = a2[i]; 
            if (!(o1==null ? o2==null : o1.equals(o2))) 
                return false; 
        } 
        return true; 
    } 
        对象引用可能包含null,为避免可能发生的NullPointException,可使用如下习惯用法来进行比较:
[java] 
(field == null ? o.field == null : field.equals(o.field)) 
        如果field和o.field总是相同的引用,则下面方法会更快:
[java] 
(field == o.field || (field != null && field.equals(o.field))) 
       
        对于某些类,例如上面的CaseInsensitiveString,域的比较要比简单的相等性测试要复杂得多。如果是这种情况,你可能会希望保存该域的一个标准形式(canonical form),equals方法在这些标准形式上进行低开销的精确比较,而不是进行高开销的非精确比较。这种方式最适合于不可变类(Item15);如果对象改变,需要连带更新其标准形式。
        域比较的顺序可能会影响equals方法的性能。为了获得最佳性能,应该首先比较哪些最可能不一致的域、开销最小的域。不应该比较哪些并非对象逻辑状态的域,例如同步操作的Lock域。不应该比较冗余域,这些域可通过关键域计算而得,但是这样做可能提高equals方法的性能。如果冗余域代表了对整个对象的综述,那么对比这些域就能够节省比较开销。【例】例如,假设有个Polygon类,并缓存了面积域,如果两个Polygon的面积不等,那就不用再去比较他们的边和顶点了。
  
        5)当你完成equals方法后,问自己三个问题:它是否是对称的、传递的、一致的?并且不仅是自问,还要编写单元测试来检验这些特性。如果不通过,找出原因,据此来修改equals方法。当然,equals还需要满徐其他两个特性(自反性、非空性),不过这两种特性通常会自动满足。
        Item9中的PhoneNumber.equals就是根据上述诀窍编写的。下面是最后的一些说明:
当重写equals时必须重写hashCode(Item9)
不要让equals过度聪明。如果只简单地测试域是否相等,则不难满足equals约定;而当过度地寻求等价关系,则容易陷入麻烦之中。例如File类不应该将指向同一文件的符号链接当做相等对象来看待。所幸File类没有这样做。
在equals方法声明中不要将Object替换为其他类型。为了防止这种情况,应该在每次重写equals时都是用@Override。
发表评论
用户名: 匿名