Python 里为什么改了 __eq__ 就进不了集合:拆开 __hash__、可哈希契约与可变 key 陷阱
一句话结论:只要你在类里定义了 __eq__ 而没显式定义 __hash__,CPython 会把 __hash__ 直接设成 None,实例立刻变成「不可哈希」,放进 set 或当 dict 键都会抛 TypeError: unhashable type。这不是 bug,是语言在替你守住一条契约:相等的对象必须有相同的哈希值。
本文所有结论来自本机 CPython 3.13.14 实测。
一、先看现象:一行 __eq__ 引发的报错
class EqOnly:
def __init__(self, k): self.k = k
def __eq__(self, o): return isinstance(o, EqOnly) and self.k == o.k
print(EqOnly.__hash__) # None
hash(EqOnly(1)) # TypeError: unhashable type: 'EqOnly'
{EqOnly(1)} # TypeError: unhashable type: 'EqOnly'
实测输出确为 None。对照组:
| 类定义 | __hash__ |
hash() |
能否进 set |
|---|---|---|---|
object 派生、什么都没定义 |
object.__hash__ |
✅ | ✅ |
只定义 __eq__ |
None |
❌ TypeError | ❌ |
定义 __eq__ + 自定义 __hash__ |
函数 | ✅ | ✅ |
显式 __hash__ = None |
None |
❌ | ❌ |
__hash__ = object.__hash__(保留父类) |
object.__hash__ |
✅ | ✅ |
为什么这么设计:默认 object.__hash__ 基于对象身份(id),而自定义 __eq__ 通常改成「按值相等」。如果还留着按身份的哈希,就会出现「a == b 为 True,但 hash(a) != hash(b)」——放进 set 会被当成两个不同元素,哈希容器直接失效。与其悄悄给错结果,不如显式置 None 让你当场撞墙。
二、三种恢复方式(各有代价)
方式一:自己写 __hash__,且与 __eq__ 用同一批字段
class EqHash:
def __init__(self, k): self.k = k
def __eq__(self, o): return isinstance(o, EqHash) and self.k == o.k
def __hash__(self): return hash(self.k)
实测 {EqHash(1), EqHash(1), EqHash(2)} → len = 2,EqHash(1) in s → True。去重正常。
⚠️ 铁律:__eq__ 比较了哪些字段,__hash__ 就只能哈希这些字段(或其子集)。反过来不行。
方式二:显式恢复 object.__hash__
class KeepParent(EqOnly):
__hash__ = object.__hash__
实测 hash() 可用。但代价是哈希回到「按身份」:两个 KeepParent(1) 相等却哈希不同,放进 set 会各占一格。只有在你明确希望「按身份去重」时才用它,多数业务场景这是错的。
方式三:不可变 dataclass
from dataclasses import dataclass
@dataclass
class DC:
k: int
@dataclass(frozen=True)
class FDC:
k: int
实测:
DC(eq=True, frozen=False) |
FDC(frozen=True) |
|
|---|---|---|
__hash__ |
None |
自动生成的函数 |
hash(DC(1)) |
❌ unhashable type: 'DC' |
✅ -6644214454873602895 |
即 dataclass 默认生成 __eq__ 后同样把 __hash__ 置 None;只有 frozen=True(或 eq=False)才会得到一个基于字段的哈希。想要「可哈希的值对象」,@dataclass(frozen=True) 是最省事的写法。
三、集合查找到底做了什么
x in s 不是逐个 ==,而是两步:
- 用
hash(x)定位到哈希表的某个桶(O(1) 平均); - 只和同桶内的元素调用
__eq__逐个比对(解决冲突)。
所以哈希只负责「缩小范围」,相等判断负责「最终确认」。这也解释了为什么两条规则缺一不可:
- 哈希不同 → 落在不同桶,永远不会比
__eq__,相等的对象被当成两个; __eq__说相等但哈希不同 → 查找时根本去不到那个桶。
实测 10 万次 1 in s(s 为 1000 元素 set)耗时 0.0028 s,这个速度就是靠「先哈希定位」换来的。
四、哈希冲突不是错误
class Collide:
def __init__(self, k): self.k = k
def __eq__(self, o): return isinstance(o, Collide) and self.k == o.k
def __hash__(self): return 1 # 所有对象都撞同一个桶
c = {Collide(i) for i in range(8)}
print(len(c), Collide(3) in c) # 8 True
实测 len = 8、Collide(3) in c = True。全部哈希相同也能正确工作,只是退化成线性比较、性能变差。哈希冲突可接受,哈希不一致不可接受。
五、违反契约会怎样:静默的错误最危险
class Bad:
def __init__(self, k): self.k = k
def __eq__(self, o): return isinstance(o, Bad) # 恒相等
def __hash__(self): return hash(self.k) # 却按 k 哈希
b = {Bad(1), Bad(2)}
print(Bad(1) == Bad(2)) # True
print(hash(Bad(1)) != hash(Bad(2))) # True
print(len(b)) # 2 ← 逻辑上应该只有 1 个
实测 len = 2。两个「相等」的对象同时存在于一个 set 里,去重语义被破坏,而且不报任何错。这比 None 哈希的 TypeError 危险得多——所以 CPython 宁可默认置 None。
六、可变 key:查不回来的经典陷阱
class Mut:
def __init__(self, k): self.k = k
def __eq__(self, o): return isinstance(o, Mut) and self.k == o.k
def __hash__(self): return hash(self.k)
m = Mut(1)
d = {m: 'found'}
m.k = 2 # 改了参与哈希的字段
print(d.get(Mut(1))) # None
print(d.get(Mut(2))) # None
print(d.get(m)) # None
实测三种查法全部返回 None。原因:条目还按旧哈希 hash(1) 存在桶里;用新的 hash(2) 去查,定位到另一个空桶,连 __eq__ 都没机会执行。
结论:凡是要做 dict 键或 set 成员的字段,必须在对象生命周期内保持不变。要么用 frozen=True dataclass,要么把可变状态挪到不参与哈希的属性上,要么干脆用 tuple/frozenset/不可变基本类型当键。
七、六条排错清单
unhashable type→ 定义了__eq__没定义__hash__,或用了默认dataclass。补__hash__,或改frozen=True。- set 里出现两个「相等」元素 →
hash()用了与__eq__不同的字段,契约被破坏。把两者对齐。 - 改了对象属性后
dict.get()全None→ 可变 key。冻结字段或换成不可变键。 __slots__类不可哈希? → 与__slots__无关,__hash__由类定义决定;别混淆「不可弱引用」(那是__weakref__槽位的事)。- 继承父类后行为变了 → 子类定义
__eq__同样会把__hash__置None;需要保留时显式写__hash__ = Parent.__hash__。 - 自定义
__hash__抛异常 → 哈希函数必须快、稳定、不抛异常,通常用hash((self.a, self.b))组合不可变字段即可,别在里面做 I/O。
八、完整可运行示例
from dataclasses import dataclass
class EqOnly:
def __init__(self, k): self.k = k
def __eq__(self, o): return isinstance(o, EqOnly) and self.k == o.k
class EqHash(EqOnly):
def __hash__(self): return hash(self.k)
@dataclass(frozen=True)
class FDC:
k: int
def demo():
print('EqOnly.__hash__ =', EqOnly.__hash__)
try:
hash(EqOnly(1))
except TypeError as e:
print('EqOnly hash ->', e)
s = {EqHash(1), EqHash(1), EqHash(2)}
print('set len =', len(s), '| EqHash(1) in s =', EqHash(1) in s)
print('hash(FDC(1)) =', hash(FDC(1)))
m = EqHash(1)
d = {m: 'found'}
m.k = 2
print('after mutate ->', d.get(EqHash(1)), d.get(m))
demo()
九、一张图记住这个契约
a == b 必须推出 hash(a) == hash(b) ← 必须满足
hash(a) == hash(b) 不要求 a == b ← 允许冲突
定义 __eq__ → __hash__ 自动变 None ← CPython 的保护
要可哈希 → 自己写 __hash__,字段与 __eq__ 对齐
要当 key → 参与哈希的字段不可变
把 __eq__ 和 __hash__ 当成一份「成对签署的协议」来写,绝大多数集合相关的怪现象都不会再出现。
实测环境:CPython 3.13.14(Windows)。所有输出为本机 d56-probe.py 实际运行结果,未做修饰。

浙公网安备 33010602011771号