软件构造之等价性

介绍

对于抽象数据类型,抽象函数解释了如何将具体表示值解释为抽象类型的值,并且抽象函数的选择决定我们如何编写实现每个ADT操作的代码。

三种判断等价方式

从形式上讲,我们可以通过几种方式来观察等价性。

使用抽象函数。抽象函数fR→A将数据类型的具体实例映射到它们相应的抽象值。 我们可以使用抽象函数f作为等价的定义,a等价于b当且仅当fa= fb)。

使用关系 等价关系E是二元关系 T X T 的一个子集它是

自反的Ett对任意的 tT

对称的:Etu可以得到Eut

传递的:Etu)并且 Euv)可以得到Etv

使E作为等价关系的定义,我们可以说ab当且仅当Eab)。

使用观察当两个对象无法通过观察进行区分时,我们可以说,这两个对象是相同的。我们可以应用的每个操作对两个对象都会产生相同的结果。 考虑集合表达式{1,2}{2,1}。使用可用于集合的观察者操作,如基数| ... | 和成员资格,下面这些表达式是难以区分的:

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

== vs. equals()

像许多语言一样,Java有两种不同的操作,用于判断两个对象是否等价

==运算符比较引用。 更确切地说,它测试了引用等价性。如果它们指向内存中的相同存储,则两个引用是==。 在快照图里面,如果它们的箭头指向相同的对象气泡,则两个引用是==

equals()操作比较对象内容换句话说,就是意义上的对象相等。

Java中,==总是意味着引用相等。但是,当我们定义一个新的数据类型时,我们有责任决定哪些对象相等对于数据类型的值意味着什么,并适当地实现equals()操作。

不可变类型的等价性

equals()方法由Object定义,其默认实现如下所示:

换句话说,equals()的默认含义与引用相等相同。 对于不可变的数据类型,这几乎总是错误的。所以必须重写equals()方法,用自己的实现代替它。

下边是一个例子:

这里有一个微妙的问题。 当我们测试下边的代码时,equals不起作用。

尽管d2o2最终引用了内存中相同的对象,但从equals()中得到的结果是不一样的,这是因为Duration已经重载了equals()方法,因为方法签名与Object的不一样,因此我们在Duration中有两个equals()方法:从Object继承的隐式equalsObject),以及新的equalsDuration)。

如果我们传递一个Object对象,就像在d1.equalso2)中那样,我们最终调用equalsObject。如果我们传递一个Duration对象,就像在d1.equalsd2)中一样,我们最终调用equalsDuration)版本。即使o2d2都在运行时指向同一个对象,也会出现不同的结果。

下边是一个正确的equals的实现方法

在上边的实现代码中,我们使用了instanceof运算符测试对象是否是特定类型的实例。 instanceof是动态类型检查,并不是我们偏好的静态类型检查。因此,除了实现equals之外,instanceof在其他地方是不允许的。 这种禁止还包括其他检查对象运行时类型的方法。 例如,getClass。

对象合同

我们必须确保由equals()实现的等价定义是一个等价关系:自反,对称和传递。如果不是,那么依赖于等价性的操作将表现出不规律和不可预测的行为。


要理解与hashCode方法有关的合同部分,需要了解哈希表如何工作 哈希表是映射的一种表示:将键映射到值的抽象数据类型。它包含一个数组,该数组的初始化大小对应于我们希望插入的元素的数量。当一个键和一个值被提供用于插入时,我们计算键的哈希码,并将其转换为数组范围内的索引,然后将该值插入该索引处。


哈希码的设计使键的值均匀分布在索引上。但偶尔会发生冲突,例如两个键被放置在相同的索引处。 因此,哈希表并不是在索引处保存单个值,而是保存着一个键/值对列表。插入时,将需要插入的键值对添加到由散列码确定的列表中。查找时,根据散列key值,找到正确的列表,然后检查每个键值对,直到找到其中的key等于查询key的键值对。


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

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

对于引用ab,如果a== b,那么a的地址== b的地址。所以对象合同是满足的。

但不可变对象需要不同的hashCode()实现。 对于Duration,因为我们还没有重写默认的hashCode(),所以当我们执行下段代码时,会出现错误的结果

d1d2等价的,但它们有不同的哈希码我们需要解决这个问题。


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

Java的最新版本现在有一个实用程序方法Objects.hash(),可以更容易地实现涉及多个字段的哈希码。注意,只要满足相同对象具有相同哈希码值的要求,那么使用的特定哈希技术并不会影响代码的正确性。它可能会通过在不同对象之间产生不必要的冲突而影响其性能,但即使是性能不佳的散列函数也比破坏合同的效果要好。重要的是,如果根本不重写hashCode,则会从Object获得一个基于对象的地址的Object,如果重写了equals,这将意味着你会违反合同。因此,作为一般规则:重写equals时总是重写hashCode

可变类型的等价性

当两个对象无法通过观察进行区分时,它们是相同的。对于可变对象,有两种解释方法:

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

行为等价。调用两个对象的任何方法,包括mutator,都无法区分两个对象。这通常被称为行为平等,因为它测试这两个对象在这个和所有未来的状态中是否会表现相同。


对于不可变对象,观察行为和行为平等是相同的,因为没有任何mutator方法。


对于可变对象,实施严格的观察平等是诱人的。事实上,Java对大多数可变数据类型都使用观察平等。如果两个不同的List对象包含相同的元素序列,则equals()会报告它们相等。

但是使用观察平等导致微妙的错误

我们可以检查该集合是否包含我们放入的列表

但是现在我们改变列表

结果它不在出现在集合中

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

这是怎么回事? List<String>是一个可变对象。在像List这样的集合类的标准Java实现中,突变会影响equals()和hashCode()的结果。当列表首先放入HashSet中时,它将存储在当时与其hashCode()结果相对应的哈希桶中。当列表随后发生变化时,其hashCode()会发生变化,但HashSet没有意识到它应该移动到不同的存储桶中。所以它再也找不到了。

从这个例子中,我们应该知道,equals()应该实现行为平等。通常,这意味着两个引用应该是equals()当且仅当它们是同一个对象的别名。所以可变对象应该继承Objectequals()和hashCode()。

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

对于不可变类型:

equals()应该比较抽象值。 也就是说对象是行为等价的

hashCode()应该将抽象值映射为一个整数。

所以不可变类型必须重写equals()和hashCode()。

对于可变类型:

equals()应该比较两个引用是否指向同一个内存地址,就像==一样。

hashCode()应该将引用映射为一个整数。

所以可变类型不应该重写equals()和hashCode(),而应该简单地使用Object提供的默认实现。 不幸的是,Java不遵循这个规则,导致我们上面看到的陷阱。


总结

等价应该是一个等价关系(自反,对称,传递)。

等价和哈希码必须相互一致,以便使用哈希表(如HashSetHashMap)的数据结构可以正常工作。

抽象函数是不变数据类型中等价的基础。

引用等价是可变数据类型中等价的基础;这是确保随时间的一致性并避免破坏散列表的不变式的唯一方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值