3.5
ADT
和
OOP
中的 等价性
ADT
和
OOP
中的 等价性
一. 不可变类型的相等
1. 等价关系
对于关系 E ⊆ T × T E\subseteq T\times T E⊆T×T ,它满足:
- 自反性: E ( t , t ) , ∀ t ∈ T E(t, t),\forall t\in T E(t,t),∀t∈T
- 对称性: E ( t , u ) ⇒ E ( u , t ) E(t,u)\Rightarrow E(u,t) E(t,u)⇒E(u,t)
- 传递性: E ( t , u ) ∧ E ( u , v ) ⇒ E ( t , v ) E(t,u)\wedge E(u,v)\Rightarrow E(t,v) E(t,u)∧E(u,v)⇒E(t,v)
我们就说 a
等于 b
当且仅当 E(a, b)
-。
2. 抽象函数
由于抽象函数是
A
F
:
R
→
A
AF:R → A
AF:R→A
将具体的表示数据映射到了抽象的值。如果
A
F
(
a
)
=
A
F
(
b
)
AF(a)=AF(b)
AF(a)=AF(b)
我们说 a
和 b
相等。
这种方法不好,这是因为 AF
不一定是双射。
3.通过观察判定抽象值相等
两个对象相等,当且仅当使用者无法观察到它们之间有不同,即每一个观察总会都会得到相同的结果。
从 ADT
来说,“观察”就意味着使用它的观察者/操作。所以也可以说两个对象相等当且仅当它们的所有 observer
方法都返回相同的结果。
这里要注意一点,“观察者/操作”都必须是 ADT
的规格说明中规定好的。
4. ==
与 equals()
==
比较的是索引。更准确的说,它测试的是指向相等。
引用等价性:如果两个索引指向同一块存储区域,那它们就是==
的。对于我们之前提到过的快照图来说,==
就意味着它们的箭头指向同一个对象。一般使用在基本数据类型
equals()
比较的是对象的内容。更准确的说,它测试的是对象值相等。在每个 ADT
中,equals
操作必须被合理定义。
5. 使用 equals()
equals()
是在 Object
中定义的,它的(默认)实现方式如下:
public class Object {
...
public boolean equals(Object that) {
return this == that;
}
}
可以看到, equals()
在 Object
中的实现方法就是测试指向/索引相等。
对于不可变类型的对象来说,这几乎总是错的。所以你需要重写(override
) 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
事实上, Duration
只是重载(overloaded
)了 equals()
方法,因为它的方法标识和Object
中的不一样。
也就是说,这是 Duration
中有两个 equals()
方法:一个是从 Object
隐式继承下来的 equals
(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;
}
}
因为方法标识的原因重载而不是重写了的方法。在 Java
中,你可以使用 @Override
来提示编译器你是要后面的方法覆盖父类中的方法,而编译器会自动检查这个方法是否和父类中的方法有着相同的标识(产生覆盖),否则编译器会报错。
instanceof
操作符用来测试一个实例是否属于特定的类型。
instanceof
是动态检查而非我们更喜欢的静态检查。
普遍来说,在面向对象编程中使用 instanceof
是一个不好的选择。除了实现相等操作,instanceof
不能被使用。这也包括其他在运行时确定对象类型的操作,例如 getClass
。
关于在 equals()
中使用 getClass
还是 instanceof
操作符存在一些争议,焦点集中于使用 instanceof
操作符可能会影响相等的对称性(父子类)。(《 Java
核心技术 卷一 第十版》的 5.2.2
节。)
二. Object
规约
1. 重写 equals
Object
的规格说明有时候称为对象契约。当重写 equals
时,要遵守如下规定:
equals
必须定义一个等价关系:自反、对称、传递equals
必须是确定的。即连续重复的进行相等操作,结果应该相同,除非其值进行了改变- 对于不是
null
的索引x
,x.equals(null)
应该返回false
- 如果两个对象使用
equals
操作后结果为真,那么它们各自的hashCode
操作的结果也应该相同
2. 破坏哈希表 / 哈希桶
一个哈希表表示的是一种映射:从键值映射到值的抽象数据类型。哈希表提供了常数级别的查找,所以它通常比数或者列表的性能要好。键不一定是有序的,也不一定有什么特别的属性,除了类型必须提供 equals
和 hashCode
两个方法。
Object
默认的 hashCode()
实现和默认的 equals()
保持一致。
public class Object {
...
public boolean equals(Object that) { return this == that; }
public int hashCode() { return /* the memory address of this */;}
}
当你重写 equals
后,将 hashCode
也重写。等价的对象必须有相同的 hashCode
。不同对象的哈希值可以一样(只是查找效率可能变低)可以不一样。
当然也可以不重写,只要你能保证你的 ADT
不会被放入到 Hash
类型的集合类中。
三. 可变类型的相等
1. 两种相等
- 观察等价性:两个索引在不改变各自对象状态的前提下不能被区分。例如,只调用观察者、生产者、创建者。它测试的是这两个索引在当前程序状态下“看起来”相等。
- 行为等价性:两个索引在执行任何代码的情况下都不能被区分,即使有一个对象调用了改造者。它测试的是两个对象是否会在未来所有的状态下“行为”相等。
对于可变对象,Java
通常实现的是观察等价性。
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!
for (List<String> l : set) {
set.contains(l);// → false!
}
如果一个集合的迭代器和 contains()
都互相冲突的时候,显然这个集合已经被破坏了。
List<String>
是一个可变对象,而在 Java
对可变对象的实现中,mutator
操作通常都会影响 equals()
和 hashCode()
的结果。所以列表第一次放入 HashSet
的时候,它是存储在这时 hashCode()
对应的索引位置。但是后来列表发生了改变,计算 hashCode()
会得到不一样的结果,但是 HashSet
对此并不知道,所以调用 contains
时候就会找不到列表。
equals()
和 hashCode()
被改动破坏了哈希表利用对象作为键的不变量。
java.util.Set
规格说明中:
注意:当可变对象作为集合的元素时要特别小心。如果对象内容改变后会影响相等比较而且对象是集合的元素,那么集合的行为是不确定的。
如果某个 mutable
的对象包含在 Set
集合类中,当其发生改变后,集合类的行为不确定。
可变类型的 equals()
应该实现行为等价性。这通常都意味着两个对象只有在是索引别名的时候 equals()
才会返回真。
索引可变类型的 equals()
和 hashCode()
应该直接从 Object
继承。但 Java
坚持它对可变类型的 equals()
的实现(使用的是观察等价性)。
但有一些如 StringBuilder
使用的是行为等价性。
对于需要观察等价性操作的可变类型,最好是设计一个新的操作,例如 similar()
或 sameValue()
。
四. equals()
和 hashCode()
的总结
对于不可变类型,
equals()
应该比较抽象值是否相等。这和equals()
比较行为相等性是一样的。hashCode()
应该将抽象值映射为整数
所以不可变类型应该同时重写 equals()
和 hashCode()
。
对于可变类型:
equals()
应该比较索引(==
)。比较行为等价性。hashCode()
应该将索引映射为整数。
所以可变类型不需要重写 equals()
和 hashCode()
,而是直接继承 Object
中的方法。Java
没有为大多数聚合类遵守这一规定。
clone()
方法浅拷贝,拷贝各属性引用。深拷贝是拷贝的是属性内容,将其放入拷贝后的相应属性。
x.clone() != x;//true
x.clone().getClass() == x.getClass();//true
x.clone().equals(x);//true
五. 自动包装与相等
对于原始基本类型和包装类型, equals
比较的是两个对象的值,但包装类型的 ==
被重载,判断的是索引相等,而原始类型仍然是值相等。
Integer x = new Integer(3);
Integer y = new Integer(3);
x.equals(y);// → true
x == y // returns false
(int)x == (int)y // returns true
所以不能真正的将 Integer
和 int
互换。事实上 Java
会自动对 int
和 Integer
进行转换(这被称作自动装箱和拆箱),这也会导致 bug
。
Map<String, Integer> a = new HashMap<>();
Map<String, Integer> b = new HashMap<>();
a.put("c", 1);//实质是 Integer.valueOf(3) 调用,只在 -128 到 127 中调用
b.put("c", 1);
System.out.println (a.get("a")== b.get("a"));//true
Integer x = new Integer(2);
Integer y = new Integer(2);
System.out.println(x==y);//false
Integer x = 2;
Integer y = 2;
System.out.println(x==y);//true