AIGC标识 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 不是逐个 ==,而是两步:

  1. 用 hash(x) 定位到哈希表的某个桶(O(1) 平均);
  2. 只和同桶内的元素调用 __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/不可变基本类型当键。

七、六条排错清单

  1. unhashable type → 定义了 __eq__ 没定义 __hash__,或用了默认 dataclass。补 __hash__,或改 frozen=True。
  2. set 里出现两个「相等」元素 → hash() 用了与 __eq__ 不同的字段,契约被破坏。把两者对齐。
  3. 改了对象属性后 dict.get() 全 None → 可变 key。冻结字段或换成不可变键。
  4. __slots__ 类不可哈希? → 与 __slots__ 无关,__hash__ 由类定义决定;别混淆「不可弱引用」(那是 __weakref__ 槽位的事)。
  5. 继承父类后行为变了 → 子类定义 __eq__ 同样会把 __hash__ 置 None;需要保留时显式写 __hash__ = Parent.__hash__。
  6. 自定义 __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 实际运行结果,未做修饰。

posted @ 2026-11-10 10:26  小小张说故事  阅读(3)  评论(0)    收藏  举报