codedevin头像
关注

Python 的 __eq__ 与 __hash__:自定义对象放进 set/dict 却报错、查不到的坑

Python 的 eqhash:自定义对象放进 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,再比 ==

setdict 是哈希表。查一个 key 时,它先算 key 的 hash() 定位到哈希桶,再在桶里用 == 逐个比对。两步都过才算「找到」。

对象默认的 __hash__ 基于对象的 id(内存地址)。假设 Python 没把 __hash__ 置空、让你的 Point 沿用默认 hash,那么 p1p2 内容虽相同,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 就变了——它还待在旧桶里,你再也找不到它,indel、去重全乱套。

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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--