Effective Java读书笔记-覆盖equals时遵守的通用约定

时间:2022-12-12 16:03:04

不需覆盖equals的情况

覆盖equals看似很简单,但是很多覆盖方式会导致错误,并且后果会非常严重,最简单的方法就是不去覆盖equals方法。
以下几种情况,可以不用覆盖equals方法:

  • 类的每个实例本质上是一致的。
  • 不关心是否提供了“逻辑相等”的测试功能。
  • 超类继承了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,x.equals(null)必须返回false。

注意:有很多类包括集合类在内,都依赖于传递给他们的对象是否遵守了equals约定。

违反equals约定的后果

  • 自反性:如果违背了自反性这一条性质,然后将该类的实例加入到集合之中,该集合的contains方法将会果断的告诉你,该集合不包含你刚刚添加的实例。
  • 对称性
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

cis.equals(s)返回的结果为true,问题在于CaseInsensitiveString中的equals方法知道String对象,但是,String类的equals方法不知道不区分大小写的字符串。因此,s.equals(cis)返回false,显然违反了对称性。假设把不区分大小写的字符串对象放在一个集合当中:

List<CaseInsensitiveString>list = new ArrayList<CaseInsensitiveString>();
list.add(cis);

此时的list.contains(s)可能会返回false,也可能返回true甚至是runtime异常。一旦违反了equals规定,当其它对象面对你的对象时,你完全不知道这些对象的行为会怎么样。

为了解决这个问题只需要把企图和String互操作的这段代码从equals方法当中去掉。这样做之后,就可以重构该方法,使它变成单独的一条返回语句返回。

public boolean equals(Object o){
    return o instanceof CaseInsensitiveString && ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
}
  • 传递性
    简单的例子:
public 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 instanceof Point)){
             Point p = (Point)o;
             return p.x == x && p.y == y;
        }
    }
}

假如你想拓展这个类,为一个点添加颜色信息。

public class ColorPoint extends Point{
     private final Color color;
     public ColorPoint(int x,int y,Color color){
         super(x,y);
         this.color = color;
     }
}

如果不提供equals方法则直接从父类中直接继承equals方法。这样在进行equals比较的时候就会忽略color的比较。这是无法接受的。假设你写了一个方法不光比较了父类的参数还比较了有色点。例如:

     @Override 
    public boolean equals(Object o){
        if(!(o instanceof ColorPoint)){
            return false;
        }
        return super.equals(o) && ((ColorPoint)o).color == color;
    }

此种方法的问题在于创建了一个“普通点”和“有色点”并互相进行比较。当普通点对有色点使用equals方法时忽略了颜色信息的比较。当有色点对普通点使用equals方法时,总是返回false。

可以采用以下方法来解决此问题:

    @Override 
    public boolean equals(Object o){
        if(!(o instanceof Point)){
            return false;
        }

        if(!(o instanceof ColorPoint)){
            return o.equals(this);
        }

        return super.equals(o) && ((ColorPoint)o).color == color;
    }

但是,此种方法虽然提供了对称性却破坏了传递性。比如:

    //创建如下几个类
    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,很明显违反了传递性。
如何解决这个问题呢?其实这是面向对象的语言中对等价关系的一个基本问题。我们无法在拓展可实例化的类的同时,既增加新的值得组件,又同时保留equals约定。除非愿意放弃面向对象的抽象所带来的优势。

有一种解决传递性问题的权益之计。复合优于继承。我们不再让ColorPoint拓展Point,而是在ColorPoint中添加一个私有域Point,以及一个公共视图方法。

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;
    }

    @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);
    }
}

注意:有一些类拓展了可实例化的类,并添加了新的值得组件。例如java.sql.Timestamp对java.util.Date进行了拓展,并增加了nanoseconds域。Timestamp的equals方法实现了对称性,如果Timestamp和Date对象用在同一个集合当中,或者以其它方法被混合在一起,则会引起不正确的行为。Timestamp类有一个免责声明,告诫程序员不要混合使用Date与Timestamp类。一旦使用则会造成无法想象的后果。

可以在一个抽象类的子类中添加新的值的组件,而不违反equals规定。例如,你有一个抽象的Shape类,它没有任何的值组件,Circle子类中添加一个radius值域,Rectangle子类添加了length和width值域。只要不可能直接创建超类的实例,之前所述的问题将不会发生。

  • 一致性
    当你在写一个类的时候就应该考虑它是否应该是不可变的。如果认为它是不可变的,就必须保证equals方法满足一致性的限制条件:相等的对象永远相等,不相等的对象永远不等

验证参数的等同性,equals方法必须先把参数转换成适当的类型,以便可以调用它的访问方法(accessor),或者访问它的域。在进行转换之前,equals方法必须使用instanceof方法,检验参数是否为正确的类型。

    @Override 
    public boolean equals(Object o){
        if(!(o instanceof Mytype))
            return false;
        Mytype mt = (Mytype)o;
    }

如果漏掉了这一步的类型检查,并且传递给equals方法的类型又是错误的,equals方法将会跑出ClassCastException异常,这就违反了equals方法的规定。但是,如果instanceof方法的结果为false,不论第二个操作数是何种类型,都将返回false.如果把null传给equals方法则将返回false。

实现高质量equals方法的诀窍:

  1. 使用==操作符用来检查参数是否为这个对象引用。
  2. 使用instanceof操作符用来检查参数是否为正确的类型。所谓正确的类型是指equals方法所在的那个类。有些情况是指该类所实现的某个接口。
  3. 把参数转化为正确的类型。
  4. 对于该类中的每个“关键域”,检查参数中的域是否与该对象中对应的域相匹配。如果是接口,则通过接口方法检查其中的域。如果该类型是类,可以直接访问参数中的域。

注意:对于Float与Double进行特殊处理是有必要的,因为存在着Float.NaN,-0.0f以及类似的double常量。对于数组域则需要把以上这些指导元素应用到每个元素上。如果数组中的每个元素都非常重要,就可以使用Arrays.equals方法(Java1.5版本中新发行的方法)

域的比较顺序可能会影响到equals方法的性能。为了获得最佳性能,应该最先比较最有可能不一致的域,或者开销最小的域,最理想的情况是两个条件同时满足的域。不应该去比较那些不属于对象逻辑状态的域。也不需要比较冗余域。

  1. 当编写完equals方法后应当问自己三个问题:它是否是对称的、传递的、一致的?

注意:不要将equals声明中的Object对象替换为其他的类型。