软件构造系列学习笔记(3.5)—————ADT和OOP中的等价性

ADT和OOP中的等价性

在很多场景下,需要判定两个对象是否 “相等”,例如:判断某个Collection 中是否包含特定元素。
==和equals()有和区别?如何为自定义 ADT正确实现equals()?

目录

  • 什么是等价性和为什么需要等价性
  • 三种判断等价性的方法
  • == vs. equals()
  • 不可变类型的等价性
  • 对象合约
  • 可变类型的等价性
  • 自动装箱和等价性

什么是等价性和为什么需要等价性

ADT是对数据的抽象, 体现为一组对数据的操作。
抽象函数AF:内部表示->抽象表示。
基于抽象函数AF定义ADT的等价操作。

现实中的每个对象实体都是独特的,所以无法完全相等,但有“相似性”,在数学中,“绝对相等”是存在的 。

三种判断等价性的方法

  • 利用抽象函数
    AF映射到同样的结果,则等价。即 a equals b if and only if f(a)=f(b)
  • 利用关系
    一个等价关系是 E ⊆ T x T that is:
    自反:E(t,t) ∀ t ∈ T
    对称:E(t,u) ⇒ E(u,t)
    传递:E(t,u) ∧ E(u,v) ⇒ E(t,v)
  • 站在外部观察者角度
    我们可以说,当两个对象无法通过观察进行区分时,这两个对象是相同的,如果我们可以应用的每个操作对两个对象都产生相同的结果。考虑集合表达式{1,2}和{2,1}。
    | {1,2} | = 2和| {2,1} | = 2
    1∈{1,2}为真,且1∈{2,1}为真
    2∈{1,2}为真,且2∈{2,1}为真
    3∈{1,2}为假,3∈{2,1}为假
    … 等等

== vs. equals()

==是引用等价性 ;而equals()是对象等价性。
在自定义ADT时,需要重写Objectequals()

对基本数据类型,使用==判定相等 。
对对象类型,使用equals() ,如果用==,是在判断两个对象身份标识ID是否相等(指向内存里的同一段空间)。

不可变类型的等价性

首先来看Object中实现的缺省equals():

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

在Object中实现的缺省equals()是在判断引用等价性。这通常不是程序员所期望的,因此需要重写。

来看下面的一个栗子:

public class 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

你会看到,即使d2o2最终参照相同的对象在内存中,对他们来说你仍然得到不同的结果。
这是怎么回事?事实证明,该方法Duration已经超载equals(),因为方法签名与Object’s 不相同。我们实际上有两种equals()方法:隐式equals(Object)继承Object,和新的equals(Duration)

public class Duration extends Object {
    // explicit method that we declared:
    public boolean equals (Duration that) {
        return this.getLength() == that.getLength();
    }
    // implicit method inherited from Object:
    public boolean equals (Object that) {
        return this == that;
    }
}

回想一下,编译器使用编译时类型的参数在重载操作之间进行选择。例如,当您使用/运算符时,编译器会根据参数是int还是float,选择整数除法或浮点除法。在这里发生相同的编译时选择。如果我们通过一个Object参考,那么d1.equals(o2)我们最终会调用equals(Object)实现。如果我们通过Duration参考,如在d1.equals(d2),我们最终调用equals(Duration)版本。即使发生这种情况o2d2两者都会在运行时指向同一个对象!平等已经变得不一致。

在方法签名中犯一个错误很容易,并且当您打算覆盖它时重载一个方法。这是一个常见的错误,Java具有语言特性,注释@Override,只要您的意图是重写超类中的方法,就应该使用它。通过这个注释,Java编译器将检查超类中是否存在具有相同签名的方法,如果签名中出现错误,则会给出编译器错误。

因此,这里的实施正确的Durationequals()方法:

@Override
public boolean equals (Object thatObject) {
    if (!(thatObject instanceof Duration)) return false;
    Duration thatDuration = (Duration) thatObject;
    return this.getLength() == thatDuration.getLength();
}

这解决了这个问题:

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

对象合约

当重写该equals方法时,您必须遵守其总体合约。它指出:

  • equals 必须定义一个等价关系 - 即一个自反,对称和传递的关系;
  • equals必须保持一致:对方法的重复调用必须产生相同的结果,前提是equals未修改用于比较对象的信息;
  • 对于非空引用xx.equals(null)应该返回false;
  • hashCode必须为两个被该equals方法视为相等的对象产生相同的结果。

如果两个对象根据equals(Object)方法相等,则对这两个对象中的每个对象调用hashCode方法必须产生相同的整数结果。

Object默认hashCode()实现与其默认值一致equals()

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

对于引用a和b,如果a == b,那么A的地址==B的地址。所以Object合约是满意的。

但不可变对象需要不同的实现hashCode()。因为Duration,由于我们尚未覆盖默认值hashCode(),我们目前正在违约Object:

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

d1并且d2是equal()的,但它们具有不同的哈希码。所以我们需要解决这个问题。

确保满足合同的一种简单而粗暴的方式是hashCode始终返回一些常量值,因此每个对象的哈希码都是相同的。这满足了Object合约,但它会产生灾难性的性能影响,因为每个密钥都将存储在同一个槽中,并且每个查找都会退化为沿着长列表进行线性搜索。

构造仍然满足合约的更合理哈希码的标准方法是计算用于确定相等性的对象的每个组件的哈希码(通常通过调用hashCode每个组件的方法),然后组合这些哈希码,抛出一些算术运算。因为Duration,这很容易,因为该类的抽象值已经是一个整数值:

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

可变类型的等价性

观察等价性:在不改变状态的情况下,即通过只调用observer,producer和creator的方法,两个mutable对象是否看起来一致 。
行为等价性:调用对象的任何方法,允许调用两个对象的任何方法,包括mutator,都展示出一致的结果 。

对可变类型来说,往往倾向于实现严格的观察等价性。但在有些时候,观察等价性可能导致bug,甚至可能破坏RI。

假设我们做了一个List,然后把它放到Set

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!

事实上,它比这更糟糕:当我们遍历集合的成员时,我们仍然在那里找到列表,但是contains()说它不在那里!

for (List l : set) { 
    set.contains(l) → false! 
}

这是怎么回事? List是一个可变对象。在标准的Java实现集合类例如List,突变影响equals()hashCode()的结果。当list首次放入HashSet,它存储在其散列桶的结果对应hashCode()。当list随后发生突变,它的hashCode()变化,但HashSet并没有意识到它应该被移动到不同的桶。所以它再也找不到了。

由于equals()hashCode()可能被突变影响时,我们可以打破使用该对象作为关键字的散列表的不变量。

以下是来自以下规范java.util.Set的一段引人注目的引语:

Note: Great care must be exercised if mutable objects are used as set elements. The behavior of a set is not specified if the value of an object is changed in a manner that affects equals comparisons while the object is an element in the set.

我们应该从这个例子中吸取教训,对可变类型,实现行为等价性即可,也就是说,只有指 向同样内存空间的objects,才是相等的。所以对可变类型来说,无需重写这两个函数,直接继承 Object对象的两个方法即可。 如果一定要判断两个可变对象看起来是否一致,最好定义一个新的方法。

equals()和hashCode()的最终规则

对于不可变类型:
  • equals()应该比较抽象值。这与说equals()应该提供行为等价性是一样的。
  • hashCode() 应该将抽象值映射为一个整数。
对于可变类型:
  • equals()应该比较引用,就像==。再说一次,这与说equals()应该提供行为等价一样。
  • hashCode() 应该将引用映射为一个整数。

自动装箱和等价性(Autoboxing and Equality)

Java中另一个有启发意义的陷阱。我们已经谈到了原始类型和它们的对象类型等价物 - 例如,intInteger。对象类型equals()以正确的方式实现,因此,如果您创建两个Integer具有相同值的对象,它们将equals()彼此相对:

Integer x = new Integer(3);
Integer y = new Integer(3);
x.equals(y) → true

但这里有一个微妙的问题。==超载。对于类似的参考类型Integer,它实现了参照等价:

x == y // returns false

但对于像原始类型int,==实现行为等价:

(int)x == (int)y // returns true

所以你不能真正使用Integer互换int。Java自动转换int和Integer(这称为autoboxing和autounboxing)的事实可能导致微妙的错误!你必须知道表达式的编译时类型是什么。考虑这个:

Map<String, Integer> a = new HashMap(), b = new HashMap();
a.put("c", 130); // put ints into the map
b.put("c", 130);
a.get("c") == b.get("c") → ?? // what do we get out of the map?

放入Map的时候, 自动将int 130转为 了Integer,对Integer类型的==, 是reference equivalence判断,b.get("c"),取出来的时候,得到的是Integer类型,因此a.get(“c”) == b.get(“c”)的结果为false。

你可能感兴趣的:(软件构造系列学习笔记)