第十条:覆盖equals方法请遵守通用规范【对于所有对象都通用的方法start】

尽管Object是一个具体类,但是设计它主要是为了扩展,它所有的非final方法(equals、hashCode、toString、clone、finalize)都有明确的通用约定,因为它们设计成是可覆盖的(override),任何一个类,它在覆盖这些方法的时候,都有责任遵守这些通用约定,如果不能做到这点,其他依赖于这些约定的类(例如:HashMap和HashSet)就无法结合该类一起正常运作。

覆盖equals方法看起来似乎很简单,但是有许多覆盖方式会导致错误,并且后果非常严重,最容易避免这类问题的办法就是不覆盖equals方法,在这种情况下,类的每个实例都只与它自身相等,如果满足以下任何一个条件,这就正是所期望的结果(就不用覆盖equals方法):

1.类的每个实例本质上是唯一的:对于代表活动实体而不是值的类确实如此,例如Thread,Object提供的equals实现对于这些类来说正是正确的行为。
2.类没有必要提供“逻辑相等“的测试功能。例如:java.util.regex.Pattern可以覆盖equals方法,已检查两个Pattern实例是否代表同一个正则表达式,但是设计者并不认为客户端需要或者期望这样的功能。在这类情况下,从Object继承的得到的equals实现就足够了。
3.超类已经覆盖了equals,超类的行为对于这个类也是合适的。例如,大多数的Set实现都从AbstractSet继承equals实现,Map实现从AbsrtractMap继承equals实现。
4.类是私有的,或者是包级私有的,可以确保它的equals方法永远不会被调用。如果非常想要避免风险,可以覆盖equals方法,并加以控制,以确保不会被意外调用

@Override
public boolean equals(Object o){
    throw new AssertionError();
}

那么,什么时候应该覆盖equals方法呢?如果类具有自己特有的"逻辑相等"概念(不同于对象等同的概念),而且超类还没有覆盖equals方法。这通常属于值类的情形。值类仅仅是一个表示值的类,例如:Integer或者String,程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是像了解它们是否指向同一个对象,不仅必须覆盖equals方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key)或者集合(set)的元素,使映射或者集合表现出逾期的行为。

有一种"值类"不需要覆盖equals方法,即用实例受控确保"每个值最多只存在一个对象"的类,枚举类型就属于这种类,对于这样的类而言,逻辑相同与对象相同是一回事。

在覆盖equals方法的时候,必须遵守通用约定,下面是约定的内容,来自Object的规范:
自反性:对于任何非null的引用值x,x.equals(x),必须返回true。
对称性:对于任何非null的引用值x、y,当且仅当x.equals(y)返回true时,y.equals(x)也必须返回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方法,每个等价类中的所有元素都必须是可交换的。现在我们按照顺序逐一查看以下5个要求:

自反性:第一个要求仅仅说明对象必须等于其自身,假如违背了这一条,然后把该类的实例添加到集合中,该集合的contains方法将果断的告诉你,该集合不包含你刚刚添加的元素。

对称性:第二个要求是说:任何两个对象对于"它们是否相等"的问题都必须保持一致,与第一个要求不同,若无意中违反了这一条,这种情形倒不难想象,例如下面的类,它实现了一个不区分大小写的字符串的equals方法。

public class CaseInsensitiveString {

    private String str;

    public CaseInsensitiveString(String str) {
        this.str = str;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof CaseInsensitiveString) {
            CaseInsensitiveString caseString = (CaseInsensitiveString) obj;
            return this.str.equalsIgnoreCase(caseString.str);
        }
        if (obj instanceof String) {
            return this.str.equalsIgnoreCase((String) obj);
        }
        return false;
    }
}

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

CaseInsensitiveString cis = new CaseInsensitiveString("Java");
String s = "java";

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

List<CaseInsensitiveString> list = new ArrayList<>();
list.contains(s); // false

此时list.contains(s)会返回false,一旦违反了equals约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎么样。

为了解决这个问题,只需把企图与String互操作的这段代码从equals中删除即可。

@Override
public boolean equals(Object obj) {
        return obj instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) obj).str.equalsIgnoreCase(str);
}

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

public class Point {
    int x;
    int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Point) {
            Point point = (Point) obj;
            return x == point.x && y == point.y;
        }
        return false;
    }
}

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

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方法,而是直接从Point继承过来,在equals作比较的时候,颜色信息就会被忽略。虽然这样做不会违反equals约定,但是很明显这是无法接收的。假设编写一个equals方法,只有当它的参数是另一个有色点并且具有相同的位置和颜色时,才返回true。

public class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

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

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

Point p1 = new Point(1, 2);
ColorPoint p2 = new ColorPoint(1, 2, Color.BLACK);
System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p1)); // false

然而,p1.equals(p2)返回true,p2.equals(p1)返回false,也可以尝试修正这个问题,让ColorPoint.equals在进行"混合比较"式忽略颜色。

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

这种方式虽然提供了对称性,但是却牺牲了传递性。例如:

ColorPoint p1 = new ColorPoint(1, 2, Color.BLACK);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.RED);

System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p3)); // true
System.out.println(p3.equals(p1)); // false

以上例子很明显这违反了传递性,前两种不需要比较颜色信息,而第三种比较则需要考虑颜色信息。
此外,这种方法还可能导致无线递归的问题,假设Point有两个子类,如ColorPoint和SmellPoint,它们各自都带有这种equals方法,那么ColorPoint.equals(SmellPoint)的调用就会出现递归,将会抛出StackOverflowError异常:举个例子:

public class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Point)) {
            return false;
        }
        if (!(obj instanceof ColorPoint)) {
            return obj.equals(this);
        }
        ColorPoint colorPoint = (ColorPoint) obj;
        return super.equals(obj) && color == colorPoint.color;
    }
}
public class SmellPoint extends Point {
    private final Color color;

    public SmellPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Point)) {
            return false;
        }
        if (!(obj instanceof SmellPoint)) {
            return obj.equals(this);
        }
        return super.equals(obj) && ((SmellPoint) obj).color == this.color;
    }
}
public class Main{
    public static void main(String[] args) {
        ColorPoint p1 = new ColorPoint(1, 2, Color.BLACK);
        SmellPoint p2 = new SmellPoint(1, 2, Color.YELLOW);
        System.out.println(p1.equals(p2)); 
    }
}
console:
Exception in thread "main" java.lang.StackOverflowError
...

那么怎么解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题,我们*无法*在扩展可实例化的类的同时,即增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。

你可能听说过,在equals方法中用getClass测试代替instanceof测试,可以扩展可实例化的类和增加新的值组件,同时保留约定。

@Override
public boolean equals(Object obj) {
        if (obj == null || obj.getClass() != getClass()) {
            return false;
        }
        Point point = (Point) obj;
        return point.x == x && point.y == y;
}

这段程序只有当对象具有相同的实现类时,才能是对象相同,但是Point子类的实例任然是一个Point,他仍然需要发挥作用,但是如果采取这种方式,它就无法完成任务!

假设我们要编写这样一个方法,以检验某个点是否存处在单位园中,下面是可以采用的一种方式:

public class CounterPoint extends Point {

    private static final AtomicInteger ATOMIC_INTEGER = new AtomicInteger();

    public CounterPoint(int x, int y) {
        super(x, y);
        ATOMIC_INTEGER.incrementAndGet();
    }

    public static int numberCreated() {
        return ATOMIC_INTEGER.get();
    }
}
public class Main {

    private static final Set<Point> unitCircle = new HashSet<>(Arrays.asList(new Point(1, 2)
            , new Point(1, 0),
            new Point(-1, 2)));

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

里氏替换原则认为,一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上也应该同样运行的很好。针对上述Point的子类(如CountPoint)仍是Point,并且必须发挥作用的例子,这就是它的正式语句。但是假设我们将CountPoint实例传给了onUnitCircle方法,如果Point类使用了基于getClass的equals方法,无论CountPoint实例的x和y值是什么,onUnitCircle都会返回false。这是因为像onUnitCircle方法所用的HashSet这样的集合,利用equals方法检验包含条件,没有任何CountPoint实例与任何Point对应。

CounterPoint counterPoint = new CounterPoint(1, 2);
CounterPoint counterPoint1 = new CounterPoint(1, 0);
CounterPoint counterPoint2 = new CounterPoint(-1, 2);
System.out.println(onUnitCircle(counterPoint)); // false
System.out.println(onUnitCircle(counterPoint1)); // false
System.out.println(onUnitCircle(counterPoint2)); // false

但是,如果在Point上使用基于instanceof的equals方法,当遇到CountPoint时,相同的onUnitCircle方法就会工作得很好。

虽然没有一种令人满意的方式可以即扩展可实例得类,又增加值组件,但是还有一种不错得权宜之计:遵从第18条"复用优先于继承"得建议。我们不再让ColorPoint扩展Point,而是在ColorPoint中加入一个私有Point域,以及一个公有的视图方法,此方法返回一个与该有色点处在相同位置的普通Point对象:

public class ColorPoint {
    private final Point point;
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        this.point = new Point(x, y);
        this.color = color;
    }

    public Point asPoint() {
        return point;
    }

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

注意,你可以在一个抽象类的子类中增加值组件且不违反equals约定,根据第23条的建议而得到的那种类层次结构来说,这一点非常重要。例如,你可能有一个抽象类Shape,它没有任何值组件,Circle子类添加了一个radius域,Rectangle子类添加了length和width域。只要不可能直接创建超类的实例,前面所述的种种问题就都不会发生。就保证了传递性。

一致性:equals约定的第四个要求是,如果两个对象相等,它们就必须始终相等,除非它们中有一个对象(或者两个都)被修改。可变对象在不同的时候可以与不同的对象相等,而不可变对象则不会这样。当在编写一个类的时候,应该仔细考虑它是否应该是不可变的,如果认为它应该是不可变的,就必须保证equals方法满足这样的限制,相等的对象永远相等,不相等的对象永远不相等。

无论类是否不可变,都不要使equals方法依赖于不可靠的资源。如果违反了这条
禁令,想要满足一致性的要求就非常困难。

非空性:最后一个要求没有正式的名字,我们姑且称它为“非空性“,意思是指所有的对象都不能等于null,尽管很难想象在什么情况下o.equals(null)调用会意外的返回true,但是意外的抛出NullPointerException异常的情形却不难想象。通用约定允许抛出NullPointerException异常。许多类的equals方法都通过一个显式的null测试来防止这种情况;

@Override
public boolean equals(Object o){
    if(o == null){
        return false;  
    } 
    ....
}

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

@Override
public boolean equals(Object o){
    if(!(o instanceof MyType)){
        return false;  
    }
    MyType type = (MyType) o;
    ....
}

如果漏掉了这一步类型检查,并且传递给equals方法参数又是错误的类型,那么equals方法将会抛出ClassCastException异常,这就违反了equals约定。但是,如果
instanceof的第一个操作数为null,那么,不管第二个是哪种类型,instanceof操作符都指定应该返回false,因此,如果把null传给equals方法,类型检查就会返回false,所以不需要显式的null检查。

结合所有要求,得出了以下实现高质量equals方法的诀窍:
1.使用==操作符检查”参数是否为这个对象的引用“。如果是,则返回true,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
2.使用instanceof操作符检查”参数是否为正确的类型“,如果不是,则返回false。一般来说,所谓“正确的类型”是指equals方法所在的那个类。某些情况下,是指该类所实现的某个接口。如果类实现的接口进行了equals约定,允许在实现了该接口的类之间进行比较,那就使用接口,集合接口如Set,List,Map和Map.Entry具有这样的特性。
3.把参数转换正确的类型,因为转换之前进行过instanceof测试,所以确保会成功。
4.对于该类的每个关键域,检查参数中的域是否与该对象中的对应的域相匹配。如果这些测试全部成功,则返回true,否则返回false,如果第2步中的类型是个接口,就必须接口方法访问参数中的域,如果该类型是一个类,也许就能够直接访问参数中的域,这要取决与它们的可访问性。

对于既不是float也不是double类型的基本类型域,可以直接使用==操作符进行比较,对于对象域,可以递归调用equals方法,对于float域,可以使用静态Float.compare(float f1,float f2)方法;对于double域,则使用Double.compare(double d1,double d2)。对float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量;虽然可以用静态方法Float.equals和Double.equals对float和double域进行比较,但是每次比较都要进行自动装箱,这回导致性能下降,对于数组域,则要把以上这些原则应用到每一个元素,如果数组域中的每个元素都很重要,就可以使用其中一个Arrays.equals方法。

有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointerException异常,则使用静态方法Object.equals(Object o1,Object o2)来检查这类域的等同性。

域的比较顺序可能会影响equals方法的性能,为了获得最佳的性能,应该最先比较最有可能不一致的域,或者开销低的域,最理想的情况是两个条件同时满足的域,不应该比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域,也不需要比较衍生域,因为这些域可以由关键域计算获得,但是这样做有可能提高equals方法的性能,如果衍生域代表了整个对象的综合描述,比较这个域可以节省在比较失败时去比较实际数据所需要的开销,例如:假设有一个Polygon类,并缓存了该面积,如果两个多边形有着不同的面积,就没有必要再去比较它们的边和顶点。

在编写完equals方法之后,应该问自己三个问题:它是否对称性?它是否传递性?它是否一致性?
并且不要只是自问,还要编写单元测试来检验这些特性,除非用AutoValue(Goole开源的框架)生成equals方法,在这种情况下就可以放心的省略测试。如果答案是否定,就要找出原因,再相应的修改equals方法的代码逻辑。equals方法也必须满足其他两个特征(自反性和非空性),当然,这两种特性通常会自动满足。

根据上面的诀窍构建equals方法的具体例子:

public final class Person {
   private final String name;
   private final int age;

   public Person(String name, int age) {
       this.name = name;
       this.age = age;
   }

   @Override
   public boolean equals(Object o) {
       if (this == o) {
           return true;
       }
       if (!(o instanceof Person)) {
           return false;
       }
       Person person = (Person) o;
       return age == person.age && Objects.equals(name, person.name);
   }
}

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

public boolean equals(MyClass o){
....
}

这个方法并没有覆盖Object.equals方法,因为参数应该是Object类型,相反,它重载了Object.equals方法,在正常的equals方法的基础上,再提供一个强类型的equals方法,这样会导致子类中的Override注解产生错误。

@Override注解的用法一致,就如本条目中所示,可以防止犯这种错误,这和equals方法不能编译,错误信息会告诉你到底哪里出了问题。

总而言之,不要轻易的覆盖equals方法,除非特殊需求,在许多情况下,从Object处继承就够了。如果要覆盖equals方法,一定要比较这个类的所有关键域,并且检查覆盖的equals方法是否遵守equals合约的所有五个条款。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345

推荐阅读更多精彩内容