Python 的 eq 与 hash:自定义对象放进 set/dict 却报错、查不到的坑
你写了个 Point 类,两个坐标相同的点理应「相等」。于是你重写了 __eq__,自测 p1 == p2 返回 True,很满意。结果把它们放进 set 去重,直接抛 TypeError: unhashable type;好不容易绕过去了,又发现拿一个内容相同的对象去 dict 里查怎么都是 KeyError。明明相等,为什么放不进、查不到?
问题出在:你只改了 __eq__,没管 __hash__。这篇讲清楚这两个方法的契约关系,以及怎么正确让自定义对象能进 set/dict。
先复现这个坑
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # True,__eq__ 生效了
print({p1, p2}) # TypeError: unhashable type: 'Point'
== 明明好使,一放进 set 就报 unhashable。这不是你哪里写错了,而是 Python 3 的一条规则:
只要你在类里定义了
__eq__而没定义__hash__,Python 会自动把这个类的__hash__设为None,实例随之变成不可哈希。
print(Point.__hash__) # None —— 被 Python 自动置空了
为什么 Python 要这么「狠」?因为如果它默默保留默认的 __hash__,你会掉进一个更隐蔽的坑。
根因:set/dict 先比 hash,再比 ==
set 和 dict 是哈希表。查一个 key 时,它先算 key 的 hash() 定位到哈希桶,再在桶里用 == 逐个比对。两步都过才算「找到」。
对象默认的 __hash__ 基于对象的 id(内存地址)。假设 Python 没把 __hash__ 置空、让你的 Point 沿用默认 hash,那么 p1 和 p2 内容虽相同,hash 却因地址不同而不同,会被分到不同的桶。查找连第二步的 == 都走不到:
# 假想:如果 Point 沿用了默认(基于 id 的)hash,会发生什么
d = {p1: "hello"}
d[p2] # KeyError!p1==p2 为 True,但 hash 不同,定位到别的桶,查不到
{p1, p2} # 长度是 2,两个「相等」的点没被去重
这种「== 是 True 却查不到、去不了重」的 bug 静默且难查。所以 Python 3 干脆让它尽早、响亮地失败(直接 unhashable),逼你把 __hash__ 也定义对——这其实是保护你。
契约:相等的对象必须有相等的 hash
Python 数据模型规定一条铁律:
如果
a == b,那么必须hash(a) == hash(b)。
反过来不要求(hash 相同的对象不一定相等,那叫哈希冲突,允许存在)。这条契约是哈希表能正确工作的前提。所以只要你重写了 __eq__,就必须同时重写 __hash__,而且 hash 要基于参与相等判断的同一批字段。
正确写法:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
# hash 基于与 __eq__ 相同的字段,用 tuple 打包最省事
def __hash__(self):
return hash((self.x, self.y))
p1, p2 = Point(1, 2), Point(1, 2)
print(len({p1, p2})) # 1,正确去重
print({p1: "hi"}[p2]) # hi,查得到
用 hash((self.x, self.y)) 是最省心的写法:直接把参与比较的字段打成 tuple,复用 tuple 自带的 hash,既满足契约又分布均匀。切记 __hash__ 依赖的字段要和 __eq__ 里比较的字段完全一致——少一个,两个「不等」的对象可能撞同一个 hash(性能退化);多一个,两个「相等」的对象 hash 却不同(直接违反契约,又回到查不到的坑)。
更省事:用 dataclass 或 namedtuple
如果你只是想要个「值对象」,根本不用手写这两个方法。
@dataclass(frozen=True) 会自动生成 __eq__ 和 __hash__:
from dataclasses import dataclass
@dataclass(frozen=True) # frozen=True 才会生成 __hash__
class Point:
x: int
y: int
p1, p2 = Point(1, 2), Point(1, 2)
print(p1 == p2, len({p1, p2})) # True 1
注意:@dataclass 默认(eq=True, frozen=False)只生成 __eq__,并把 __hash__ 设为 None——和手写只改 __eq__ 一个下场,实例不可哈希。必须加 frozen=True(或显式 eq=True, frozen=True)才会得到 __hash__。
namedtuple 更轻量,天生不可变、可哈希、按值相等:
from collections import namedtuple
Point = namedtuple("Point", "x y")
print(Point(1, 2) == Point(1, 2)) # True
print(len({Point(1, 2), Point(1, 2)})) # 1
坑:可变对象别用作 key
契约还有个隐含要求:对象放进 set/dict 后,它的 hash 不能变。如果你的 __hash__ 依赖某个字段,而对象入桶后你又改了那个字段,hash 就变了——它还待在旧桶里,你再也找不到它,in、del、去重全乱套。
class Bad:
def __init__(self, v):
self.v = v
def __eq__(self, o):
return isinstance(o, Bad) and self.v == o.v
def __hash__(self):
return hash(self.v)
b = Bad(1)
s = {b}
b.v = 999 # 入桶后改了 hash 依赖的字段
print(b in s) # False!它就在集合里,却查不到了
这就是为什么标准库里能当 key 的都是不可变类型(str、tuple、frozenset),以及为什么推荐用 frozen=True 的 dataclass:frozen 会禁止改字段,从根上杜绝这个坑。要当 dict key / set 元素的对象,就让它不可变,别在入桶后改动参与 hash 的字段。
小结
- set/dict 查找是「先比 hash 定位桶,再比
==」两步;hash 不匹配根本走不到==。 - Python 3 里重写
__eq__却不写__hash__,实例会变 unhashable(直接报错)——这是 Python 逼你别踩「== 是 True 却查不到」的静默坑。 - 契约:
a == b必须hash(a) == hash(b);手写__hash__用hash((与 __eq__ 相同的字段...))最稳。 - 更省事直接
@dataclass(frozen=True)或namedtuple;用作 key 的对象一定要不可变。
一句话记忆:__eq__ 和 __hash__ 是一对,基于同一批字段,要么一起写,要么用 frozen dataclass 让 Python 替你写。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_42662753/article/details/164422680



