目录链接
文章的内容是我在复习这门课程时候看PPT时自己进行的翻译和一些总结,如果有错误或者出入希望大家能和我讨论!同时也希望给懒得翻译PPT而刷到这篇博客的你一些帮助!
Part I Equivalence Relation
在我们的物质的现实世界,每一个对象都是独一无二的,即使是一些很细小的事物,比如叶子和雪花都没有两个完全相同的个体。所以呢,在物质世界中无法定义完全相等,但是有相似性。
然而在人类语言和数学中,绝对相等是存在的。就比如说,我们说计算式1 + 2
、3
和math.sqrt(9)
在数值上是完全相等的。那么等价性问题在软件中是怎么定义的呢?
- 等价关系
我们定义下面一种关系E,使之满足:
- reflexive自反关系: E ( t , t ) s . t . ∀ t ∈ T \Epsilon(t, t) \qquad s.t.\forall t \in T E(t,t)s.t.∀t∈T
- symmetric对称关系: E ( t , u ) ⇒ E ( u , t ) \Epsilon(t, u) \Rightarrow\Epsilon(u,t) E(t,u)⇒E(u,t)
- transitive传递关系: E ( t , u ) ∧ E ( u , v ) ⇒ E ( t , v ) \Epsilon(t, u) \land \Epsilon(u, v) \Rightarrow \Epsilon(t, v) E(t,u)∧E(u,v)⇒E(t,v)
同时满足上述自反、传递和对称的关系我们称之为等价关系。
举一个例子来说,我们经常使用的逻辑比较符==
和.equals()
方法就是一种等价关系。因为a.equals(a)永真;a.equals(b)
⇒
\Rightarrow
⇒ b.equals(a);a.equals(b)
∧
\land
∧ b.equals(c)
⇒
\Rightarrow
⇒ a.equals(c),满足自反对称传递,因此为等价关系。
Part II Three ways to regard equality
这里讲述描述等价性的角度
ADT是对数据的抽象,通过已操作为特点的数据的抽象而不是表示方法的抽象。
2.1Using AF to define the equality
对于一个抽象数据类型而言,抽象函数AF解释了具体的表示值是怎么对应映射到抽象值的。所以我们可以根据抽象函数AF来定义ADT的等价操作。具体的定义为:如果AF映射到同一个抽象空间的话,则等价。
2.2Using observation to define the equality
另一种定义等价性的方式就是通过observer来判断。在使用observer时,我们说如果当两个对象让我们看不出来行为上的区别,那么他们就是等价的。
因为对于ADT来说,observation意味着观察被调用对象的操作,因此当对两个对象调用任何相同的操作都得到相同的结果的时候,这两个对象就是等价的。
2.3Example
针对上图中的类我们定义一些操作,代码如下:
/** @return the size of this set */
public int size() { ... }
/** @param letter must be a letter 'a'...'z' or 'A'...'Z' *
@return true iff this set contains letter, ignoring alphabetic case */
public boolean contains(char letter) { ... }
/** @return the length of the string that this set was constructed from */
public int length() { ... }
/** @return the union of this and that */
public LetterSet union(LetterSet that) { ... }
/** @return true if and only if all the letters in this set are lowercase */
public boolean isAllLowercase() { ... }
/** @return the first letter in s */
public char first() { ... }
这些方法的含义是很容易理解的,我就不加以赘述了,重点放在他们之间的等价性关系上。在这里我们声明几个实例:
new LetterSet("abc")
new LetterSet("aBc")
new LetterSet("")
new LetterSet("bbbbbbbc)"
new LetterSet("1a2b3c")
PPT上说的可能有些难懂,下面这些是我对PPT内容的理解。
看我们定义的方法都属于observer,我们拿.contains()
方法做一下解释。这里我们说的等价性是指,通过观察器得到的结果相同的,就比如字符串"abc"
和字符串"aBc"
通过contains()
方法得到的结果是一样的;而且这两个字符串在AF定义的等价性上也是相同的,所以我们说.contains()
方法定义的观察丁家兴和AF定义的等价性有着相同的选择。
我们再看.size()
方法,如果两个实例的.size()
方法相同的话,AF的映射结果是有可能不同的。就比如字符串"abc"
和字符串"def"
,他们的.size()
方法返回值都是3,但是AF映射出的Set确实不同的,因此我们说.size()
方法单独是无法判断观察等价性的,但是结合.contains()
方法就可以判断了。
我从集合论的角度来描述一下吧,可能让你更容易理解一点。我们以椭圆来表示一个方法所包含的等价对象的多少:如果一个椭圆B被另一个椭圆A包含,那么A和B就不是等价的,因为属于大椭圆的元素不一定属于小椭圆;如果一个椭圆A包含一个椭圆B,那么A和B就是等价的因为属于椭圆B的元素一定属于椭圆A。这两句话是有着很大区别的!我们以Venn图来演示并解释:
看这个图,在上面我们讨论过,.size()
方法的观察等价性和AF定义的等价性是不一样的,表现为.size()
方法的圈更大,包含了更多AF中没有的元素;而.contain()
方法观察等价性筛选出来的等价元素和AF选出来的是一毛一样的,因此圈重合;那么.length()
方法定义的观察等价和AF有不重合的地方,因此也不同。
Part III == vs. equals()
这一节的内容是将逻辑运算符==
和.equals()
方法进行比较
在Java中,这两种等价性测试都是支持的,但是他们有着不同的语义:
- ==:比较的是引用等价性,也就是说如果两个对象指向同一个引用的值,那么它们通过
==
判断就是等价的。 - .equals()方法:
.equals()
方法判断的是对象等价性。需要注意的是,在自定义ADT时,需要根据对等价的要求,决定是否重写Object的.equals()
方法。
针对这两种判断在使用的时候我们进行一下简单的总结:对于基本数据类型我们使用==
判断相等;对于对象类型,我们使用.equals()
方法判断相等。再对两个对象等价性进行判断的时候,我们应该总是使用.equals()
方法,如果我们想要判断两个对象的逻辑是内存地址是否相等,那么则可以不用重写Object.equals()
方法,此时它等价于==
;其他情况下如果我们判断特定的逻辑,则需要重写Object.equals()
方法。
Part IV Implementing equals()
在Object类中,缺省的equals()方法判断的是引用等价性,而这通常不是我们所期望的,因此需要重写。我们看一下下面一段代码示例:
public class Duration{
...
// Problematic definition of equals()
public boolean equals(Duration that){
return this.getLength() == that.Length();
}
}
你可能会想这是一种重写,但是实际上它是一种重载。为什么呢?因为这个类中的.equals()
方法和他继承的Object类中的.equals()
方法有着不同的参数列表,因此这种写法是一种重载。
我们的client可以这样使用:
Duration d1 = new Duration(1, 2);
Duration d2 = new Duration(1, 2);
Object o2 = d2;
assertEquals(true, d1.equals(d2));
assertEquals(false, d1.equasl(o2));
我们关注assert的这两行,为什么会是这样的结果呢?我们再传入参数o2的时候,传入的是一个Object类型的参数,因此我们在调用的时候实际上调用的是父类中的.equals()
方法,比较的是引用等价性,那么显然,两者引用是不相等的。关于重载的问题我们在上一章已经做了详细的讨论,相信你已经理解了;如果不明白的话可以跳回看一下上一章的6.2中的示例。
如果我们想要重写.equals()
方法,最好的做法是在要重写的方法前面加上@Override,请求编译器帮助你检查两个方法的签名是否一致。给一个重写的例子:
@Override
public boolean equals(Object thatObject){
if (!(thatObject instanceof Duration)) return false;
Duration tahtDuration = (Duration) thatObject;
return this.getLength() == thatDuration.getLength();
}
我们还可以进行优化,关于数值比较的部分我们可以通过委派的方式让另一个方法去做这件事,从而使代码等价清晰简洁。
4.1equals()重写套路
首先将传入的参数和null进行比较,如果传进来一个空指针那么返回值肯定是false
;接下来判断如果传入类型是本类的一个字类型,那么也返回false
;最后就是进行判断逻辑的实现了。
- instanceof关键字
instanceof
关键字实现的功能是判断某个对象是不是特定的类型或者他的子类型,使用instanceof
是一种动态类型检查,而非静态类型检查。它的实现逻辑是,尝试进行一次向运算符右侧类型的强制转换,如果成功了就返回true
;失败了就返回false
。
但是我们在写代码的过程中,频繁的使用instanceof
和getClass()
是一种不好的编程习惯,我们除了在重写.equals()
方法的时候,尽量不使用这两种形式。
Part V The Object contract
在上面的讨论中我们队与等价关系的定义阐述的已经比较清晰了,等价关系是自反传递对称的。在讨论这个关系的时候有几个点需要注意一下:
- 除非对象被修改过,否则多次调用
.equals()
方法得到的结果应该是一样的 - 定义相等的对象,其
.hashCode()
方法返回的结果必须是一样的
因为这几个性质,可以通过是否为等价关系来检验你写的.equals()
方法是否正确。你可能会想,如果我定义一个等价关系,定义的方法是两个对象的.hashCode()
方法返回值的差值在一定范围内就判定为等价,这样可以吗?答案是否定的。举一个简单的例子,如果两个数的差值小于5,那我认为两个Integer相等的话,就会出现0和3等价,3和6等价,但是0和6并不等价的情况。导致这问题的原因是它破坏了哈希表。
哈希表实现了键-值之间的映射,它的实现方式是使用一个数组,根据对象的hashcode来决定它将被放在哪里。因此,哈希表在工作的时候,他不是简简单单维护一个值,而是掌握了一组值,我们称之为哈希桶。但是哈希桶的大小是有限的,两个不同对象的哈希值可能相同,这时就发生了冲突,进一步会通过散列技术来解决冲突,而你可以使用.equals()
方法来进一步判断两个对象的等价性。如下图所示:
5.1hashCode()方法注意事项
因为我们要求等价的对象的.hashCode()
返回值相同,因此就是需要我们使用重写.hashCode()
方法。那么怎么么样进行重写呢?
最简单的方法就是我们让所有等价的对象得hashCode相同就可以了,非常好实现但是会降低hashTable的效率。另一种比较常用的有效的方法是将.euqlas()
方法中使用到的计算信息进行组合,拼出新的hashCode。
Part VI Equality of Mutable Types
关于可变类型的等价性我们从下面两个角度来进行考虑,也是前面提及过的两个角度:
- 观察等价性:再不改变状态的情况下,如果两个对象通过Observers看到的东西没有区别则满足观察等价性
- 行为等价性:调用两个可变对象的方法,如果通过方法看不出来区别就满足行为等价性
而对于可变对象来说,我们一般更注意他的观察等价性,在Java语言中也是这么进行处理的。例如两个List中包含相同顺序的元素则.equals()
方法返回true,但是不排除个别可变类型探究其行为等价性的情况。
但是在有些时候,观察等价性可能导致bug的出现,甚至可能破坏RI,我们看下面的例子:
List<String> list = new ArrayLList<>();
List.add("a");
Set<List<String>> set = new HashSet<List<String>>();
set.add(list);
assertEquals(true, set.contains(list));
list.add("GoodBye!");
assertEquals(false, set.contains(list));
我们发现在对list进行了操作之后set中竟然找不到我们之前存入的list了!这是为什么呢?原因是我们进行的对可变类型的改变影响了equals()和hashCode()的结果。我们在进行操作的时候,对象的hashCode发生了改变,但是HashSet却没有更新其在哈希桶中的位置,所以我们在查找的时候找不到元素。
所以!如果某个mutable的对象包含在Set集合中,当期发生改变之后,集合类的行为不确定,请务必小心!
因此对可变类型,实现行为等价性即可。也就是说,只有指向同样的内存空间的objects,才是相等的。对于可变类型来说,无需重写equals()
和hashCode()
这两个函数,直接继承Object的两个方法即可。如果一定要判断两个可变对象看起来是否一致,最好定义一个新的方法。
Part VII Autoboxing and Equality
这一节讲的是自动装箱有关的问题
自动装箱是什么呢?我们在写代码的时候一定是用过这样的语句:
Integer x = new Integer(3);
assertEquals(true, x.equals(y));
assertEquals(false, x == y);
这里其实就是用到了一种叫做自动装箱的技术,因为赋值等式的左端是一个Integer对象类型,但是右边是一个int的整型数值,那么在进行赋值的时候,就会将这个int值包装成一个Integer的对象。
那么我们再来看下面一段代码:
Map<String, Integer> a = new HashMap<>();
Map<String, Integer> b = new HashMap<>();
a.put("c", 1); b.put("c", 1);
assertEquals(true, a.get("c") == b.get("c"));
在上一段代码的最后一句,我们实际上是将两个Integer值使用了==
进行直接的比较,那么返回结果为什么是true呢?这涉及到一个概念,叫做常量池,在Java语言中,对于Integer类型,有一个范围在-128~127
的一个空间,如果我们声明的一个Integer的值在这个范围内,那么就会将引用指向这个常量池相应位置。因此,在-128~127
之间的Ineger使用==
比较引用等价性的结果等同于基本数据类型之间的比较。
这一部分的知识到此就结束了,感谢你能看到这里,说明你真的有好好学习。如果你觉得我写的还行,还请一键三连!