如果你的类只重写了__eq__然后突然变得不可哈希了不用怀疑这是 Python 在教你尊重哈希协议。我第一次踩这个坑是在做多币种对账脚本的时候——Money类才加上金额和币种相等的判断逻辑自我感觉良好结果把它往set里一放解释器当场给我抛了个TypeError: unhashable type: Money。那一瞬间我是懵的相等判断和能不能哈希看起来八竿子打不着啊后来翻了官方文档才明白这正是 Python 魔法方法之间最经典的联动之一。这篇文章我打算分两条线讲。第一条线是把__eq__重写后不可哈希这件事彻底讲透包括背后的规则、常见解法第二条线借这个机会把日常开发里高频使用的魔法方法按协议分组盘一遍。这样你既知道怎么修眼前的问题以后看到__len__、__getitem__、__radd__这些东西也不再发怵。1. 现象拆解一个只重写了__eq__的类为什么会触发 unhashable1.1 最小复现从相等判断正常到set 直接报错先看一个不能再小的复现。假设我有一个表示金额的Money类正常情况下两个对象即使内容相同在内存里也是两个不同的实例比较的是身份默认是False。为了让100 美元等于 100 美元这种业务逻辑成立我重写了__eq__class Money: def __init__(self, amount, currency): self.amount amount self.currency currency def __eq__(self, other): if not isinstance(other, Money): return NotImplemented return (self.amount, self.currency) (other.amount, other.currency) m1 Money(100, USD) m2 Money(100, USD) print(m1 m2) # True print(m1 is m2) # False # 下面这两行会怎样 s {m1, m2} d {m1: 扣款}运行到构建集合和字典的那两行解释器会直接抛错True False TypeError: unhashable type: Money注意m1 m2是能正常返回True的类本身也没有任何语法问题。问题就出在set和dict的内部机制上它们存储元素或键的时候第一步要做的事情是计算哈希值而Money类此时根本没有可用的__hash__。这里的没有可用不是没写这么简单而是 Python 语言层面主动把你的哈希通道给关了。1.2 语言规则定义eq后hash会被置为 NonePython 对象模型里有一条容易被忽略的规则当你在类中定义了__eq__()却没有定义__hash__()时Python 会隐式地把__hash__设置为None。你可以直接验证print(Money.__hash__ is None) # True为什么语言要这么设计这得从默认行为说起。没有重写任何魔法方法时一个类的实例可以哈希是因为基于对象身份id计算哈希值而默认的__eq__也是身份比较。两者天然自洽同一个对象身份一定相同哈希一定相同。可一旦你自定义了相等规则Python 就不知道你打算用什么字段来生成哈希了。假设两个对象在你的__eq__里相等但 Python 还按旧的身份算哈希那么这两个相等对象就会被放进哈希表的不同位置set的去重和dict的查找都会全面混乱。我习惯用一个图书馆的类比来理解这件事书按编号上架编号一样就认为同一本书。如果你突然规定只要书名相同就算同一本书却不告诉图书馆按什么规则重新编号那图书馆只能拒收这本书防止同一本书出现在两个位置。unhashable type就是这个拒收动作。需要强调一点哪怕你重写的__eq__内部逻辑和默认身份比较完全一样Python 也不会好心帮你保留原来的__hash__因为语言层面根本无法判断你的实现到底等不等价于身份比较。规则就是规则——写了__eq__就必须连带考虑__hash__。1.3 不可哈希的实际影响范围一旦类变得不可哈希受影响的场景是明确且固定的不能作为dict的 key像d[m1] 1会直接报错。不能放入set也就不能做交集、并集、去重这些集合运算。手动调用hash(m1)会得到TypeError。但对象本身依然可以正常实例化、比较、打印、传给函数类的常规使用不受阻碍。所以不可哈希并不是类坏了而是它被排除在依赖哈希表的数据结构之外。Python 里list、dict、set本身也是不可哈希的正是因为它们可变。你自定义的类如果结构上和它们一样多变那不可哈希反而是一种合理的自我保护。2. 可维护的解决方案让eq和hash重新对齐2.1 同时重写hash用同一组不可变字段计算最直接的修复方式就是手动补一个__hash__让它和你定义的相等规则基于同一套字段class MoneyV1: def __init__(self, amount, currency): self.amount amount self.currency currency def __eq__(self, other): if not isinstance(other, MoneyV1): return NotImplemented return (self.amount, self.currency) (other.amount, other.currency) def __hash__(self): return hash((self.amount, self.currency))这里有一个必须遵守的约束相等的对象哈希值必须相同。所以__hash__依赖的字段应当与__eq__参与比较的字段保持一致或者至少是它的子集。举个例子如果你__eq__只比较amount那__hash__就不能同时用amount和currency否则会出现两个对象相等但哈希值不同的情况破坏哈希表的基本约定。如果参与哈希的字段里有可变类型比如list要么先转换成不可变形式要么干脆换一种思路设计def __hash__(self): return hash((self.id, tuple(sorted(self.tags))))如果元素之间没有顺序概念可以用frozenset(self.tags)。参与哈希的数据必须是不可变类型而且最好在对象生命周期内保持稳定这一点在第 4 章还会重点展开。2.2 显式把hash设为 None表达这个类不该被哈希不是所有类的实例都适合放进set或作为dict的 key。比如一个表示数据库连接的类它的本质是某个底层资源的引用而不是一段可比较的数据。如果你为了判断是不是同一个连接而重写了__eq__但又不希望这个对象被到处塞进集合可以在类体里明确把哈希关掉class Connection: def __init__(self, sock): self.sock sock def __eq__(self, other): return isinstance(other, Connection) and self.sock is other.sock __hash__ None写成__hash__ None语义非常清楚这个类的实例在业务上不应该被哈希。调用者一旦尝试把它放进set立刻得到明确的TypeError而不是在一个遥远的内存操作里才暴露问题。在我看来这比让它能哈希但哈希规则很模糊要安全得多。2.3 用 dataclass 和 NamedTuple 减少手写量标准库里已经提供了很多能自动生成魔法方法的工具处理__eq__和__hash__尤其省事。dataclass(frozenTrue)会自动生成__init__、__repr__、__eq__并且因为frozenTrue保证了字段不可变它会自动生成安全的__hash__。dataclass(eqTrue, frozenFalse)会重写__eq__但出于同一套规则__hash__会被设为None。typing.NamedTuple生成的类字段不可变自动拥有__eq__和__hash__非常适合做简单的值对象。from dataclasses import dataclass dataclass(frozenTrue) class Point: x: int y: int p1 Point(1, 2) p2 Point(1, 2) print(p1 p2) # True print(hash(p1)) # 正常如果你需要在dataclass上自定义比较逻辑仍然要自己处理__hash__。有一个unsafe_hashTrue参数它允许可变dataclass也生成哈希但名字里的 unsafe 已经说明风险了字段一旦变化哈希值就会变而对象可能正躺在某个集合里。我一般不建议在非必要场景用它。2.4 项目落地时的取舍建议根据我自己的项目经验处理这个问题时可以按一个简单标准分类值对象比如金额、坐标、日期区间这种数据本身即身份的对象优先让它可哈希方便做去重、缓存、集合运算。实体对象比如订单、用户、商品业务上通常用主键 ID 判断相等但我不建议让整个对象直接可哈希。更好的做法是用 ID 构造一个元组作为dict的 key而不是把整个对象扔进去。状态经常变动的对象比如一个正在累积数据的缓存对象直接显式设置__hash__ None防止以后有人误用。这里我补充一个血泪教训千万不要为了能用set去重这个目的硬把一个可变对象的__hash__写出来。短时间看很爽等你后续修改属性时改完属性集合里出现脏数据的问题会让你追查很久。3. 常用魔法方法大盘点按协议分组快速查阅3.1 比较与判定eq、ne、lt、bool比较这一组是日常开发里最常用的。__eq__(self, other)对应重写后要记得和__hash__保持一致。__ne__(self, other)对应!。在 Python 3 里如果你只定义了__eq__!通常会自动取的反向结果想精细控制时可以单独定义。__lt__、__le__、__gt__、__ge__对应四种排序比较。手动写四个很啰嗦可以用functools.total_ordering装饰器只实现__eq__和一个排序方法其余自动补齐。from functools import total_ordering total_ordering class Version: def __init__(self, major, minor): self.major major self.minor minor def __eq__(self, other): if not isinstance(other, Version): return NotImplemented return (self.major, self.minor) (other.major, other.minor) def __lt__(self, other): if not isinstance(other, Version): return NotImplemented return (self.major, self.minor) (other.major, other.minor) print(Version(2, 0) Version(1, 9)) # True__bool__(self)决定bool(obj)的真假。如果你没有定义它但定义了__len__Python 会退一步用长度是否为 0 来判断真假。如果两者都没定义实例默认为True。所以一个空的自定义容器如果想表现为False需要实现__bool__或__len__。3.2 可视化与类型转换repr、str、int、float__repr__面向开发者__str__面向普通用户。交互式命令行、日志调试时看到的是__repr__print()和str()看到的是__str__。一个很好的习惯是让__repr__输出能直接还原对象的表达式。class DollarAmount: def __init__(self, cents): self.cents cents def __repr__(self): return fDollarAmount({self.cents}) def __str__(self): return f${self.cents / 100:.2f} def __int__(self): return self.cents // 100__int__和__float__用于类型转换比如int(amount)、float(amount)。__complex__对应complex()。还有一个容易被忽略的__index__它专门服务于切片和hex()、oct()、bin()这类函数。要实现这个对象能被list[obj]当作索引用就得定义__index__而不是依赖__int__。我的个人习惯是所有的值对象都至少写一个__repr__。调试时看到Money(100, USD)信息量比__main__.Money object at 0x7f9...大得多。3.3 容器与迭代len、getitem、iter、contains这一组协议决定了对象能不能像序列或容器一样使用。__len__支持len(obj)。__getitem__支持obj[key]。__setitem__支持obj[key] value。__iter__返回迭代器for循环优先使用它。__contains__支持value in obj。__reversed__支持reversed(obj)。Python 相当灵活你不需要把全套方法都写完。比如下面这个简单的斐波那契序列类只实现了__len__和__getitem__就能同时支持下标访问、len()、for循环和in判断class Fib: def __init__(self, n): self.n n def __len__(self): return self.n def __getitem__(self, idx): if idx 0 or idx self.n: raise IndexError a, b 0, 1 for _ in range(idx): a, b b, a b return a f Fib(10) print(len(f)) # 10 print(f[5]) # 5 print(list(f)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] print(8 in f) # Truefor循环在没有__iter__但有__getitem__时会从索引 0 不断递增访问直到遇到IndexError。这个尽力而为的行为很方便但如果你的类型语义更复杂还是建议显式实现__iter__避免不同方法之间的隐式推导让人困惑。3.4 数值运算add、radd、iadd等当你希望自定义对象支持、-、*、/这类运算时需要实现相应的算术魔法方法。一个完整的加法实现通常要考虑三个角色__add__a b时Python 先尝试a.__add__(b)。__radd__如果左边对象的类型不兼容或者左边对象返回了NotImplementedPython 会尝试b.__radd__(a)。__iadd__对应。如果没有定义Python 会退化成a a b。class Money: def __init__(self, cents, currencyUSD): self.cents cents self.currency currency def __add__(self, other): if isinstance(other, Money) and other.currency ! self.currency: raise ValueError(currency mismatch) return Money(self.cents other.cents, self.currency) def __radd__(self, other): if isinstance(other, int): return Money(self.cents other, self.currency) return NotImplemented def __repr__(self): return fMoney({self.cents}, {self.currency!r})这里__radd__不是可有可无的。当你对一组Money对象调用sum()时Python 从0开始累加实际会先计算0 Money(...)。这个表达式会先尝试整数的__add__整数不认识Money于是只能回头找Money.__radd__(0)。没有实现__radd__sum在这组对象上就没法用。3.5 生命周期与上下文new、init、enter、exit、del__new__是创建实例的第一步返回值才是一个新实例__init__负责初始化。大多数时候你只需要写__init__但像不可变类型、单例场景、元类控制实例创建时__new__就很重要了。上下文管理器相关的是__enter__和__exit__这是with语句的底层协议import time class Timer: def __enter__(self): self.start time.perf_counter() return self def __exit__(self, exc_type, exc_val, exc_tb): self.elapsed time.perf_counter() - self.start return False with Timer() as t: # 执行一些耗时操作 pass print(t.elapsed)__exit__的三个参数分别对应异常类型、异常实例和 traceback。如果方法返回True异常会被吞掉with块后面的代码继续执行如果返回False或None异常继续向上抛。这个行为本身很清晰但误写return True吞掉异常的情况确实很常见我在第 4 章会展开讲。__del__是析构方法对象被垃圾回收时调用。它看起来很诱惑可以用来自动释放资源但实际使用时坑非常多循环引用时回收时机不确定解释器退出时可能根本不调用。我的原则是资源清理永远用close()方法或上下文管理器显式完成绝不依赖__del__。4. 比能不能哈希更隐蔽的魔法方法组合陷阱4.1 哈希依赖可变字段放进 set 后才改字段会出现幽灵元素很多人在重写了__hash__就觉得大功告成实际上最隐蔽的坑在后面哈希依赖的字段必须保持稳定。看这个例子class Tag: def __init__(self, name): self.name name def __eq__(self, other): return isinstance(other, Tag) and self.name other.name def __hash__(self): return hash(self.name) tags {Tag(a)} t next(iter(tags)) t.name b print(Tag(b) in tags) # 结果可能为 False对象放进set时是按a的哈希值放入某个桶的。你把t.name改成b之后这个对象实际的哈希值已经变了但它还留在旧的桶里。当你查找Tag(b)时Python 会按b的哈希去另一个桶找自然找不到而旧桶里还躺着一个名字已经是b的对象。这就导致集合里既可能存在重复元素也可能查不到已经存在的元素整个set的逻辑都崩了。规避方法有两个一是让参与哈希和相等的字段在对象生命周期内不可变比如用__slots__或避免对外暴露字段修改方法二是必须改字段时先把对象从集合或字典中移除改完再插回去。4.2eq应该返回 NotImplemented而不是 False比较运算中一个很常见但不够严谨的写法是在__eq__里看到类型不匹配就直接返回Falseclass A: def __eq__(self, other): if not isinstance(other, A): return False # 不好 return self.value other.value问题出现在子类和跨类型比较时。假设B继承自A并且有自己的__eq__那么a b和b a的结果可能不对称A.__eq__直接返回了False根本没给B机会表达自己的比较逻辑。正确的做法是返回NotImplementeddef __eq__(self, other): if not isinstance(other, A): return NotImplemented return self.value other.valueNotImplemented不是异常也不是False它只是告诉 Python当前对象的类型不知道怎么跟另一个类型比较你去试试对方的__eq__吧。 如果双方都返回NotImplemented最终会得到False但这个过程给了每个类表达自己逻辑的机会。处理子类、代理对象、跨库类型时这个细节尤其重要。4.3getattr和getattribute的递归陷阱这两个方法名字很像触发时机完全不同__getattribute__在每次属性访问时都会触发包括内部代码通过self.xxx访问属性__getattr__只在正常属性查找失败之后才触发。很多人实现代理对象时会用__getattr__转发属性class Proxy: def __init__(self, target): self._target target def __getattr__(self, name): return getattr(self._target, name)这个写法里self._target是真实存在于实例字典里的所以不会触发__getattr__不会递归。但如果你在__getattribute__里写成self.xxx那就会无限循环因为访问self.xxx又会调用__getattribute__。标准做法是显式调用object.__getattribute__(self, xxx)。如果你在代理场景里使用了__setattr__来重定向属性写入也要小心同样的循环问题必要时用object.__setattr__。4.4exit返回 True 会把异常静默吞掉上下文管理器的__exit__返回值是一个容易被忽略的开关。如果你在资源清理逻辑里写了一句return True本意可能只是清理完成但实际效果是with块内抛出的异常会被吞掉程序不会崩溃也不会打印任何 traceback。有时候这可能正是你想要的但更多时候它只会让 bug 藏得极深。我建议默认情况下__exit__返回None或False让异常正常向上传播。如果你确实要处理异常就在__exit__内部显式记录日志、做状态标记然后再决定是否返回True。这样代码的意图不会被一个隐晦的布尔返回值掩盖。4.5del不要用来做资源清理__del__的设计初衷是对象被回收前执行清理但它的执行时机在存在循环引用时非常不可控而且在解释器退出阶段部分全局对象可能已经不存在此时访问它们会得到奇怪的结果。把文件句柄、数据库连接这类关键资源的释放放在__del__里等于把程序的稳定性交给垃圾回收器的调度运气。更稳妥的模式是提供显式的close()方法同时支持上下文管理器让调用方用with语句保证释放。如果实在需要兜底清理可以在__del__里只做简单、不易复杂的操作并且做好异常保护。5. 给类做一套魔法方法体检5.1 三个快速自查点每当我写完一个自定义类都会在脑子里过三个检查点是否定义了__eq__如果定义了__hash__是否仍然可用直接用cls.__hash__ is None判断即可。相等判断和哈希判断是否使用相同字段相等对象必须哈希相同否则哈希表行为不可预测。参与哈希的字段是否在对象生命周期内保持不变如果有任何字段可能在对象放进set后被修改说明可哈希设计本身就值得警惕。写一个小的辅助函数可以很方便地批量检查def check_hashable(cls): if cls.__hash__ is None: print(f{cls.__name__}: unhashable) else: print(f{cls.__name__}: hashable)这个函数虽然简单但放到项目的工具模块里调试时很顺手。5.2 用单元测试锁定相等和哈希的一致性魔法方法之间是隐式联动的靠肉眼检查很容易漏。我会建议为关键类写几个小的单元测试把相等对象必须哈希相同这条规则固化下来def test_eq_hash_consistent(): a Money(100, USD) b Money(100, USD) assert a b assert hash(a) hash(b) def test_eq_symmetric(): a Money(100, USD) b Money(100, USD) assert a b assert b a这只是最基本的两个用例。如果类涉及排序还要验证a b、a b、a b之间的传递性如果涉及容器还要验证放进set后能否正确去重、能否作为dict的 key 正常查找。这类测试的收益很高因为魔法方法一旦写错影响往往是全局性的不仅出现在单个操作里。5.3 善用标准库工具最后说说我实际工作中处理魔法方法时的工具选择functools.total_ordering需要排序比较但不想手写六个方法时使用。dataclasses.dataclass批量生成样板代码frozenTrue时自动生成安全哈希。typing.NamedTuple轻量值对象自带相等、哈希、表示、可迭代等基础能力。enum.Enum枚举成员天然单例相等判断由枚举框架保证不需要你操心。__slots__需要限制实例属性、节省内存时使用但它不会自动生成或保留哈希该遵守的规则一条不少。我个人的习惯是任何类只要设计到__eq__第一反应该是去看__hash__。先把相等语义定清楚再决定要不要哈希、用什么字段哈希然后补上类型不匹配时返回NotImplemented的判断。现在项目里已经有挺多值对象但我几乎不手动写大量魔法方法了——能用NamedTuple或frozen dataclass解决的就让标准库帮我生成剩下需要自定义行为的再逐个对照协议手写。这套做法帮我省下了不少排查问题的时间也希望你在遇到unhashable type的时候能想起这篇文章里说的不是 Python 变笨了是它比你先看到了规则冲突。