【MIT软件构造】Equality

引言

由前文知java提供了抽象数据类型ADT,使数据类型不再表现于具体属性,而表现于ADT在用户眼中的特征。可以想到 ,定义并阐明ADT的相等关系是很有必要的。

等价关系

一个等价关系E具有如下性质

  1. 自反性:E(t, t) ∀ t ∈ T
  2. 对称性:E(t, u) ⇒ E(u, t)
  3. 传递性:E(t, u) ∧ E(u, v) ⇒ E(t, v)
    显然,我们定义的相等关系应该是一个等价关系,如“==”,equals()

不可变类型的相等定义

定义不可变ADT的相等关系(以下讨论均为不可变类型)有两种方法
1、根据AF定义
AF为R空间到A空间的一个映射,a与b相等当且仅当AF(a)=AF(b)
2、根据observer定义
observer为用户观察ADT的手段,故定义当所有的观察器都区分不了a与b时,a与b相等
下面是一个例子

public class Duration {
    private final int mins;
    private final int secs;
    // Rep invariant:
    //    mins >= 0, secs >= 0
    // Abstraction function:
    //    AF(min, secs) = the span of time of mins minutes and secs seconds

    /** Make a duration lasting for m minutes and s seconds. */
    public Duration(int m, int s) {
        mins = m; secs = s;
    }
    /** @return length of this duration in seconds */
    public long getLength() {
        return (long)mins*60 + secs;
    }
}
Duration d1 = new Duration (1, 2);
Duration d2 = new Duration (1, 3);
Duration d3 = new Duration (0, 62);
Duration d4 = new Duration (1, 2);

站在AF的角度,d1d2d4是相等的,它们都映射到62秒
站在观察器的角度,d1d2d4是相等的,getLength()都得到62秒
值得一提的是两个角度得到的结果应始终保持一致

==与equals()的区别

在java中,==判定两边是否指向一块内存,即判定引用相等性(reference equality);而equals()判断内容是否相等,即判定对象相等性(object equality)
在其他语言中也有不同
【MIT软件构造】Equality_第1张图片
java中object类中的equals方法就是简单的引用比较

public class Object {
    ...
    public boolean equals(Object that) {
        return this == that;
    }
}

==与object的equals()方法是无法改变的,所以我们设计ADT时有必要重写equals()方法来根据需求判定相等

如何实现equals()

对于上述的Duration类,我们很自然地如下实现

// Problematic definition of equals()
    public boolean equals(Duration that) {
        return this.getLength() == that.getLength();        
    }

但在某些情况会得出错误的结果

Duration d1 = new Duration (1, 2);
Duration d2 = new Duration (1, 2);
Object o2 = d2;
d1.equals(d2)true
d1.equals(o2)false

原因很简单,由于参数列表不同,在Duration里写的equals(Duration that)并没有重写Object类中的equals(Object that),于是Duration中实际上有两个equals方法。

Object类的规约

Object类的规约十分重要,一般称为Object Contract,其中的equals方法强调了重写也应遵守的原则:

  1. 具有等价性
  2. 当比较的信息不变时,equals方法的结果应保持不变
  3. 对于任意一个非空的x,x.equals()应该永远返回false
  4. equals方法判断出相等的两个对象hashcode必须相等

下面看看如果不遵守上述原则重写equals会发生什么错误
首先,我们想允许一个一定程度的误差范围

@Override
public boolean equals(Object that) {
    return that instanceof Duration && this.sameValue((Duration)that);
}

private static final int CLOCK_SKEW = 5; // seconds

// returns true iff this and that represent the same abstract value within a clock-skew tolerance
private boolean sameValue(Duration that) {
    return Math.abs(this.getLength() - that.getLength()) <= CLOCK_SKEW;
}

其中instanceof检测that是否在运行时为Duration类型(应保证只在equals中使用此方法)
可以看到这样的equals不是等价关系(没有传递性),会导致如下问题

Duration d_1_00 = new Duration(1, 0);
Duration d_0_57 = new Duration(0, 57);
Duration d_1_03 = new Duration(1, 3);
d_0_57.equals(d_1_00) -> true
d_1_00.equals(d_1_03) -> true
d_0_57.equals(d_1_03) -> false

Hashcode()

紧接前文,现在我们来看看如果equals相等的两个对象hashcode不同会发生什么
hash散列技术将一个个对象通过键值映射到一个个桶中,每个桶有不止一个对象(它们的hashcode相同)。所以,如果两个equals方法判断出相等的两个对象A,B,若它们的hashcode不同,它们会被分配到不同的桶中。这样在查找的时候(通过hashcode锁定桶)可能就会失败。
可以看到Object类本身的hashcode实现是遵循这个原则的,equals的Object意味着两个对象地址相同,故hashcode也相同

public class Object {
  ...
  public boolean equals(Object that) { return this == that; }
  public int hashCode() { return /* the memory address of this */; }
}

但如果我们的ADT不重写hashcode,是会发生错误的

Duration d1 = new Duration(1, 2);
Duration d2 = new Duration(1, 2);
d1.equals(d2)true
d1.hashCode()2392
d2.hashCode()4823

对于上文提到的Duration类,稍加改变就能使其满足要求

@Override
public int hashCode() {
    return (int) getLength();

所以可以明确地说:重写equals方法后一定要重写hashcode方法
同时为了避免拼写错误(如误拼为hashcode),建议带上@Override,这样在误拼的时候会提示没有重写任何方法。

可变类型的相等定义

可变类型的相等定于与不可变类型大同小异,一般从以下两个方面考虑:

  1. 观察等价性(observational equality):A与B相等当且仅当在当前时刻,Observer无法区分两者
  2. 表现等价性(behavioral equality):A与B相等当且仅当在现在或未来,A与B无法被区分(即使内容被改变)

对于不可变类型来说,观察等价性与表现等价性是完全相同的,因为不可变类型无法被改变,当在某一时刻被观测到两者相同时,这两者会一直保持相同。
值得注意的是java的list和array实现的equals并不相同,其他collection如map、set与list相同

// for arrays, java uses  behavioral equality
int[] arrayA = new int[] { 1, 2, 3 };
int[] arrayB = arrayA;
int[] arrayC = new int[] { 1, 2, 3 };
arrayA.equals(arrayB) -> true
arrayA.equals(arrayC) -> false
arrayA == arrayB -> true
arrayA == arrayC -> false

//	for list, java uses observational equality
List<Integer> listA = List.of(1, 2, 3);
List<Integer> listB = new ArrayList<>(listA);
List<Integer> listC = listB;
ListA.equals(ListC) -> true
ListA.equals(ListB) -> true
arrayA == arrayB -> false
arrayB == arrayC -> true

至于我们在设计可变类型ADT时,可能认为根据观察等价性是不错的选择。毕竟如果根据表现等价性,A与B相等只有可能A与B指向了同一块内存,也就是说不需要重写equals和hashcode,直接用Object提供的就行。但事实告诉我们,我们就应该根据表现等价性设计相等。也就是说,对于mutable类型的ADT,我们不应该重写Object类的equals()与hashCode()!下面是为什么这么做的原因:

List<String> list = new ArrayList<>();
list.add("a");

Set<List<String>> set = new HashSet<List<String>>();
set.add(list);

set.contains(list)true
list.add("goodbye");
set.contains(list)false!!!!

list改变后hashcode改变了,但是set中存放的hashcode并没有变,所以查找不到,但是通过iterator却发现它明明就在set中,这便是可变类型equals设计成观察等价性带来的bug,因为它只能保证在某一时刻两者是相等的
【MIT软件构造】Equality_第2张图片

总结

对于可变类型:

  1. equals方法应与==相同,即比较引用
  2. hashCode方法应将引用映射到一个整数值

即,一定不要重写Object里的equals与hashCode方法
对于不可变类型:

  1. equals方法应比较观察等价性(即表现等价性)
  2. hashCode方法应该将抽象数据类型映射到一个整数值

即,一定要重写Object里的equals与hashCode方法

你可能感兴趣的:(java与软件构造)