<font size="3">html
本文内容来自MIT_6.031_sp18: Software Construction课程的Readings部分,采用CC BY-SA 4.0协议。java
因为咱们学校(哈工大)大二软件构造课程的大部分素材取自此,也是推荐的阅读材料之一,因而打算作一些翻译工做,本身学习的同时也能帮到一些懒得看英文的朋友。另外,该课程的阅读资料中有的练习题没有标准答案,所给出的“正确答案”为译者所写,有错误的地方还请指出。程序员
(更新:从第10章开始只翻译正确答案)web
<br />编程
<br />api
译者:李秋豪数组
审校:安全
V1.0 Thu Apr 12 21:02:06 CST 2018数据结构
<br />oracle
<br />
在以前的阅读材料中,咱们已经描述了抽象数据类型(ADT)是由它对应的操做而非内部表示决定的。而ADT中的抽象函数解释了该类型是如何将内部表示映射为使用者理解的抽象数据的,咱们也看到了抽象函数决定了咱们应该如何实现ADT的各个操做。
在这篇阅读中咱们会聚焦于如何定义ADT的相等:抽象函数会给咱们对相等操做一个清晰的定义。
在现实物理世界中,任何对象都是不相等的——在某些层次,即便是两片雪花也是不一样的,即便这种不一样只是在空间中的位置(严格一点的话,在原子层次不能这么说,不过对于现实生活中“大”的对象已经足够正确了)。因此任何物理对象都不会真正相等,它们只会在某一些方面类似。
可是对于人类语言,或者对于数学世界,你能够有不少彻底相同的东西。例若有两个相等的表达式是很正常的,又例如√9 和 3表现了彻底相同的数值。
<br />
严格来讲,咱们能够从三个角度定义相等:
**抽象函数:**回忆一下抽象函数(AF: R → A ),它将具体的表示数据映射到了抽象的值。若是AF(a)=AF(b),咱们就说a和b相等。
**等价关系:**等价是指对于关系E ⊆ T x T ,它知足:
咱们说a等于b当且仅当E(a,b)。
以上两种角度/定义其实是同样的,经过等价关系咱们能够构建一个抽象函数(译者注:就是一个封闭的二元关系运算);而抽象函数也能推出一个等价关系。
第三种断定抽象值相等的方法是从使用者/外部的角度去观察。
**观察:**咱们说两个对象相等,当且仅当使用者没法观察到它们之间有不一样,即每个观察总会都会获得相同的结果。例如对于两个集合对象 {1,2} 和 {2,1},咱们就没法观察到不一样:
从ADT来讲,“观察”就意味着使用它的观察者/操做。因此咱们也能够说两个对象相等当且仅当它们的全部观察操做都返回相同的结果。
这里要注意一点,**“观察者/操做”都必须是ADT的规格说明中规定好的。**Java容许使用者跨过抽象层次去观察对象的不一样之处。例如==
就可以判断两个变量是不是索引到同一个存储地方的,而 System.identityHashCode()
则是根据存储位置计算返回值的。可是这些操做都不是ADT规格说明中的操做,因此咱们不能根据这些“观察”去判断两个对象是否相等。
这里有一个不可变ADT的例子:
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 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);
试着分别从抽象函数、等价关系以及使用者观察这三个角度分析。
Any second now
思考上面的 Duration
以及变量 d1
, d2
, d3
, d4
,从抽象函数或等价关系来看,哪一些选项和d1
相等?
[x] d1
[ ] d2
[x] d3
[x] d4
Eye on the clock
从使用者观察的角度,哪一些选项和d1
相等?
[x] d1
[ ] d2
[x] d3
[x] d4
<br />
和不少其余语言同样,Java有两种判断相等的操做—— ==
和 equals()
。
==
比较的是索引。更准确的说,它测试的是指向相等(referential equality)。若是两个索引指向同一块存储区域,那它们就是==的。对于咱们以前提到过的快照图来讲,==
就意味着它们的箭头指向同一个对象。equals()
操做比较的是对象的内容,换句话说,它测试的是对象值相等(object equality)。e在每个ADT中,quals操做必须合理定义。做为对比,这里列出来了几个语言中的相等操做:
referential equality | object equality | |
---|---|---|
Java | == |
equals() |
Objective C | == |
isEqual: |
C# | == |
Equals() |
Python | is |
== |
Javascript | == |
n/a |
注意到==
在Java和Python中的意义正好相反,别被这个弄混了。
做为程序员,咱们不能改变测试指向相等操做的意义。在Java中,==
老是判断指向是否相等。可是当咱们定义了一个新的ADT,咱们就须要判断对于这个ADT来讲对象值相等意味着什么,即如何判断对象值相等/如何实现equals()
操做。
<br />
equals()
是在 Object
中定义的,它的(默认)实现方式以下:
public class Object { ... public boolean equals(Object that) { return this == that; } }
能够看到, equals()
在Object
中的实现方法就是测试指向/索引相等。对于不可变类型的对象来讲,这几乎老是错的。因此你须要覆盖(override) equals()
方法,将其替换为你的实现。
咱们来看一个例子,Duration
的相等操做:
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
以下图所示,能够看到,虽然d2
和o2
最终指向的是同一个对象/存储区域,可是咱们的 equals()
却获得的不一样的结果。
这是怎么回事呢?事实上, 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; } }
咱们在以前的“静态检查”阅读中已经说太重载了,回忆一下,编译器会在重载操做之间根据参数类型作出选择。例如,当你使用/
操做符的时候,编译器会根据参数是ints仍是floats选择整数除法或浮点数触发。同理,若是咱们对equals()
传入的是 Duration
索引,编译器就会选择equals(Duration)
这个操做。这样,相等性就变得不肯定了。
这是一个很容易犯的错误,即由于方法标识的缘由重载而不是覆盖了的方法。在Java中,你可使用 @Override
来提示编译器你是要后面的方法覆盖父类中的方法,而编译器会自动检查这个方法是否和父类中的方法有着相同的标识(产生覆盖),不然编译器会报错。
如今咱们更正 Duration
的 equals()
:
@Override public boolean equals(Object that) { return that instanceof Duration && this.sameValue((Duration)that); } // returns true iff this and that represent the same abstract value private boolean sameValue(Duration that) { return this.getLength() == that.getLength(); }
它首先测试了传入的that
是 Duration
(译者注:这里that
还能够是 Duration
的子类),而后调用sameValue()
去判断它们的值是否相等。表达式 (Duration)that
是一个类型转换操做,它告诉编译器你确信 that
指向的是一个 Duration
对象。
咱们再次运行测试代码,结果正确:
Duration d1 = new Duration(1, 2); Duration d2 = new Duration(1, 2); Object o2 = d2; d1.equals(d2) → true d1.equals(o2) → true
instanceof
操做符 是用来测试一个实例是否属于特定的类型。 instanceof
是动态检查而非咱们更喜欢的静态检查。广泛来讲,在面向对象编程中使用 instanceof
是一个很差的选择。在本门课程中——在不少Java编程中也是这样——**除了实现相等操做,instanceof
不能被使用。**这也包括其余在运行时肯定对象类型的操做,例如 getClass
。
咱们会在之后学习如何使用更安全、可改动的代码而不是 instanceof
。
译者注:关于在equals()
中使用 getClass
仍是 instanceof
操做符存在一些争议,焦点集中于使用 instanceof
操做符可能会影响相等的对称性(父子类)。《Java核心技术 卷一 第十版》的5.2.2节对此作了说明,读者能够参考一下。
<br />
因为Object
的规格说明实在过重要了,咱们有时也称它为“对象契约”(the Object Contract)。你能够在object
类中找到这些规格说明。咱们在这里主要研究equals
的规格说明。当你在覆盖equals
时,要记得遵照这些规定:
equals
必须定义一个等价关系。即一个知足自反性、对称性和传递性关系。equals
必须是肯定的。即连续重复的进行相等操做,结果应该相同。x
, x.equals(null)
应该返回false。equals
操做后结果为真,那么它们各自的hashCode
操做的结果也应该相同。正如前面所说,equals()
操做必须构建出一个知足自反性、对称性、传递性的等价关系。若是没有知足,那么与相等相关的操做(例如集合、搜索)将变得不可预测。例如你确定不但愿a
等于b
可是后来发现b
不等于a
,这都是很是隐秘的bug。
这里举出了一个例子,它试图将相等变得更复杂,结果致使了错误。假设咱们但愿在判断 Duration
相等的时候容许一些偏差,由于不一样的电脑同步的时间可能会有一小点不一样:
@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; }
上面相等操做违背了等价关系里面的什么属性?
Equals-ish
思考上面提到的 Duration
:
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 mins*60 + secs; } @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; } }
假设下面这些 Duration
对象被建立:
Duration d_0_60 = new Duration(0, 60); Duration d_1_00 = new Duration(1, 0); Duration d_0_57 = new Duration(0, 57); Duration d_1_03 = new Duration(1, 3);
如下哪一些选项会返回真?
[x] d_0_60.equals(d_1_00)
[x] d_1_00.equals(d_0_60)
[x] d_1_00.equals(d_1_00)
[x] d_0_57.equals(d_1_00)
[ ] d_0_57.equals(d_1_03)
[x] d_0_60.equals(d_1_03)
Skewed up
上面相等操做违背了等价关系里面的什么属性?(忽略null索引)
[ ] recursivity
[ ] 自反性
[ ] sensitivity
[ ] 对称性
[x] 传递性
Buggy equality
若是你想证实上面的equals
违反了自反性,你须要建立几个对象?
[ ] none
[x] 1 object
[ ] 2 objects
[ ] 3 objects
[ ] all the objects in the type
Null, null, null
和咱们以前说过的不一样,equals
操做容许参数为null
,这是由于Object
的规格说明中提到了这种前置条件:
x
, x.equals(null)
应该返回false若是 x.equals(null)
返回true,equals
将会违背等价的什么属性?
[ ] recursivity
[ ] 自反性
[ ] sensitivity
[x] 对称性
[ ] 传递性
哪一行代码会让 equals()
在 that
是null时返回false?
1 @Override 2 public boolean equals(Object that) { 3 return that instanceof Duration 4 && this.sameValue((Duration)that); } // returns true iff this and that represent the same abstract value 5 private boolean sameValue(Duration that) { 6 return this.getLength() == that.getLength(); }
--> 3
为了理解契约中有关hashCode
的部分,你须要对哈希表的工做原理有必定的了解。两个常见的聚合类型 HashSet
和 HashMap
就用到了哈希表的数据结构,而且依赖hashCode
保存集合中的对象以及产生合适的键(key)。
一个哈希表表示的是一种映射:从键值映射到值的抽象数据类型。哈希表提供了常数级别的查找,因此它一般比数或者列表的性能要好。键不必定是有序的,也不必定有什么特别的属性,除了类型必须提供 equals
和 hashCode
两个方法。
哈希表是怎么工做的呢?它包含了一个初始化的数组,其大小是咱们设计好的。当一个键值对准备插入时,咱们经过hashcode计算这个键,产生一个索引,它在咱们数组大小的范围内(例如取模运算)。最后咱们将值插入到数组索引对应的位置。
哈希表的一个基本不变量就是键必须在hashcode规定的范围内。
Hashcode最好被设计为键计算后的索引应该平滑、均匀的分布在全部范围内。可是偶尔冲突也会发生,例如两个键计算出了一样的索引。所以哈希表一般存储的是一个键值对的列表而非一个单个的值,这一般被称为哈希桶(hash bucket)。而在Java中,键值对就是一个有着两个域的对象。当插入时,你只要像计算出的索引位置插入一个键值对。当查找时,你先根据键哈希出对应的索引,而后在索引对应的位置找到键值对列表,最后在这个列表中查找你的键。
如今你应该知道了为何Object
的规格说明要求相等的对象必须有一样的hashcode。若是两个相等的对象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的存储地址也就相同,hashCode()
的结果也就相同。因此Object
的契约知足。
可是对于不可变对象来讲,它们须要从新实现hashCode()
。例如上面提到的 Duration
,由于咱们尚未覆盖默认的 hashCode()
,实际上打破了对象契约:
Duration d1 = new Duration(1, 2); Duration d2 = new Duration(1, 2); d1.equals(d2) → true d1.hashCode() → 2392 d2.hashCode() → 4823
d1
和 d2
是 equals()
为真的,可是它们的hashcode不同,因此咱们须要修复它。
一个简单粗暴的解决办法就是让hashCode
老是返回相同的常量,这样每个对象的hashcode就都同样了。这样确实知足了对象契约,可是会给性能带来灾难性的后果,由于咱们必须将每个键值对都保存到相同的位置,并且查找会是线性遍历全部插入过的对象。
而一个广泛(更合理)的方法就是计算对象每个内容的hashcode而后对它们进行一系列算术运算,最终返回一个综合hashcode。对于 Duration
而言就更简单了,由于它只有一个整型内容:
@Override public int hashCode() { return (int) getLength(); }
更多有关于hashcode的细节,你能够参考Josh Bloch的书 Effective Java,他详细介绍了hashcode应该注意的问题和设计方法。另外StackOverflow上面也有关于这个的问答。在近些版本的Java中,你能够利用 Objects.hash()
方便的计算多个域的综合hashcode。
要注意的是,只要你知足了相等的对象产生相同的hashcode,无论你的hashcode是如何实现的,你的代码都会是正确的。哈希碰撞仅仅只会性能,而一个错误哈希方法则会带来错误!
最重要的是,若是你没有覆盖默认的hashCode
,你就会继承Object
中根据存储地址得到的hashCode
。若是你又覆盖了equals
,这就意味着你很大可能破坏了对象契约,因此一个通用准则就是:
当你覆盖
equals
后,将hashCode
也覆盖
在不少年前,一个本课程的学生花了几个小时找到了一个bug:他将 hashCode
拼成了 hashcode
,也就是说他没有将默认的 hashCode
覆盖,最终奇怪的事情就发生了。因此记得使用 @Override
!
Give me the code
思考下面这个ADT:
class Person { private String firstName; private String lastName; ... @Override public boolean equals(Object that) { return that instanceof Person && this.sameValue(that); } // returns true iff this and that represent the same abstract value private boolean sameValue(Person that) { return this.lastName.toUpperCase().equals(that.lastName.toUpperCase()); } public int hashCode() { // TODO } }
TODO
的地方可使用如下哪些选项,让 hashCode()
和 equals()
保持一致?
return 42;
return firstName.toUpperCase();
return lastName.toUpperCase().hashCode();
return firstName.hashCode() + lastName.hashCode();
<br />
以前咱们已经对不可变对象的相等性进行了讨论,那么可变类型对象会是怎样呢?
回忆以前咱们对于相等的定义,即它们不能被使用者观察出来不一样。而对于可变对象来讲,它们多了一种新的可能:经过在观察前调用改造者,咱们能够改变其内部的状态,从而观察出不一样的结果。
因此让咱们从新定义两种相等:
对于不可变对象,观察相等和行为相等是彻底等价的,由于它们没有改造者改变对象内部的状态。
**对于可变对象,Java一般实现的是观察相等。**例如两个不一样的 List
对象包含相同的序列元素,那么equals()
操做就会返回真。
可是使用观察相等会带来隐秘的bug,而且也会让咱们很容易的破坏聚合类型的表示不变量。假设咱们如今有一个 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<String> l : set) { set.contains(l) → false! }
若是一个集合的迭代器和contains()
都互相冲突的时候,显然这个集合已经被破坏了。
发生了什么?咱们知道 List<String>
是一个可变对象,而在Java对可变对象的实现中,改造操做一般都会影响 equals()
和 hashCode()
的结果。因此列表第一次放入 HashSet
的时候,它是存储在这时 hashCode()
对应的索引位置。可是后来列表发生了改变,计算 hashCode()
会获得不同的结果,可是 HashSet
对此并不知道,因此咱们调用contains
时候就会找不到列表。
当 equals()
和 hashCode()
被改动影响的时候,咱们就破坏了哈希表利用对象做为键的不变量。
下面是 java.util.Set
规格说明中的一段话:
注意:当可变对象做为集合的元素时要特别当心。若是对象内容改变后会影响相等比较并且对象是集合的元素,那么集合的行为是不肯定的。
不幸的是,Java库坚持它对可变类型的 equals()
的实现,即聚合类使用观察相等,不过也有一些可变类型(例如 StringBuilder
)使用的是行为相等。
咱们从上面的例子和分析能够知道**可变类型的equals()应该实现为行为相等。**这一般都意味着两个对象只有在是索引别名的时候equals()
才会返回真。索引可变类型的 equals()
和 hashCode()
应该直接从 Object
继承。
对于须要观察相等操做的可变类型(即当前状态下是否“看起来”同样),最好是设计一个新的操做,例如similar()
或 sameValue()
. 它们的实现或许和上文中的私有方法 sameValue()
类似(可是是公有的)。不幸的是Java没有采起这种设计。
<br />
对于不可变类型:
equals()
应该比较抽象值是否相等。这和 equals()
比较行为相等性是同样的。hashCode()
应该将抽象值映射为整数。因此不可变类型应该同时覆盖 equals()
和 hashCode()
.
对于可变类型:
equals()
应该比较索引,就像 ==
同样。一样的,这也是比较行为相等性。hashCode()
应该将索引映射为整数。因此可变类型不该该将 equals()
和 hashCode()
覆盖,而是直接继承 Object
中的方法。Java没有为大多数聚合类遵照这一规定,这也许会致使上面看到的隐秘bug。
Bag
假设 Bag<E>
是一个可变聚合类型,它表示的是一个multiset(元素能够出现屡次并且无序)。它的操做以下:
/** make an empty bag */ public Bag<E>() /** modify this bag by adding an occurrence of e, and return this bag */ public Bag<E> add(E e) /** modify this bag by removing an occurrence of e (if any), and return this bag */ public Bag<E> remove(E e) /** return number of times e occurs in this bag */ public int count(E e)
运行下面的代码:
Bag<String> b1 = new Bag<>().add("a").add("b"); Bag<String> b2 = new Bag<>().add("a").add("b"); Bag<String> b3 = b1.remove("b"); Bag<String> b4 = new Bag<>().add("b").add("a"); // swap!
如下那些选项在运行事后为真?
[x] b1.count("a") == 1
[ ] b1.count("b") == 1
[x] b2.count("a") == 1
[x] b2.count("b") == 1
[x] b3.count("a") == 1
[ ] b3.count("b") == 1
[x] b4.count("a") == 1
[x] b4.count("b") == 1
Bag behavior
若是 Bag
实现的是行为相等,如下哪一些表达式为真?
[ ] b1.equals(b2)
[x] b1.equals(b3)
[ ] b1.equals(b4)
[ ] b2.equals(b3)
[ ] b2.equals(b4)
[x] b3.equals(b1)
Bean bag
若是 Bag
是Java API的一部分,即它可能实现的是观察相等,如下哪一些表达式为真?
[ ] b1.equals(b2)
[x] b1.equals(b3)
[ ] b1.equals(b4)
[ ] b2.equals(b3)
[x] b2.equals(b4)
[x] b3.equals(b1)
咱们以前提到过原始/基本类型和它们的对应的包装(对象)类型,例如int
和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),这也会致使bug,你应该意识到编译期发生的类型转换。思考下面的代码:
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?
Boxes
在上面的代码中:
表达式 130
在编译期的类型是什么?
--> int
在 a.put("c", 130)
执行后,Map中表示130的值会是什么类型?
--> Integer
a.get("c")
在编译期中的类型是什么?
--> Integer
Circles
Map<String, Integer> a = new HashMap<>(), b = new HashMap<>(); a.put("c", 130); // put ints into the map b.put("c", 130);
画出上面代码执行后的快照图,在你的快照图中有几个 HashMap
对象?
--> 2
在你的快照图中有几个 Integer
对象?
--> 2
Equals
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").equals(b.get("c"))
会返回什么?
--> true
a.get("c") == b.get("c")
会返回什么?
--> false
Unboxes
如今假设你将 get()
的结果存储在int
变量中:
int i = a.get("c"); int j = b.get("c"); boolean isEqual = (i == j);
在上面代码执行后, isEqual
的返回值是什么?
--> true
<br />
HashSet
和 HashMap
)正常工做。相等是实现抽象数据类型中的一部分。如今咱们将本文的知识点与咱们的三个目标联系起来:
Object
中的实现,实现不可变类型时必定要覆盖它们。</font>