← 返回Python进阶编程目录
← 返回学习笔记首页
专题: 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 契约的数学表述
用形式化的语言来描述,这一契约可以表达为以下三条规则:
自反性: 对于任何对象 x,x.__eq__(x) 必须返回 True,且 hash(x) == hash(x) 恒成立。
相等推导散列相等: 如果 x.__eq__(y) 返回 True,则必须保证 hash(x) == hash(y)。这是最核心的约束。
散列相等不推导相等: 反过来不成立——hash(x) == hash(y) 并不意味着 x == y。这是哈希碰撞的正常现象,Python 的哈希表通过开放寻址法来处理碰撞。
如果违反第二条规则——即两个相等的对象散列值不同——会导致极其隐蔽的 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__ 。这确保了一个对象的散列值在其整个生命周期内保持稳定。
标准库中的例子非常清晰地展示了这一原则:
不可变类型: str、int、float、tuple、frozenset、bytes —— 都定义了 __hash__,可作为字典键。
可变类型: list、dict、set、bytearray —— 都没有定义 __hash__(或者说 __hash__ = None),不可作为字典键。
例外与陷阱: 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__ 的计算使用了一个绝对可靠的元组。这种做法完全符合契约:
对象一旦创建就不可变,散列值永远不变。
equals 和 hash 基于完全相同的属性组合((x, y)),保证了一致性。
可以安全地用作字典键和集合元素。
四、常见的 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__),装饰器就会自动推导出其他四个方法。推导逻辑如下:
如果定义了 __lt__(小于)和 __eq__(等于):
__le__ (x <= y) 推导为:x < y or x == y
__gt__ (x > y) 推导为:y < x
__ge__ (x >= y) 推导为:y < x or x == y
__ne__ (x != y) 推导为:not (x == y)
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 的注意事项
有几个容易踩的坑需要特别注意:
必须定义 __eq__ 和至少一个其他比较方法 ,否则装饰器会抛出 TypeError。
不要混用不同类型的比较: 如果 __lt__ 返回 NotImplemented,自动推导的方法也会返回 NotImplemented,可能导致意外的结果。
继承问题: 如果父类使用了 total_ordering,子类在重写 __eq__ 或 __lt__ 后也需要重新应用装饰器。
不支持有向比较: 如果 A 类知道如何与 B 类比较,但 B 类不知道如何与 A 类比较,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 的定义包含两个条件:
对象定义了 __hash__() 方法且返回值在整个生命周期内不变。
对象定义了 __eq__() 方法用于相等性比较(或者从 object 继承了默认实现)。
所有 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("所有断言通过!")
这个实现模式有以下优点:
单一真相来源: _key() 方法同时被 __eq__、__hash__ 和 __lt__ 使用,保证了一致性。
不可变性: 下划线前缀 + @property(无 setter)防止外部修改。
类型安全: isinstance 检查 + NotImplemented 返回,支持类型互操作。
可排序: 定义了 __lt__,可以与 sorted()、heapq 等配合使用。
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) 的作用:
frozen=True — 生成 __hash__(不可变类的必要条件),且禁止属性赋值。
order=True — 生成 __lt__、__le__、__gt__、__ge__ 全部四个比较方法。
__eq__ 由 dataclass 默认生成(除非 eq=False)。
推荐: 对于 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. 测试保证: 始终用自动化测试验证散列契约,防止回归。