Lec 10 相等性
在之前的学习中,我们通过创建由操作而非表示来定义的类型,建立了数据抽象的严格概念。对于一个ADT,抽象函数说明了如何把一个具体的表示值解释为抽象类型中的值;我们也看到,抽象函数的选择决定了如何编写该 ADT 各个操作的实现代码。
在本节中,我们将讨论如何定义数据类型中“值的相等”这一概念:抽象函数将为我们提供一种清晰的方式来定义 ADT 的相等操作。
在物理世界中,每个对象都是不同的——即使是看似完全相同的两片雪花,也在空间中的位置或微观结构上存在差异。因此,从物理意义上讲,两个物体从未真正“相等”;它们只是“相似程度”不同而已。
但在人类语言世界和数学概念世界中,我们可以有多个名称指代同一个对象。因此,我们自然会问:何时两个表达式代表“同一个东西”?例如,1+2、2+1 和 3 都表示同一个理想的数学值。
本节的第一部分将重点讨论不可变类型的相等性定义——这样我们可以在没有“状态变化”的干扰下理解核心概念。随后,我们再扩展到可变类型的相等性讨论。
哈希函数
在大多数语言中(包括 TypeScript 和 Python),Set 和 Map 类型都是用哈希表(hash table)实现的,它能快速查找集合元素或映射键。哈希表要求集合元素类型或映射键类型提供一个哈希函数操作,用于把对象值转换为一个整数。
本节讨论哈希函数与相等性(equality)之间的相互关系。由于 TypeScript 不允许你自己实现哈希函数,我们将使用 Python 进行说明。
在 Python 中,对象类型的相等性操作是通过 __eq__(..) 实现的,类似于我们在 TypeScript 中定义的 equalValue()。哈希函数操作则由 __hash__() 实现:
def __hash__(self):
'''
由内置函数 hash() 调用,也用于集合(set、frozenset)和字典(dict)等需要哈希的场景。
__hash__() 应返回一个整数。
唯一的要求是:相等的对象必须具有相同的哈希值。
'''要理解为什么 __hash__ 必须与 __eq__ 保持一致,你需要了解哈希表的工作原理。Python 的集合(set)和字典(dict)使用哈希表这种数据结构,它们依赖 __hash__ 方法的正确实现来存储对象和查找键。
哈希表是一种“映射”(mapping)的表示形式:这是一种抽象数据类型,用于将键映射到值。哈希表支持常数时间的查找,因此通常比树或列表表现更好。键不需要有序,也不需要具备任何特殊属性,只要能进行相等性比较和哈希运算即可。
哈希表的工作原理如下:它包含一个数组,该数组的大小根据预期要插入的元素数量进行初始化。当插入一个键值对时,程序会计算键的哈希值(hash code),并将其转换为数组索引(例如通过取模运算)。然后把该值插入到对应的索引位置。
哈希表的表示不变式(rep invariant)包含一个基本约束:键必须能从其哈希值所对应的数组槽(array slot)中被找到。
哈希码的设计目标是让键在数组索引上尽可能均匀分布。但有时会发生冲突——即两个键被映射到同一个索引位置。因此,哈希表在每个索引位置通常存放一个“哈希桶”(hash bucket),也就是一个键/值对的列表。插入时,将新的键值对加入到对应槽位的列表中;查找时,计算键的哈希值,找到对应槽位,然后依次检查列表中的键,直到找到一个等于查询键的项为止。
现在可以理解,为什么 __hash__ 的规范要求相等的对象必须有相同的哈希值。如果两个相等的对象拥有不同的哈希值,它们可能被放入不同的槽位。这样,当你使用一个“相等”的键去查找时,就可能找不到原本插入的值。
一种极端但满足规范的做法是让 __hash__ 始终返回一个常数值,这样所有对象的哈希值都一样。 这种方式虽然正确,但性能会极差,因为所有键都会落在同一个槽位上,查找会退化为沿着长列表的线性搜索。
一个更合理的标准做法是: 对决定对象相等性的每个组成部分分别计算哈希值(在 Python 中通过 hash() 实现),然后将这些结果通过一些算术运算组合起来。
(在 Java 中,这个操作叫做 hashCode。Josh Bloch 的《Effective Java》对此进行了详细说明,并给出编写高质量哈希函数的策略。相关建议在 StackOverflow 上也有总结。)
不过要注意,只要你满足“相等对象必须有相同哈希值”这一要求,那么具体采用哪种哈希算法,对代码的正确性并没有影响。 不同算法可能会影响性能(例如产生过多的哈希冲突),但即使性能差的哈希函数,也比违反规范的哈希函数要好。
因此,在像 Python 或 Java 这样允许你自定义哈希和相等操作的语言中:一定要让哈希函数与相等性保持一致。
>>> l1 = ['a','b']
>>> l2 = ['a']
>>> s = { l1, l2 } # not actually legal, but suppose Python did allow it...
>>> s
{ ['a','b'], ['a'] }
>>> l2.append('b')
>>> s
{ ['a','b'], ['a','b'] }我们显然破坏了某些东西,因为集合(set)本不应该包含重复元素,但现在这个集合里出现了两个相同的列表。而且,更糟糕的还在后面。让我们试着删除其中一个元素:
>>> s.remove(l1)
>>> s
{ ['a','b'] }很好,看起来成功了。那么我们能删除另一个吗?
>>> s.remove(l2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: ['a','b']KeyError?集合居然找不到 l2 了!这是怎么回事?
原因在于列表是可变的(mutable)。在 Python 中,__eq__() 实现的是观测相等(observational equality),而我们假设存在的列表哈希函数 __hash__() 必须与之保持一致。当 l2 第一次被放入集合时,它会根据当时的值(['a'])计算哈希值,并存入相应的哈希桶中。之后,当我们修改列表为 ['a','b'] 时,它的哈希值很可能发生了变化(因为相等性也随之改变),但集合并不知道它应该被移到另一个哈希桶里。 结果,这个列表仍然“藏”在旧的哈希桶中——打印整个集合时(因为会遍历所有元素)仍能看到它,但集合无法根据它的新哈希值准确地定位到它,因此在执行 remove(l2) 时会失败。
当对象的相等性(equality)和哈希值(hash)可能被修改时,我们就可能破坏哈希表的表示不变式(rep invariant)。哈希表的不变式要求:每个键都必须能在与其 __hash__() 值对应的哈希桶中被找到。 如果对象的 __hash__() 值能随对象的变动而改变,那么这一不变式就会被破坏。
这个例子是一个假想情景,用来说明为什么对于像列表这样的可变类型,Python 明确禁止实现 __hash__():如果一个类表示可变对象,并且实现了 __eq__() 方法,那么它不应实现 __hash__(), 因为可哈希集合(如 set、dict)的正确性要求键的哈希值必须是不可变的。 如果对象的哈希值能变,那它就可能被放错哈希桶。
Python 之所以有这个限制,是因为:
__eq__()实现的是观测相等(observational equality),而这种相等性在可变类型中可能随时间改变;- 而
__hash__()必须保持稳定,否则哈希表的结构就会被破坏。
因此——哈希函数必须基于行为相等性(behavioral equality)来实现,以确保哈希值不会随时间改变。
对于不可变类型(immutable types),观测相等与行为相等是一致的,所以 Python 只允许对不可变类型进行哈希。
TypeScript/JavaScript 中的哈希
在 TypeScript 和 JavaScript 中,哈希函数是内置的,目前无法被用户为自定义类型重写。 内置哈希函数的设计与 === 一致,因此只要一个类型使用 === 来表示行为相等性(behavioral equality),就可以安全地作为 Set 的元素或 Map 的键。
这意味着,Set 和 Map 的键可以是不可变的内置类型(如 number、string),也可以是可变的对象类型(使用引用相等性)。 这些类型的内置哈希函数都与 === 一致。
然而,一个问题是: 我们无法轻易将不可变对象类型作为 Set 元素或 Map 键使用,因为 TypeScript 的 Set 和 Map 并没有提供自定义相等操作或哈希函数的方式。 而 === 对不可变对象类型是错误的(它比较的是引用,而不是内容)。
有几种应对方法:
- Interning模式 这是一种设计模式,保证程序中每个抽象值只有一个对象实例。 当需要一个相同值的对象时,直接复用已有的实例。 这样,观测相等性就等价于引用相等性,而对象的哈希函数(基于引用)也能正确地工作。 实现这种模式通常需要一个缓存来存储已创建的对象,以及一个在创建前先查询缓存的工厂函数。
- 定义规范化的原始类型表示 为类型的每个值定义一个唯一的原始类型表示。 例如,
Date对象可以通过转换为时间戳或标准化字符串来安全地用作Map键。 - 使用第三方库 使用支持自定义相等性和哈希函数的集合库,例如
collections.js或Immutable。
Python和TypeScript以及JavaScript相等性
| 类型种类 | Python | TypeScript / JavaScript |
|---|---|---|
不可变原始类型 (int, float, str / number, string, bigint) | == 表示值相等(可观察且行为一致) 可哈希(hashable) | === 表示值相等(可观察且行为一致) 可哈希(hashable) |
可变集合类型 (list, dict, set / Array, Map, Set) | == 表示值相等(可观察) is 表示引用相等(行为) 不可哈希(因为 == 不是行为性的) | 没有内建的值相等(只能靠第三方库) === 表示引用相等(行为) 可哈希(因为 === 是行为性的) |
不可变集合类型 (tuple, frozenset) | == 表示值相等(可观察且行为一致) 可哈希(因为 == 是行为性的) | 没有内建的不可变集合类型 (想想为什么 ReadonlyArray 不算真正的不可变类型?) |
不可变用户自定义类型 例如 Duration | == 表示值相等(可观察且行为一致) 可哈希(如果正确实现 __eq__ 与 __hash__) | equalValue() 表示值相等(可观察且行为一致) 不可哈希(因为 === 比较引用) |
可变用户自定义类型 例如 Bag | == 表示值相等(可观察) is 表示引用相等(行为) 不可哈希(因为 == 不是行为性的) | equalValue() 表示值相等(可观察) === 表示引用相等(行为) 可哈希(因为 === 是行为性的) |