dataclass 的 frozen=True 与 field(compare=False) 在集合去重中的哈希行为

把一个 @dataclass 实例丢进 set 或当作 dict 的 key,很快会遇到两类问题:一是 TypeError: unhashable type,二是「明明字段值不同,却被集合判成同一条」或者反过来「以为是同一条,却出现两条」。前者来自 dataclass 默认把 __hash__ 置为 None,后者来自 eq 与 frozen 两个开关对 _

把一个 @dataclass 实例丢进 set 或当作 dict 的 key,很快会遇到两类问题:一是 TypeError: unhashable type,二是「明明字段值不同,却被集合判成同一条」或者反过来「以为是同一条,却出现两条」。前者来自 dataclass 默认把 __hash__ 置为 None,后者来自 eq 与 frozen 两个开关对 __eq__、__hash__ 生成逻辑的交叉影响,再叠加 field(compare=False) 把个别字段从比较中剔除。本篇按 CPython 里 dataclasses.py 的实际规则,把 frozen=True 和 field(compare=False) 在哈希与去重中的表现拆开讲清楚,并给出可复现的代码与报错。

一、先看清 dataclass 的 eq / frozen 组合矩阵

dataclass 在生成类时,会根据三个参数决定是否注入 __eq__ 与 __hash__:eq(默认 True)、frozen(默认 False)、unsafe_hash(默认 False)。官方文档给出的规则可以概括成下表,这张表是后面所有困惑的根源:

eq frozen unsafe_hash 生成的 __hash__ 能否放进 set
True False False 置为 None 不能,抛 TypeError
True True False 自动生成(基于参与比较的字段) 能
True False True 强制生成 能,但对象可变,有风险
False False False 不处理,继承 object.__hash__ 能,但按 id 哈希
False True False 不处理,继承 object.__hash__ 能,但按 id 哈希
False False True 强制生成 能,但 eq=False 时哈希与相等性不一致

注意前两行是绝大多数人踩坑的地方:eq=True, frozen=False 时 dataclass 显式把 __hash__ 设成 None,这不是「没生成」,而是「明确不可哈希」。所以下面这段代码必然报错:

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(1, 2)
print(p.__hash__)   # None
s = {p}

运行结果:

None
Traceback (most recent call last):
  File "demo.py", line 10, in <module>
    s = {p}
        ^^^
TypeError: unhashable type: 'Point'

补一句 frozen=True 就恢复正常,因为 frozen 保证了字段值不会在对象进入集合后改变,哈希值稳定,这是它被允许自动生成 __hash__ 的前提。

二、frozen=True 生成的哈希到底对哪些字段取值

关键点:__hash__ 并不是对「所有字段」求哈希,而是对「参与比较的字段」求哈希。具体实现是把这些字段收集成一个 tuple,再调用 hash(tuple)。而「哪些字段参与比较」正是由 field(compare=False) 控制的。

先验证最朴素的写法:

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
    amount: int
    currency: str

a = Money(100, "CNY")
b = Money(100, "CNY")
print(a == b)              # True
print(hash(a) == hash(b))  # True
print(len({a, b}))         # 1

输出全部符合预期。此时 hash(a) 等价于 hash((100, "CNY"))。可以进一步确认:

print(hash(Money(100, "CNY")) == hash((100, "CNY")))  # True

这说明哈希的输入就是比较字段组成的 tuple,不加任何自定义盐值。了解这一点后,下面 compare=False 的行为就完全可以推导出来。

三、field(compare=False):从 __eq__ 和 __hash__ 里同时摘掉字段

compare=False 的语义是「这个字段不参与 __eq__ 的比较」。由于 __hash__ 是按参与比较的字段生成的,这个字段也同时被排除在哈希之外。这是很多教程没讲透的一层耦合。

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Event:
    user_id: int
    kind: str
    raw_payload: dict = field(compare=False)

e1 = Event(1, "click", {"ts": 1000})
e2 = Event(1, "click", {"ts": 2000})

print(e1 == e2)             # True:raw_payload 不参与比较
print(hash(e1) == hash(e2)) # True:raw_payload 也不参与哈希
print(len({e1, e2}))        # 1

这里 raw_payload 是 dict,本身不可哈希。如果 dataclass 把它算进哈希 tuple,hash(e1) 会直接抛 TypeError: unhashable type: 'dict'。正是 compare=False 让它绕过了这一步,这也是这个参数最常见的用途之一:让不可哈希的辅助字段不破坏整个对象的可哈希性。

反过来验证移除 compare=False 的后果:

@dataclass(frozen=True)
class BadEvent:
    user_id: int
    kind: str
    raw_payload: dict

print(hash(BadEvent(1, "click", {"ts": 1000})))
Traceback (most recent call last):
  File "demo.py", line 9, in <module>
    print(hash(BadEvent(1, "click", {"ts": 1000})))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'dict'

注意报错发生在 hash() 调用时,而不是在定义类或构造对象时。所以这类问题往往在数据跑到集合去重环节才暴露。

四、去重语义的两种写法:compare=False 还是自定义 __hash__

业务里经常需要「按主键去重,但保留完整记录」。用 compare=False 是最省事的做法,但它有明确的副作用:

  • e1 == e2 会返回 True,即便两条记录内容差很多;
  • 集合的 add 会安静地丢弃后到的元素,不报错也不提示;
  • 如果先放进去的是「信息不全」的那条,后面那条完整的再也进不来。

看一个容易出 bug 的场景,注意插入顺序决定最终留在集合里的是哪一条:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class User:
    uid: int
    name: str = field(compare=False)
    email: str = field(compare=False)

s = set()
s.add(User(1, "", ""))              # 先到,信息为空
s.add(User(1, "alice", "a@x.com"))  # 后到,被判定为重复而丢弃
print(s)
{User(uid=1, name='', email='')}

如果业务要求「后到的覆盖先到的」,用 set 是做不到的,应该改用 dict 以主键为 key:

records = [User(1, "", ""), User(1, "alice", "a@x.com")]
by_id = {u.uid: u for u in records}
print(list(by_id.values()))
[User(uid=1, name='alice', email='a@x.com')]

如果确实希望 == 保持严格语义(全字段相等),又想让集合按主键去重,就不要用 compare=False,而是显式定义 __hash__。注意 __eq__ 与 __hash__ 必须保持契约一致:a == b 蕴涵 hash(a) == hash(b)。下面这种写法就违反了契约——相等对象哈希不同,会导致集合里出现「看起来重复」的元素:

from dataclasses import dataclass

@dataclass(frozen=True)
class Bad:
    uid: int
    name: str

    def __hash__(self):
        return hash(self.uid)   # 只用 uid 算哈希

print(Bad(1, "a") == Bad(1, "b"))                 # False,按全字段比较
print(hash(Bad(1, "a")) == hash(Bad(1, "b")))     # True
print(len({Bad(1, "a"), Bad(1, "b")}))            # 2

这个例子本身不算「错误」(相等对象哈希相同,只是哈希碰撞多),但它说明:只改 __hash__ 不改 __eq__ 达不到按主键去重的目的,集合里仍会出现两条。真正按主键去重,必须让「参与比较的字段」等于「主键」,也就是回到 compare=False 或自定义 __eq__。

五、可变字段、init=False 与继承带来的意外

几个细节会让人对哈希行为产生误判,逐个验证。

5.1 init=False 字段同样参与比较和哈希

init=False 只影响 __init__ 的参数列表,不影响 __eq__ 与 __hash__。若该字段在构造后由 __post_init__ 赋值,且值依赖外部状态(比如时间戳、自增 id),哈希就会不稳定:

import itertools
from dataclasses import dataclass, field

_counter = itertools.count(1)

@dataclass(frozen=True)
class Ticket:
    title: str
    seq: int = field(init=False)

    def __post_init__(self):
        object.__setattr__(self, "seq", next(_counter))

t1 = Ticket("bug")
t2 = Ticket("bug")
print(t1 == t2)    # False:seq 参与了比较
print(len({t1, t2}))  # 2

两个「同名」工单没有被去重,原因就是 seq 参与比较。若本意是只按 title 去重,应写 field(init=False, compare=False)。这里必须用 object.__setattr__ 赋值,因为 frozen 类拦截了普通 setattr,直接写 self.seq = ... 会抛:

dataclasses.FrozenInstanceError: cannot assign to field 'seq'

5.2 继承时哈希的重新生成

dataclass 在每个被装饰的类上都会重新判断是否需要生成 __hash__。如果父类是 frozen=True,子类写普通 dataclass,会直接报错:

from dataclasses import dataclass

@dataclass(frozen=True)
class Base:
    a: int

@dataclass
class Child(Base):
    b: int
TypeError: cannot inherit non-frozen dataclass from a frozen one

子类也必须 frozen=True。反过来,父类非 frozen、子类 frozen 是允许的,此时子类会重新生成 __hash__,但父类字段已经存在,说明继承链上「可变父类 + 不可变子类」这种混搭在语义上就别扭,实际项目里应统一。

5.3 字段本身不可哈希时的报错位置

如果没有 compare=False,也不要把 list、dict、set 这类字段塞进 frozen dataclass 然后指望能进集合。错误信息很明确:TypeError: unhashable type: 'list'。解决办法有三种:改为 tuple / frozenset;加 compare=False, hash=False;或者继续用 field(hash=False) 但保留比较。

注意 hash=False 是独立于 compare 的参数:compare=True, hash=False 表示「参与相等比较,但不参与哈希」。这会造成相等对象哈希相同(合法),但不相等的对象也可能哈希相同(碰撞变多),性能上略有损失,语义上没问题。而 compare=False, hash=True 是自相矛盾的写法,dataclasses 会直接拒绝:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class W:
    a: int
    b: int = field(compare=False, hash=True)
ValueError: field b: cannot have compare=False and hash=True

六、可复用的判定清单与实测脚本

把上面的规则压成一份决策顺序,遇到「哈希/去重不符合预期」时按序排查:

  1. 报 unhashable type: 'X',先看 X 是不是 dataclass 类名本身——是则缺 frozen=True 或 unsafe_hash=True。
  2. 是类名而字段里有 list/dict/set——给该字段加 field(compare=False) 或换成不可变类型。
  3. 集合里条数比预期多——检查是否有 init=False 字段、自增 id、时间戳参与了比较,加 compare=False。
  4. 集合里条数比预期少——检查是否误加 compare=False,导致本该区分的字段被忽略。
  5. 担心插入顺序影响结果——改用 dict 按主键覆盖,不要依赖 set 的「先到先得」。

下面这段脚本把关键结论都跑了一遍,可以直接存成 check.py 验证环境行为:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Rec:
    key: str
    payload: tuple = field(compare=False, default=())

r1 = Rec("k1", (1, 2))
r2 = Rec("k1", (3, 4))
r3 = Rec("k2", (1, 2))

assert r1 == r2, "compare=False 字段不参与相等比较"
assert hash(r1) == hash(r2)
assert r1 != r3
assert len({r1, r2, r3}) == 2
assert hash(Rec("k1", (1, 2))) == hash(("k1",))

print("all assertions passed")
print(r1.__hash__)          # 由 dataclass 生成的方法对象,不是 None
print(hash(r1))

最后一行 hash(Rec("k1", (1, 2))) == hash(("k1",)) 直接印证了第二节的结论:哈希输入就是参与比较字段组成的 tuple。掌握这一条,frozen 与 compare 的各种组合都能当场推导,不必再靠试。

小结

frozen=True 的作用是让 dataclass 敢于自动生成 __hash__,其实现是对「参与比较的字段」组成的 tuple 求哈希;field(compare=False) 则把这个字段同时移出 __eq__ 与 __hash__,因此它会直接改变集合去重的粒度,也会让不可哈希的辅助字段不再破坏整体可哈希性。代价是相等性变宽松,集合的「先到先得」语义可能悄悄丢掉后到的完整数据。要求覆盖式合并时用 dict;要求严格相等又要按主键去重时,必须让比较字段等于主键,而不是只改 __hash__。

FAQ

Q1:为什么加了 compare=False 之后 hash 也变了?我只想去掉相等比较。

因为 dataclass 生成的 __hash__ 是基于比较字段 tuple 计算的,两者共用同一份字段清单。想保留比较但不参与哈希,用 field(hash=False);想彻底自定义,就显式写 __hash__ 并保证与 __eq__ 的契约一致。

Q2:unsafe_hash=True 和 frozen=True 该选哪个?

字段确实不可变时优先 frozen=True,dataclass 会自动生成合法哈希。对象必须可变又急着塞进集合时才能用 unsafe_hash=True,但要清楚风险:对象进入 set 后一旦改动参与哈希的字段,它就再也找不到了,in 判断会返回 False,而且不报错。这类「幽灵元素」极难排查。

Q3:报 TypeError: unhashable type: 'Rec',但类上明明写了 frozen=True?

检查是不是同时写了 eq=False。eq=False 时 dataclass 不处理 __hash__,直接继承 object.__hash__,此时对象可哈希但按 id 计算,两个内容相同的实例不会被去重。这类「能跑但结果错」的 bug 比报错更难发现,建议写单测断言 len({a, b}) == 1。

Q4:frozen dataclass 里放了 list 字段,怎么既保留比较又能进集合?

把 list 换成 tuple,或在 __post_init__ 里用 object.__setattr__(self, "tags", tuple(self.tags)) 归一化。直接放 list 且不加 compare=False,hash() 时必然抛 TypeError: unhashable type: 'list'。

Q5:为什么子类不能是非 frozen 的?

报错是 TypeError: cannot inherit non-frozen dataclass from a frozen one。frozen 保证了实例不可变,若允许子类可变,父类生成的 __hash__ 语义就被破坏。让子类同样声明 frozen=True 即可。