散列与比较协议

Python进阶编程专题 · 理解__hash__与__eq__的契约关系

专题:Python进阶编程系统学习

关键词:Python, __hash__, __eq__, 散列, 比较, total_ordering, hashable, 不可变

一、概述:为什么需要散列与比较协议

Python 是一门高度依赖协议(Protocol)的动态语言。所谓协议,就是一组特殊方法(dunder methods)的约定集合,对象通过实现这些方法来获得某种语言能力。在所有协议中,散列协议(Hash Protocol)和比较协议(Comparison Protocol)是最基础也是最重要的两个。前者决定了对象能否作为字典的键或集合的元素,后者决定了对象之间如何进行排序和相等性判断。

这两者并非互相独立——恰恰相反,Python 语言规范对它们之间存在一条严格的约束:如果两个对象相等(__eq__ 返回 True),它们的散列值(__hash__ 的返回值)必须相同。这条规则是哈希表(dict、set 等)能够正确工作的数学基础。违反这条规则不会立即报错,但会导致数据结构出现逻辑错误——例如在集合中查不到明明已经添加进去的元素,或者同一个对象在字典中出现多次。

本讲将系统性地剖析这两个协议的设计原理、实现细节和最佳实践,帮助你在日常开发中编写出正确、高效、可维护的 Python 代码。

核心要点:Python 的 dict 和 set 底层依赖哈希表实现。哈希表通过散列函数将键映射到存储槽位,当发生碰撞时,再通过相等性比较来区分不同键。因此,hash 和 eq 必须协同工作——hash 决定"去哪里找",eq 决定"找到的是不是它"。

二、__hash__ 与 __eq__ 的契约关系

Python 语言规范对 __hash__ 和 __eq__ 的关系给出了明确约束,理解这一契约是掌握散列协议的关键。

2.1 契约的数学表述

用形式化的语言来描述,这一契约可以表达为以下三条规则:

如果违反第二条规则——即两个相等的对象散列值不同——会导致极其隐蔽的 bug。下面看一个典型的错误示例:

class BadPoint: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, BadPoint): return NotImplemented return self.x == other.x and self.y == other.y # 故意不定义 __hash__,导致 Python 使用默认的基于 ID 的散列 p1 = BadPoint(1, 2) p2 = BadPoint(1, 2) print(p1 == p2) # True —— 内容相等 print(hash(p1)) # 123456 (示例值,基于对象 ID) print(hash(p2)) # 789012 (不同 ID,不同散列值) s = {p1} print(p2 in s) # False !!! 虽然 p1 == p2,但散列值不同 # 哈希表根据 hash(p2) 找到了不同的槽位,然后发现该槽位为空 # p2 明明在逻辑上"等于"集合中的元素,却找不到

这个例子揭示了违反契约的严重后果。当你自定义了 __eq__ 但没有同时定义 __hash__ 时,Python 会将 __hash__ 设为 None(在 Python 3 中),从而禁止将该对象用作字典键或集合元素。但如果基类定义了 __hash__,而子类只覆盖了 __eq__,那么就会在运行时静默地产生上述 bug。

2.2 默认行为:当你不定义任何方法时

Python 的 object 基类提供了默认的 __hash__ 和 __eq__ 实现,它们都基于对象的内存地址(ID)。这意味着:

# 默认行为:基于对象 ID a = object() b = object() print(a == b) # False —— ID 不同 print(hash(a) == hash(b)) # 几乎肯定 False —— ID 不同 # 默认行为完全满足契约:a == b 为 False,hash 不同不违反任何规则 # 实际上,只有当 a is b(即同一个对象)时,hash 才相同

这其实是完全正确的行为——默认实现遵守了契约。问题只出现在你部分重写这些方法时。下表总结了各种场景下的默认行为:

定义了 __eq__ 定义了 __hash__ 结果
默认基于 ID 的 hash 和 eq。可哈希,不可修改外部比较行为。
__hash__ 被设为 None。对象变为不可哈希(unhashable)。
极少见,但不违法。__eq__ 继承自 object 的基于 ID 的比较。
完全自定义。注意保证契约一致。

注意:Python 3 中,如果定义了 __eq__ 但没有定义 __hash__,__hash__ 会被隐式设为 None。这是 Python 3 的安全机制——宁可禁止对象被哈希,也不能让程序员在不知情的情况下违反契约。

三、为什么可变对象不能定义 __hash__

这是一个经常被问到的面试题,也是 Python 设计的核心原则之一。要理解这个问题,我们首先需要理解哈希表的一个基本要求:键的散列值在其生命周期内不能改变

3.1 问题根源:可变性导致散列值变化

假设我们将一个可变对象放入字典作为键,然后修改了这个对象的内容。由于 __hash__ 通常基于对象的内容计算,修改内容会导致散列值发生变化。但此时该对象已经在字典的哈希表中占据了某个槽位——散列值变了,Python 却不会去更新哈希表。结果就是再也无法通过该对象找到对应的值了。

# 假设 list 是可哈希的(实际上不是) d = {} key = [1, 2, 3] # 假设 d[key] = "value" 能工作 key.append(4) # 修改内容,hash 值变了 # d[key] 现在会引发 KeyError!或者更糟——返回错误的值 # Python 通过让 list 不可哈希来彻底杜绝这个问题: # hash([1, 2, 3]) # TypeError: unhashable type: 'list'

Python 的设计选择是:如果一个类型是可变(mutable)的,就不应该定义 __hash__。这确保了一个对象的散列值在其整个生命周期内保持稳定。

标准库中的例子非常清晰地展示了这一原则:

例外与陷阱:tuple 虽然是不可变的,但如果它包含可变对象,那么 tuple 的散列值在理论上仍然可能不安全。例如 t = ([1, 2], 3) 中的列表可以被修改。但 Python 仍然允许 tuple 定义 __hash__,只是要求在计算散列值时只调用元素的 __hash__——而可变元素(list)的 __hash__ 为 None,所以这种 tuple 在运行时计算 hash(t) 会抛出 TypeError。这是一种"延迟检查"的策略。

3.2 如何为"逻辑上不可变"的自定义类定义 __hash__

对于自定义类,如果它在设计上是不可变的——即初始化后所有属性都不再改变——那么就可以安全地定义 __hash__。标准的做法是:

class ImmutablePoint: def __init__(self, x, y): self._x = x self._y = y @property def x(self): return self._x @property def y(self): return self._y def __eq__(self, other): if not isinstance(other, ImmutablePoint): return NotImplemented return (self._x, self._y) == (other._x, other._y) def __hash__(self): return hash((self._x, self._y)) def __repr__(self): return f"ImmutablePoint({self._x}, {self._y})"

这个模式的关键在于:只提供 getter(通过 @property),不提供 setter,并且 __hash__ 的计算使用了一个绝对可靠的元组。这种做法完全符合契约:

四、常见的 hash/eq 实现模式

根据实际需求的不同,__hash__ 和 __eq__ 的实现可以分为三种经典模式。理解它们的适用场景能帮助我们在设计和编码时做出正确的选择。

4.1 基于 ID 的模式(默认模式)

这是 object 基类的默认行为,适用于那些不需要值相等性比较的类。每个对象都是唯一的,即使内容完全相同的两个实例也被视为不同。

class UserSession: """每个会话实例都是唯一的,即使包含相同数据""" def __init__(self, user_id, token): self.user_id = user_id self.token = token # 不定义 __eq__ 和 __hash__,使用默认的基于 ID 的实现 s1 = UserSession(1, "abc") s2 = UserSession(1, "abc") # 相同数据,但不同对象 sessions = {s1: "活跃"} print(s2 in sessions) # False —— 符合预期:不同会话即使数据相同也不同

这种模式适用于:实体类、服务类、连接池、会话管理等场景,其中身份标识由对象 ID 而非内容决定。

4.2 基于值的模式

这是最常见的模式,适用于值对象(Value Object)。两个对象只要具有相同的"值",就视为相等。这也是需要同时重写 __eq__ 和 __hash__ 的场景。

class Color: def __init__(self, r, g, b): self.r = r self.g = g self.b = b def __eq__(self, other): if not isinstance(other, Color): return NotImplemented return (self.r, self.g, self.b) == (other.r, other.g, other.b) def __hash__(self): return hash((self.r, self.g, self.b)) def __repr__(self): return f"Color({self.r}, {self.g}, {self.b})" # 使用示例 red1 = Color(255, 0, 0) red2 = Color(255, 0, 0) color_map = {red1: "红色"} print(color_map[red2]) # "红色" —— 基于值的查找正常工作 print({red1, red2}) # {Color(255, 0, 0)} —— 只有一个元素,没有重复

实现时的关键技巧:将多个属性打包为元组再计算散列值。这样做既简洁又正确,因为 tuple 的 __hash__ 实现是经过精心设计的,可以很好地避免碰撞。切勿自己实现散列算法(如 r + g + b 或 r ^ g ^ b),这会导致大量碰撞,严重降低哈希表性能。

4.3 混合模式(部分基于值)

有些场景需要部分属性参与相等性判断,部分属性不参与。例如,一个带缓存的实体对象,其"业务主键"决定相等性,而其他字段(如缓存时间戳)不参与。

from datetime import datetime class CachedUser: def __init__(self, user_id, name, cached_at=None): self.user_id = user_id # 业务主键 self.name = name self.cached_at = cached_at or datetime.now() # 缓存时间,不参与相等性 def __eq__(self, other): if not isinstance(other, CachedUser): return NotImplemented # 只比较业务主键 return self.user_id == other.user_id def __hash__(self): # 只对业务主键计算散列 return hash(self.user_id) def __repr__(self): return f"CachedUser({self.user_id}, '{self.name}', cached={self.cached_at})" # 演示 u1 = CachedUser(1, "Alice", datetime(2025, 1, 1)) u2 = CachedUser(1, "Alice Updated", datetime(2025, 6, 1)) print(u1 == u2) # True —— 相同 user_id print({u1, u2}) # 集合中只有一个元素 print(hash(u1) == hash(u2)) # True —— 契约满足

警告:混合模式需要极其小心。如果参与 __eq__ 的属性集合与参与 __hash__ 的属性集合不一致,虽然不违反"相等对象散列值相同"的契约(因为 __eq__ 是 __hash__ 的超集判断),但可能导致违反直觉的行为。务必确保 __hash__ 的计算粒度"粗于或等于"__eq__ 的判断粒度。

4.4 三种模式对比

模式 __eq__ 实现 __hash__ 实现 适用场景
基于 ID 不定义(默认 is 比较) 不定义(默认基于 ID) 实体、服务、会话
基于值 比较所有值属性 hash((attr1, attr2, ...)) 值对象、DTO、配置项
混合模式 比较部分关键属性 hash(关键属性) 有缓存的实体、ORM 模型

五、@functools.total_ordering 自动补全比较方法

Python 的比较协议包括六个方法:__lt__、__le__、__eq__、__ne__、__gt__、__ge__。手动实现全部六个方法既繁琐又容易出错。@functools.total_ordering 装饰器可以自动补全缺失的比较方法

5.1 基本原理

total_ordering 的工作原理基于数学上的"全序关系"(Total Ordering)。你只需要定义 __eq__ 和其余五个中的任意一个(通常选 __lt__),装饰器就会自动推导出其他四个方法。推导逻辑如下:

from functools import total_ordering @total_ordering class Version: def __init__(self, major, minor, patch): self.major = major self.minor = minor self.patch = patch def __eq__(self, other): if not isinstance(other, Version): return NotImplemented return (self.major, self.minor, self.patch) == \ (other.major, other.minor, other.patch) def __lt__(self, other): if not isinstance(other, Version): return NotImplemented return (self.major, self.minor, self.patch) < \ (other.major, other.minor, other.patch) def __repr__(self): return f"v{self.major}.{self.minor}.{self.patch}" # 现在所有比较操作都可用 v1 = Version(2, 1, 0) v2 = Version(3, 0, 0) print(v1 < v2) # True —— 自定义 print(v1 <= v2) # True —— 自动推导:v1 < v2 or v1 == v2 print(v1 > v2) # False —— 自动推导:v2 < v1 为 False print(v1 >= v2) # False —— 自动推导:v2 < v1 or v1 == v2 print(v1 != v2) # True —— 自动推导:not (v1 == v2)

5.2 total_ordering 的性能考量

total_ordering 虽然方便,但有一个性能上的代价:每个自动推导的方法都会增加一次额外的函数调用。例如,调用 __le__ 会先调用 __lt__ 再调用 __eq__。在大规模排序场景中,这可能产生可观测的性能差异。

import time from functools import total_ordering # 手动实现全部方法的版本 class ManualVersion: def __init__(self, major, minor, patch): self._key = (major, minor, patch) def __eq__(self, other): return self._key == other._key def __lt__(self, other): return self._key < other._key def __le__(self, other): return self._key <= other._key def __gt__(self, other): return self._key > other._key def __ge__(self, other): return self._key >= other._key def __ne__(self, other): return self._key != other._key # total_ordering 装饰的版本 @total_ordering class DecoratedVersion: def __init__(self, major, minor, patch): self._key = (major, minor, patch) def __eq__(self, other): return self._key == other._key def __lt__(self, other): return self._key < other._key # 性能测试:对 10000 个对象排序 import random items_m = [ManualVersion(random.randint(1,10), 0, 0) for _ in range(10000)] items_d = [DecoratedVersion(random.randint(1,10), 0, 0) for _ in range(10000)] t0 = time.perf_counter() sorted(items_m) t1 = time.perf_counter() sorted(items_d) t2 = time.perf_counter() print(f"Manual: {t1-t0:.4f}s") print(f"Decorated: {t2-t1:.4f}s") # 差异通常在 5-15% 之间,对于大多数应用可以忽略

实践建议:对于绝大多数应用场景,total_ordering 的性能开销可以忽略不计。优先使用装饰器来保证正确性和可维护性。只有当性能分析(profiling)确认为瓶颈时,才考虑手动实现全部六个方法。

5.3 使用 total_ordering 的注意事项

有几个容易踩的坑需要特别注意:

# 继承场景下需要重新应用装饰器 @total_ordering class Base: def __init__(self, value): self.value = value def __eq__(self, other): return self.value == other.value def __lt__(self, other): return self.value < other.value # 子类重写了 __eq__,但继承了 __lt__,这会导致不一致 # 正确做法:子类也应用 @total_ordering @total_ordering class Sub(Base): def __init__(self, value, extra): super().__init__(value) self.extra = extra def __eq__(self, other): if not isinstance(other, Sub): return NotImplemented return self.value == other.value and self.extra == other.extra # __lt__ 继承自 Base,只比较 value # 由于重新应用了 @total_ordering,推导出的 __gt__/__le__/__ge__ 会与 # self.value < other.value 保持一致,不再依赖 __eq__

六、frozenset / dict 键对 hashable 的要求

Python 中"可哈希"(hashable)是一个类型的重要属性。一个对象可哈希,意味着它可以作为字典的键、放入集合(set)或作为 frozenset 的元素。理解 hashable 的精确定义和边界情况,对编写正确的 Python 代码至关重要。

6.1 什么是 hashable

Python 官方文档对 hashable 的定义包含两个条件:

所有 Python 内置的不可变类型(str、int、float、tuple、frozenset、bytes)都是可哈希的。所有内置的可变类型(list、dict、set、bytearray)都是不可哈希的。

6.2 frozenset 的特殊性

frozenset 是 Python 中一个有趣的边界案例:本身是不可变的,因此是可哈希的。但它的元素也必须是可哈希的。更关键的是,frozenset 的 __hash__ 实现经过了精心设计——假设两个 frozenset 相等,它们的散列值一定相同。

# frozenset 可以作为字典的键 d = { frozenset({"a", "b"}): "group1", frozenset({"c", "d"}): "group2", } print(d[frozenset({"b", "a"})]) # "group1" —— 集合无序,但相等判断正确 # frozenset 包含不可哈希的元素会怎样? try: fs = frozenset([[1, 2], [3, 4]]) # list 不可哈希 except TypeError as e: print(e) # unhashable type: 'list' # 嵌套 frozenset 完全没问题 fs1 = frozenset({1, 2}) fs2 = frozenset({3, 4}) fs_of_fs = frozenset({fs1, fs2}) # 完全合法 print(fs_of_fs) # frozenset({frozenset({1, 2}), frozenset({3, 4})})

6.3 dict 键:不仅仅是 hashable

虽然 Python 只要求字典的键是 hashable 的,但实际使用中还有两个重要考量:

第一,键的类型一致性。混合类型的键虽然技术上可行,但可能导致令人困惑的行为:

# 混合类型的键 —— 技术上合法,但实践中要避免 d = { 1: "整数1", "1": "字符串1", (1,): "元组1", } print(d[1]) # "整数1" print(d["1"]) # "字符串1" print(d[(1,)]) # "元组1" # 更隐蔽的问题:bool 是 int 的子类 d = {True: "yes", 1: "no"} print(d) # {True: "no"} —— True 和 1 散列值相同且相等,后者覆盖了前者

第二,散列冲突的性能影响。当大量键的散列值相同时,哈希表的性能会从 O(1) 退化到 O(n)。

# 散列冲突示例 —— 人为构造的糟糕散列 class BadHash: def __init__(self, value): self.value = value def __hash__(self): return 0 # 所有对象散列到同一个槽位 def __eq__(self, other): return self.value == other.value # 插入 1000 个对象到字典 d = {} for i in range(1000): d[BadHash(i)] = i # 查找时,每次都要遍历整个链表(实际上 Python 用开放寻址,效果类似) # 性能从 O(1) 退化到 O(n) import time t0 = time.perf_counter() _ = d[BadHash(500)] t1 = time.perf_counter() print(f"BadHash 查找耗时: {(t1-t0)*1000:.2f}ms") # 相比之下,正常散列函数的查找不到 1 微秒

思考题:为什么 Python 的 dict 在 Python 3.7+ 中会保持插入顺序?这与 hashable 的设计有任何关系吗?答案:插入顺序的保持是通过额外的双向链表实现的,与哈希表的核心查找机制完全独立。hashable 协议保证的是"能不能放进去",而插入顺序保证的是"放进去后能不能按顺序取出来"。

七、完整比较协议:__lt__ / __gt__ / __le__ / __ge__

除了 __eq__ 和 __hash__ 之外,Python 的比较协议还包含四个用于排序的方法。它们在标准库(如 sorted()、bisect、heapq)和自定义排序场景中发挥着关键作用。

7.1 比较方法的返回值协议

每个比较方法都应该返回一个布尔值,或者返回 NotImplemented 特殊常量来表示"我不知道如何与这个类型的对象比较"。这个协议比很多人想象的要更加细致:

class Number: def __init__(self, value): self.value = value def __lt__(self, other): if isinstance(other, Number): return self.value < other.value if isinstance(other, (int, float)): return self.value < other return NotImplemented # 不知道如何处理这种类型 def __le__(self, other): if isinstance(other, Number): return self.value <= other.value if isinstance(other, (int, float)): return self.value <= other return NotImplemented def __gt__(self, other): if isinstance(other, Number): return self.value > other.value if isinstance(other, (int, float)): return self.value > other return NotImplemented def __ge__(self, other): if isinstance(other, Number): return self.value >= other.value if isinstance(other, (int, float)): return self.value >= other return NotImplemented def __eq__(self, other): if isinstance(other, Number): return self.value == other.value if isinstance(other, (int, float)): return self.value == other return NotImplemented def __hash__(self): return hash(self.value) # 现在 Number 可以和 int/float 互操作 n = Number(42) print(n > 10) # True print(10 < n) # True —— 因为 int.__lt__(10, n) 返回 NotImplemented # Python 反射调用 n.__gt__(10) print(n == 42) # True print(42 == n) # True —— 同上反射机制

这里的 NotImplemented 反射机制是 Python 比较协议的核心设计。当 left.__lt__(right) 返回 NotImplemented 时,Python 会自动尝试调用 right.__gt__(left)。这种双向调度(two-way dispatch)机制使得不同类型之间的比较成为可能。

7.2 NotImplemented 与 NotImplementedError 的区别

这是一个容易被混淆的重要概念:

NotImplemented NotImplementedError
类型 单例常量(类似 None、Ellipsis) 异常类(Exception 的子类)
使用位置 在比较方法、__add__ 等方法中返回 在抽象方法中 raise
语义 "这个操作对我这边不支持,试试对方" "这个方法必须在子类中实现"
Python 处理 触发反射机制,调用另一侧的方法 直接向上抛出异常

常见错误:在比较方法中 raise NotImplementedError 而不是 return NotImplemented。这会导致 Python 无法进行反射调用,直接抛出 TypeError,破坏了不同类型的互操作性。正确的做法永远是 return NotImplemented。

7.3 比较方法在排序算法中的应用

Python 的排序算法(Timsort)以及 heapq、bisect 等模块都依赖比较协议。理解比较方法在这些场景中的调用频率有助于编写高效的代码:

import heapq class Task: def __init__(self, priority, name): self.priority = priority self.name = name def __lt__(self, other): # heapq 是"最小堆",只需要 __lt__ return self.priority < other.priority def __repr__(self): return f"Task({self.priority}, '{self.name}')" # 使用 heapq 实现优先级队列 tasks = [ Task(3, "写报告"), Task(1, "修复紧急bug"), Task(2, "代码审查"), Task(5, "阅读文档"), Task(4, "团队会议"), ] heapq.heapify(tasks) print("按优先级出队:") while tasks: print(f" 弹出: {heapq.heappop(tasks)}") # 输出顺序: # Task(1, '修复紧急bug') # Task(2, '代码审查') # Task(3, '写报告') # Task(4, '团队会议') # Task(5, '阅读文档')

值得注意的是,heapq 模块只依赖 __lt__ 来实现最小堆。如果同时定义了 __gt__,它也不会在堆操作中使用。这是 Python 内部优化的一部分——尽量减少比较操作的方法查找开销。

7.4 比较方法的对称性陷阱

理想的比较操作应该满足对称性:a < b 和 b > a 应该返回相同的逻辑结果。但在自定义比较方法中,很容易破坏对称性:

# 对称性被破坏的例子 class Broken: def __init__(self, val): self.val = val def __lt__(self, other): if isinstance(other, Broken): return self.val < other.val # 错误:只处理了 Broken 类型,没有返回 NotImplemented raise TypeError(f"不能比较 Broken 和 {type(other)}") def __gt__(self, other): if isinstance(other, Broken): return self.val > other.val raise TypeError(f"不能比较 Broken 和 {type(other)}") a = Broken(5) try: result = 10 < a # int.__lt__(10, a) 返回 NotImplemented except TypeError: # Python 会尝试 a.__gt__(10),但 Broken.__gt__ 也抛出 TypeError print("反射机制无法工作,因为使用了 raise 而不是 return NotImplemented") pass # 正确的做法,如前文所示:返回 NotImplemented

八、自定义可哈希对象的最佳实践

综合以上所有知识,我们可以提炼出自定义可哈希对象的一套最佳实践。这套实践涵盖了设计决策、编码技巧和测试策略。

8.1 黄金法则

三原则:

1. 要么全部自定义,要么全部默认。不要只覆盖 __eq__ 而不覆盖 __hash__。

2. 不可变是前提。只有在对象不可变(或逻辑上不可变)时才定义 __hash__。

3. 一致性是生命线。__eq__ 和 __hash__ 必须基于相同的属性集合。

8.2 推荐实现模式

from typing import Any, Tuple class ColorPoint: """三维空间中的颜色点 —— 完全不可变的值对象""" def __init__(self, x: float, y: float, z: float, color: str): # 使用不可变类型存储 self._coords = (x, y, z) self._color = color # 只读属性 —— 没有 setter,确保不可变 @property def x(self) -> float: return self._coords[0] @property def y(self) -> float: return self._coords[1] @property def z(self) -> float: return self._coords[2] @property def color(self) -> str: return self._color # 将参与比较的属性打包为元组 def _key(self) -> Tuple[Tuple[float, float, float], str]: return (self._coords, self._color) def __eq__(self, other: Any) -> bool: if not isinstance(other, ColorPoint): return NotImplemented return self._key() == other._key() def __hash__(self) -> int: return hash(self._key()) # 实现 __lt__ 以支持排序 def __lt__(self, other: Any) -> bool: if not isinstance(other, ColorPoint): return NotImplemented return self._key() < other._key() def __repr__(self) -> str: return f"ColorPoint({self.x}, {self.y}, {self.z}, '{self.color}')" # 验证 p1 = ColorPoint(1, 2, 3, "red") p2 = ColorPoint(1, 2, 3, "red") p3 = ColorPoint(1, 2, 3, "blue") assert p1 == p2 assert hash(p1) == hash(p2) assert p1 != p3 assert hash(p1) != hash(p3) # 大概率不等 # 可以用作字典键 d = {p1: "point A"} assert d[p2] == "point A" # 基于值查找 assert d[ColorPoint(1, 2, 3, "red")] == "point A" # 全新对象也能查找 # 支持排序 points = [p3, p1] assert sorted(points) == [p1, p3] # 按 (coords, color) 排序 print("所有断言通过!")

这个实现模式有以下优点:

8.3 使用 dataclass 简化实现

Python 3.7+ 的 dataclasses 模块可以自动生成 __init__、__repr__、__eq__ 和 __hash__,大幅简化上述模式:

from dataclasses import dataclass @dataclass(frozen=True, order=True) class ColorPointDC: """使用 dataclass 实现的不可变值对象""" x: float y: float z: float color: str # 与手写版本完全等价 p1 = ColorPointDC(1, 2, 3, "red") p2 = ColorPointDC(1, 2, 3, "red") assert p1 == p2 assert hash(p1) == hash(p2) assert p1 < ColorPointDC(2, 0, 0, "blue") # order=True 生成了 __lt__ 等 print(f"dataclass 自动生成的 repr: {p1}")

@dataclass(frozen=True, order=True) 的作用:

推荐:对于 Python 3.7+ 项目,优先使用 @dataclass(frozen=True, order=True) 来定义不可变的值对象。这比手写所有 dunder 方法更简洁、更不容易出错。只有在需要特殊比较逻辑(如混合模式)时,才回归到手写方式。

8.4 测试清单

无论使用手写还是 dataclass 方式,在定义可哈希对象后,都应验证以下测试:

def test_hashable_contract(cls): """验证可哈希对象的基本契约""" import pytest # 1. 创建两个相等的对象 a = cls(1, 2, "red") b = cls(1, 2, "red") # 2. 创建两个不相等的对象 c = cls(3, 4, "blue") # 测试相等性 assert a == b, "__eq__ 应该对相同值返回 True" assert a != c, "__eq__ 应该对不同值返回 False" # 测试散列契约(核心) assert hash(a) == hash(b), "相等对象必须有相同的散列值" # 测试在集合中的行为 s = {a} assert b in s, "基于值的集合查找" assert c not in s, "不同值的对象不应该在集合中" # 测试在字典中的行为 d = {a: "value"} assert d[b] == "value", "基于值的字典键查找" assert c not in d, "不同值的键不应该在字典中" # 测试不可变性(如果适用) try: a.x = 999 # type: ignore assert False, "不可变对象应该禁止属性赋值" except (AttributeError, dataclasses.FrozenInstanceError): pass # 测试排序(如果实现了 __lt__) if hasattr(a, '__lt__'): sorted_list = sorted([c, a]) assert sorted_list == [a, c], "排序应该按值进行" print(f"{cls.__name__}: 所有契约测试通过!")

九、核心要点总结

散列与比较协议知识体系:

1. 核心契约:a == b 蕴含 hash(a) == hash(b),这是不可逾越的红线。

2. 可变性约束:可变对象不应定义 __hash__,因为散列值在生命周期内必须保持稳定。

3. 三种模式:基于 ID(默认)、基于值(最常见)、混合模式(谨慎使用)。

4. total_ordering:定义 __eq__ 和 __lt__ 即可自动获得全部六个比较方法。优先使用。

5. NotImplemented:在比较方法中遇到未知类型时要 return NotImplemented 而非 raise TypeError。

6. hashable:dict 键、set/frozenset 元素必须可哈希。Python 内置可变类型全不可哈希。

7. 最佳实践:优先使用 @dataclass(frozen=True, order=True) 创建不可变值对象。

8. 测试保证:始终用自动化测试验证散列契约,防止回归。