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

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

术语:

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


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

       1、类的每个实例本质上都是唯一的。对于代表活动实体而不是值(value)的类来说确实如此,例如Thread。Object提供的equals实现对于这些类来说是正确的行为。

       2、不关心类是否提供了“逻辑相等(logical equality)”的测试功能。如java.util.Random覆盖了equals,以检查两个Random实例是否产生相同的随机序列,但是设计者并不认为客户需要或者期望这样的功能。在这样的情况下,从Object继承得到的equals实现已经足够了。

       3、超类已经覆盖了equals,从超类继承过来的行为对于子类来说也是合适的。例如大多数Set实现都从AbstractSet继承equals实现,类似的有List和Map等。

       4、类是私有的或是包级私有的,可以确定它的equals方法永远不会被调用。在这种条件下,无疑应该覆盖equals方法,以防止它被意外调用。

@Override
public boolean equals(Object c) {
throw new AssertionError(); // Method is never called
}
        在这第四种情况下,只是对equals的一种废弃,并没有新加什么功能。

        以上说的都是什么时候不用覆盖equals方法,那么,什么时候应该覆盖Object.equals呢?如果类具有自己特有的“逻辑相等”的概念(不同于对象等同的概念),而且超类还没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals方法。这通常是属于“值类(value class)”的情形,值类仅仅是一个表示值的类,例如Integer或者Date。程序员在利用equals方法来比较值对象的引用时,希望知道它们是逻辑上是否相等,而不是想了解它们是否指向同一个对象。为了满足程序员的要求,不仅必须覆盖equals方法,而且这样做也使得这个类的实例可以被用做映射表的键或者是集合的元素,使映射或者集合表现出预期的行为。但是有一种"值类"是不需要覆盖equals方法的,即用实例受控确保“每个值至多只存在一个对象的类”,枚举就属于这种类,对于这样的类而言,逻辑相同与对象相同同一回事,为什么呢?因为不论实例多少次,总是返回固定的实例,而这些实例总是唯一的,所以逻辑相等和对象相同是一回事。

        在覆盖equals方法时,必要要遵守Object中关于equals的通用约定,它们是equals实现的等价关系(equivalence relation)。

        1、自反性(reflexive)。对于任何非null的引用值x,x.equals(x)必须返回true。

        这条要求仅仅说明对象必须等于自身,很难想出违反这一条的例子,但是如果违反了这一条当把该类的实例添加到集合中,该集合的contains方法将不会认为它你刚刚存在集合的实例在集合中。

        2、对称性(symmetric)。对于任何非null引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。

        这条要求是说任何两个对象对于“它们是否相等”的问题都必须保持一致,如果违反了这条要求可能会有以下场景。

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

        这段代码存在什么问题呢?看起来equals检查了当前类类型的相等情形和String类的相等情形,但是问题在于,这该类知道使用忽略大小写的方法与String类实例进行比较,但是String类却不知道,比如有一个不区分大小写的字条串和一个普通的字符串:

CaseInsensitiveString cis = new CaseInsensitiveString("TEST");
String s = "test"
        那么,cis.equals(s)返回为true,而s.equals(cis)则返回假。这显然违反了对称性原则,假设把不区分大小写的字符串对象放到一个集合中。
List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>();
list.add(cis);
       当执行list.contains(s)的时候会产生不确定的结果。一旦违反了equals约定,当其他对象面对你的对象时,你完成不知道这些对象的行为会怎么样。这了解决这问题,只要把企图与String互操作的这段代码从equals有一方看去掉就可以了。这样做之后,就可以重构该方法,让它变成一条单独的返回语句。

@Override
public boolean equals(Object o) {
return (o instanceof CaseInsensitiveString) &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
        注意,在此时,我们进行的是针对同一个类的不同实例的比较,而不再是不同类的实例之间的比较。

        3、传递性(transitive)。对于任何非null引用值x、y、z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。

        对于这条要求,考虑子类的情形,它将一个新的值组件(value component,就是一个类的field)添加到超类中,换句话说,子类增加的信息会影响到equals的比较结果。首先以一个简单的不可变的二维整数型Point类开始:

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))
return false;
Point p = (Point)o;
return p.x == x && p.y == y;
}
// Remainder omitted
}
        现在假设你想扩充Point类,为它添加一个颜色信息:

public class ColorPoint extends Point {
private final Color color;

public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// Remainder omitted
}
        考虑一下,如果在ColorPoint子类中不重写equals方法的话,那么在做比较的时候颜色信息就被忽略掉了,好,现在我们重写一个equals方法,让它只有在参数是另一个相同类的实例并且有着相同的x,y和color的时候才返回真。现在ColorPoint可能像下面这样:

public class ColorPoint extends Point {
private final Color color;

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

// Broken - violates symmetry!
@Override
public boolean equals(Object c) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
// Remainder omitted
}
        这个方法看起来好像刚好解决了我们刚才的问题,但是事实上它违反了对称性的原则,想一想Point类的实例p和ColorPoint的实例cp,在两种具有相同的x,y时,p.equals(cp)总是真,而cp.equals(p)为假。为了修正这个问题,可能会采用以下这种”混合比较“的模式:

public class ColorPoint extends Point {
private final Color color;

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

// Broken - violates symmetry!
@Override
public boolean equals(Object c) {
if (!(o instanceof Point))
return false;

// If o is a normal Point,do a color-blind comparison
if (!(o instanceof ColorPoint))
return o.equals(this);

// o is a ColorPoint; do a full comparison
return super.equals(o) && ((ColorPoint) o).color == color;
}
// Remainder omitted
}
        再一次的,刚刚好,它解决了刚才的问题,可是,考虑如下的测试

ColorPoint p1 = new ColorPoint(0, 0, Color.RED);
Point p2 = new Point(0, 0);
ColorPoint p3 = new ColorPoint(0, 0, Color.BLUE);
        用以上的类定义有p1.equals(p2)和p2.equals(p3)都为true,但是p1.equals(p3)却返回的是false,事实上这是面向对象语言中关于等价关系的一个基本问题, 我们无法在扩展可实例化类的同时,既增加新值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。

        书上提出了另一个”解决方案“,在equals方法中用getClass测试代替instanceof测试,可以扩展可实例化的类和增加新的值组件,同时又保留住equals约定:

// Broken - violates Liskov substitution principle
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
        这段程序的问题在于,只有当对象具有相同的实现时,才能使对象等同。他们必须属于同一类的实例。假设要编写一个方法,以检验某个整数值点是不是处在单位圆中,下面是可以采用的其中一种办法:

// 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,例如让它的构造器记录创建了多少个实例:

public class CounterPoint extends Point {
private static final AtomicInteger COUNTER_ATOMIC_INTEGER =
new AtomicInteger();

public CounterPoint(int x, int y) {
super(x, y);
COUNTER_ATOMIC_INTEGER.incrementAndGet();
}
public int numberCreated() { return COUNTER_ATOMIC_INTEGER.get(); }
}
        显然,用上面的getClass()测试的equals版本对于unitCircle中查找 CounterPoint实例不论x,y是什么值都将失败,因为getClass()限制了equals会返回false,而此时如果选用基于instanceof的测试方法,当遇到CounterPoint时,相同的onUnitCircle方法就会工作的很好。

        为了解决以上问题,可以采用复合来替代继承。如下例:

// 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 ommited
}
        注意,可以在一个抽象类的子类中增加新值组件,而不会违反equals约定,事实上,只要不能创建超类的实例,前面的问题就不会发生,因为并不存在一个层次的关系。

        4、一致性(consistent)。对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致的返回true或false。

        这个要求是如果两个对象相等,它们就必须始终保持相等,除非它们中至少有一个对象被修改了。或者说,可变对象在不同的时候可以与不同的对象相等,而不可变对象则不会这样。如果在编写一个类的时候确定它应该是不可变的,那就必须保证equals方法满足这样的限制,相等的对象永远相等,不相等的对象永远不相等。

        无论类是否相等,都不要使equals方法依赖于不可靠的资源。例如,java.net.URL的equals方法依赖于对URL中主机IP地址的比较,将一个主机名转换成IP地址可能需要访问网络,随着时间的推移,不确保会产生相同的结果。这样就会导致URL的equals方法违反此条约定,从而引发一系列问题(但是出于兼容性的原因,这一行为无法被改变)。

        以下是java docs中对java.net.URL的equals的说明:

public boolean equals(Object obj)

Compares this URL for equality with another object.
If the given object is not a URL then this method immediately returns false.

Two URL objects are equal if they have the same protocol, reference equivalent hosts, have the same port number on the host, and the same file and fragment of the file.

Two hosts are considered equivalent if both host names can be resolved into the same IP addresses; else if either host name can't be resolved, the host names must be equal without regard to case; or both host names equal to null.

Since hosts comparison requires name resolution, this operation is a blocking operation.

Note: The defined behavior for equals is known to be inconsistent with virtual hosting in HTTP.
        这个例子来说明不一致性,我个人认为可能是因为1、网络的不稳定性。2、多宿主机??
       

        5、非空性(not null)。对于任何非null的引用值x,x.equals(null)必须返回false。

        尽管很难想象什么情况下o.equals(null)调用会意外地返回true,但是意外抛出NullPointerException异常的情况倒不难想象。通用约定不允许抛出NullPointerException异常,许多类的equals方法都通过一个显式的null测试来防止这种情况:

@Override
public boolean equals(Object o) {
if (o == null)
return false;
...
}
        但是事实上,这项测试是不必要的,为了测试其参数的等同性,equals方法必须先把Object对象o转换成适当的类型,以便可以调用它的访问方法,或者访问它的域。在进行转换之前,equals方法必须(良好的习惯)使用instanceof操作符,检查其参数的正确类型:

@Override
public boolean equals(Object o) {
if (!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
}
        如果不加这一步的检查而又传递给equals方法的参数为不正确的类型,那么equals方法将会抛出ClassCaseException,这就违反了了equals的给定,但是,如果指定instanceof的第一个操作数为null,那么,不管第二个操作数是哪种类型,instanceof操作符都返回false。因此不再需要独立的null检查。

        综上所述,得出以下实现高质量equals方法的诀窍。

        1、使用==操作符检查”参数是否为这个对象的引用“。如果是,就返回true,这只不过是一种性能优化,如果比较操作很昂贵时就值得这么做。

        2、使用instanceof操作符检查”参数是否为正确的类型“。一般来说,所谓正确的类型指的是equals方法所在的那个类。

        3、把参数转换成正确的类型。

        4、对于该类中的每个”关键“域,检查参数中的哉是否与该对象中对应域相匹配。如果这些测试全部都成功,则返回true,否则返回false。如果第2步中的类型是个接口,那么就必须通过接口访问参数中的域,如果该类型是个类,也许就能够直接访问参数中的域,这要取决于他的可访问性。

        对于既不是float又不是double类型的基本类型域,可以直接使用==操作符进行比较,对于对象引用域,可以递归地调用equals方法。对于float域,可以使用Float.compare方法,对于double域可以使用Double.compare方法,这是因为浮点数的精度问题。存在着Float.NaN、-0.0f以及类似的double常量。对于数组域,则要把以上这些指导原则应用在每一个元素上,如果数组域中的每个元素都很重要,那么就可以考虑使用Arrays.equals方法。

        有些对象引用域包含null可能是合法的。所以,为了避免可能导致NullPointException,则使用下面的习惯用法来比较这样的域。

(field == null ? o.field == null : field.equals(o.field))
       如果field和o.field引用的是相同的对象引用,那么下面的做法更快一些。

(field == o.field || (field != null && field.equals(o.field)
       这主要是考虑到短路求值的问题,如果field和o.field引用同一个对象的机率很大的话,这种方法就会只需要一次比较既可确定结果。同时,域的比较顺序可能会影响到equals方法的性能。为了获得最佳的性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域,不应该去比较那些不属于对象逻辑状态的域,例如用于同步操作的lock域,也不需要去比较冗余域,因为这些冗余域可以由”关键域“计算获得。但是这样做有可能提高 equals方法的性能。如果冗余域代表了整个对象的综合描述,比较这个域可以节省当比较失败时去比较实际数据所需要的开销。

        5、当编写完成equals方法之后,应该考虑它是否是对称的,传递的,一致的。还要编写些测试用例用来测试。一般情况下,自反和非空会自动满足。

        书中给的一些关于写equals的告诫:

        1、覆盖equals时总是要覆盖hashCode。

        2、不要企图让equals方法过于智能。过度去寻找各种等价关系很容易陷入麻烦之中。

        3、不要将equals声名中的Object对象替换成其他的类型。如果替换以后就是重载了Object类的equals类,而不是重写了。@Override可以防止范这种错误。