把一个 @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
六、可复用的判定清单与实测脚本
把上面的规则压成一份决策顺序,遇到「哈希/去重不符合预期」时按序排查:
- 报
unhashable type: 'X',先看 X 是不是 dataclass 类名本身——是则缺frozen=True或unsafe_hash=True。 - 是类名而字段里有
list/dict/set——给该字段加field(compare=False)或换成不可变类型。 - 集合里条数比预期多——检查是否有
init=False字段、自增 id、时间戳参与了比较,加compare=False。 - 集合里条数比预期少——检查是否误加
compare=False,导致本该区分的字段被忽略。 - 担心插入顺序影响结果——改用
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 即可。