第3章:抽象数据类型(ADT)和面向对象编程(OOP) 3.5 ADT和OOP中的等价性

大纲

什么是等价性?为什么要讨论等价性?
三种等价性的方式
==与equals()
不可变类型的等价性
对象契约
可变类型的等价性
自动包装和等价性

什么是等价性?为什么要讨论等价性?

ADT上的相等操作

ADT是通过创建以操作为特征的类型而不是其表示的数据抽象。
对于抽象数据类型,抽象函数(AF)解释了如何将具体表示值解释为抽象类型的值,并且我们看到了抽象函数的选择如何决定如何编写实现每个ADT操作的代码。
抽象函数(AF)提供了一种方法来清晰地定义ADT上的相等操作。

数据类型中值的相等性?

在物质世界中,每个物体都是不同的 - 即使两个雪花的区别仅仅是它们在太空中的位置,在某种程度上,即使是两个雪花也是不同的。
所以两个实体对象永远不会真正“相等”。 他们只有相似的程度。
然而,在人类语言的世界中,在数学概念的世界中,对同一事物可以有多个名称。

  • 当两个表达式表示相同的事物时,很自然地:1 + 2,√9和3是同一个理想数学值的替代表达式。

三种等价性的方式

使用AF或使用关系

使用抽象函数。 回想一下抽象函数f:R→A将数据类型的具体实例映射到它们相应的抽象值。 为了使用f作为等价性的定义,我们说当且仅当f(a)= f(b)时等于b。
使用关系。 等价关系是E⊆T x T,即:

  • 自反:E(t,t)∀t∈T
  • 对称:E(t,u)⇒E(u,t)
  • 传递:E(t,u)∧E(u,v)⇒E(t,v)
  • 用E作为等价性的定义,当且仅当E(a,b)时,我们会说a等于b。

等价关系:自反,对称,传递
这两个概念是等价的。

  • 等价关系导致抽象函数(关系分区T,因此f将每个元素映射到其分区类)。
  • 抽象函数引发的关系是等价关系。

使用观察

我们可以谈论抽象价值之间的等价性的第三种方式就是外部人(客户)可以观察他们的情况
使用观察。 我们可以说,当两个对象无法通过观察进行区分时,这两个对象是相同的 - 我们可以应用的每个操作对两个对象都产生相同的结果。站在外部观察者角度

就ADT而言,“观察”意味着调用对象的操作。 所以当且仅当通过调用抽象数据类型的任何操作不能区分它们时,两个对象是相等的。

==与equals()

Java有两种不同的操作,用于测试相等性,具有不同的语义。

  • ==运算符比较引用。

它测试引用等价性。 如果它们指向内存中的相同存储,则两个引用是==。 就快照图而言,如果它们的箭头指向相同的对象气泡,则两个引用是==。

  • equals()操作比较对象内容

换句话说,对象等价性。

必须为每个抽象数据类型适当地定义equals操作。在自定义ADT时,需要重写对象的equals()方法

  • 当我们定义一个新的数据类型时,我们有责任决定数据类型值的对象相等是什么意思,并适当地实现equals()操作。

==运算符与equals方法

对于基本数据类型,您必须使用==对基本数据类型,使用==判定相等
对于对象引用类型对象类型,使用equals()

  • ==运算符提供身份语义如果用==,是在判断两个对象身份标识ID是否相等(指向内存里的同一段空间)
  • 完全由Object.equals实现
  • 即使Object.equals已被覆盖,这很少是你想要的!
  • 你应该(几乎)总是使用.equals

重写方法的提示

如果你想覆盖一个方法:

  • 确保签名匹配
  • 使用@Override编译器有你的背部
  • 复制粘贴声明(或让IDE为你做)

不可变类型的等价性

equals()方法由Object定义,其默认含义与引用相等相同。在对象中实现的缺省equals()方法是在判断引用等价性
对于不可变的数据类型,这几乎总是错误的。
我们必须重写equals()方法,将其替换为我们自己的实现。

重写与重载

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

instanceof

instanceof运算符测试对象是否是特定类型的实例。
使用instanceof是动态类型检查,而不是静态类型检查。
一般来说,在面向对象编程中使用instanceof是一种陋习。 除了实施等价性之外,任何地方都应该禁止。
这种禁止还包括其他检查对象运行时类型的方法。

  • 例如,getClass()也是不允许的。

对象契约

对象中equals()的契约

您重写equals()方法时,您必须遵守其总体契约:

  • 等于必须定义一个等价关系
  • 即一种等价关系:自反,传递,对称
  • equals必须一致:对方法的重复调用必须产生相同的结果,前提是没有在对象的等值比较中使用的信息被修改;除非对象被修改了,否则调用多次等于应同样的结果
  • 对于非空引用x,x.equals(null)应返回false;
  • hashCode()必须为等于equals方法的两个对象产生相同的结果。 “相等”的对象,其hashCode()的结果必须一致

Equals契约

equals方法实现等价关系:

  • 自反:对于任何非空参考值x,x.equals(x)必须返回true。
  • 对称:对于任何非空引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
  • 传递:对于任何非空引用值x,y,z,如果x.equals(y)返回true并且y.equals(z)返回true,则x.equals(z)mus返回true。
  • 一致性:对于任何非空引用值x和y,如果修改了在对象上的等值比较中没有使用的信息,则x.equals(y)的多个调用始终返回true或始终返回false。
  • 对于任何非空引用值x,x.equals(null)必须返回false。

equals是所有对象的全局等价关系。

打破等价关系

我们必须确保由equals()实现的等价性定义实际上是一个前面定义的等价关系:自反,对称和传递。

  • 如果不是,那么依赖于等价性的操作(如集合,搜索)将表现出不规律和不可预测的行为。
  • 你不想用一个数据类型进行编程,其中有时等于b,但b不等于a。
  • 会产生微妙而痛苦的错误。

打破哈希表

散列表是映射的表示:将键映射到值的抽象数据类型。

  • 哈希表提供了恒定的时间查找,所以它们往往比树或列表执行得更好。 密钥不必订购,或具有任何特定的属性,除了提供equals和hashCode。

哈希表如何工作:

  • 它包含一个数组,该数组的初始化大小与我们希望插入的元素的数量相对应。
  • 当提供一个键和一个值用于插入时,我们计算该键的哈希码,并将其转换为数组范围内的索引(例如,通过模分割)。 该值然后插入该索引。

哈希表的rep不变量包含密钥在由其哈希码确定的时隙中的基本约束。

散列码的设计使密钥均匀分布在索引上。
但偶尔会发生冲突,并且两个键被放置在相同的索引处。
因此,不是在索引处保存单个值,而是使用哈希表实际上包含一个键/值对列表,通常称为哈希桶。
一个键/值对在Java中被简单地实现为具有两个字段的对象。
插入时,您将一对添加到由散列码确定的阵列插槽中的列表中。
对于查找,您散列密钥,找到正确的插槽,然后检查每个对,直到找到其中的密钥等于查询密钥的对。

hashCode契约

只要在应用程序执行过程中多次调用同一对象时,只要修改了对象的等值比较中未使用的信息,hashCode方法就必须始终返回相同的整数。

  • 该整数不需要从应用程序的一次执行到同一应用程序的另一次执行保持一致。

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

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

但是,程序员应该意识到,为不相等的对象生成不同的整数结果可能会提高散列表的性能。不相等的对象,也可以映射为同样的的hashCode,但性能会变差

相等的对象必须具有相同的散列码

  • 如果你重写equals,你必须重写hashCode

不相等的对象应该有不同的哈希码

  • 构建时考虑所有的值域

除非对象发生变化,否则散列代码不能更改

重写hashCode()

确保合约满足的一个简单而激烈的方法是让hashCode始终返回一些常量值,因此每个对象的散列码都是相同的。

  • 这符合Object合同,但是它会带来灾难性的性能影响,因为每个密钥都将存储在同一个槽中,并且每个查找都会退化为沿着长列表的线性搜索。

标准是计算用于确定相等性的对象的每个组件的哈希代码(通常通过调用每个组件的hashCode方法),然后组合这些哈希码,引入几个算术运算。

打破哈希表

为什么对象合同要求相同的对象具有相同的哈希码?

  • 如果两个相等的对象有不同的哈希码,它们可能被放置在不同的槽中。
  • 因此,如果您尝试使用与插入值相同的键来查找值,则查找可能会失败。

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

重写hashCode()

Java的最新版本现在有一个实用程序方法Objects.hash(),可以更容易地实现涉及多个字段的哈希码。
请注意,如果您根本不重写hashCode(),您将从Object获得一个Object,该Object基于对象的地址。
如果你有等价性的权利,这将意味着你几乎肯定会违反合同

  • 两个相同的对象,一定要有同样的hashcode。

一般规则:
覆盖equals()时总是覆盖hashCode()。

可变类型的等价性

等价性:当两个对象无法通过观察区分时,它们是等价的。

对于可变对象,有两种解释方法:

  • 当它们不能通过不改变对象状态的观察进行区分时,即只通过调用观察者,生产者和创建者方法。这通常被严格地称为观察等价性,因为它在当前的程序状态下测试两个对象是否“看起来”是相同的。

观察等价性:在不改变状态的情况下,两个可变对象是否看起来一致

  • 当他们无法通过任何观察来区分时,即使状态发生变化。这个解释允许调用两个对象的任何方法,包括增变器。这被称为行为等价性,因为它测试这两个对象在这个和所有未来的状态中是否会“表现”相同。

行为等价性:调用对象的任何方法都展示出一致的结果

注意:对于不可变的对象,观察和行为的等价性是相同的,因为没有任何变值器方法。

Java中的可变类型的等价性

对可变类型来说,往往倾向于实现严格的观察等价性

  • Java对大多数可变数据类型(例如Collections)使用观察等价性,但其他可变类(如StringBuilder)使用行为等价性。
  • 如果两个不同的List对象包含相同的元素序列,则equals()报告它们相等。

但是使用观察等价性导致微妙的错误,并且事实上允许我们轻易地破坏其他集合数据结构的代表不变量。但在有些时候,观察等价性可能导致错误,甚至可能破坏RI

这是怎么回事?

List <String>是一个可变对象。 在像List这样的集合类的标准Java实现中,突变会影响equals()和hashCode()的结果。
当列表第一次放入HashSet时,它将存储在当时与其hashCode()结果相对应的哈希桶/散列桶中。
当列表随后发生变化时,其hashCode()会发生变化,但HashSet没有意识到它应该移动到不同的存储桶中。 所以它再也找不到了。
当equals()和hashCode()可能受突变影响时,我们可以打破使用该对象作为关键字的哈希表的不变性。

如果可变对象用作集合元素,必须非常小心。

如果对象的值以影响等于比较的方式更改,而对象是集合中的元素,则不会指定集合的行为。 如果某个可变的对象包含在集合类中,当其发生改变后,集合类的行为不确定,务必小心
不幸的是,Java库对于可变类的equals()的解释并不一致。 集合使用观察等价性,但其他可变类(如StringBuilder)使用行为等价性。 在JDK中,不同的mutable类使用不同的等价性标准...

从这个例子中学到的经验教训

equals()对可变类型,实现行为等价性即可

通常,这意味着两个引用应该是equals()当且仅当它们是同一个对象的别名。也就是说,只有指向同样内存空间的对象,才是相等的。
所以可变对象应该继承Object的equals()和hashCode()。 对可变类型来说,无需重写这两个函数,直接继承Object对象的两个方法即可。
对于需要观察等价性概念的客户(两个可变对象在当前状态下“看起来”是否相同),最好定义一个新方法,例如similar()。

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

对于不可变类型:

  • equals()应该比较抽象值。 这与equals()应该提供行为等价性相同。
  • hashCode()应该将抽象值映射到一个整数。
  • 所以不可变类型必须覆盖equals()和hashCode()。

对于可变类型:

  • equals()应该比较引用,就像==一样。 同样,这与等价性()应该提供行为等价性一样。
  • hashCode()应该将引用映射为一个整数。
  • 所以可变类型不应该重写equals()和hashCode(),而应该简单地使用Object提供的默认实现。 不幸的是,Java不遵循这个规则,导致我们上面看到的陷阱。

对象中的clone()

clone()创建并返回此对象的副本。
“拷贝复制”的确切含义可能取决于对象的类别。
一般意图是,对于任何对象x:

x.clone() != x
x.clone().getClass() == x.getClass() 
x.clone().equals(x)

自动打包和等价性

基本类型及其对象类型等价性,例如int和Integer。
如果您创建两个具有相同值的Integer对象,则它们将相互为equals()。
但是如果x == y呢?
-----错误(因为引用等价性)
但是如果(int)x ==(int)y呢?
-----正确

总结

等价性是实现抽象数据类型(ADT)的一部分。

  • 等价性应该是一种等价关系(反身,对称,传递)。
  • 相等和散列码必须相互一致,以便使用散列表(如HashSet和HashMap)的数据结构能够正常工作。
  • 抽象函数是不变数据类型中等式的基础。
  • 引用等价性是可变数据类型中等价性的基础; 这是确保随时间的一致性并避免破坏散列表的不变式的唯一方法。

减少错误保证安全

  • 使用集合数据类型(如集合和地图)需要正确实现相等和散列码。 编写测试也是非常理想的。 由于Java中的每个对象都继承了Object实现,所以不可变类型必须重写它们。

容易明白

  • 读过我们规范的客户和其他程序员会希望我们的类型实现适当的等价性操作,如果我们不这样做,会感到惊讶和困惑。

准备好改变

  • 为不可变类型正确实施的等价性将参考等价性与抽象价值的等价性分开,从客户身上隐藏我们是否共享价值的决定。 选择可变类型的行为而不是观察等价性有助于避免意外的别名错误。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值