只重写了 __eq__,为什么类突然变得不可哈希了?常用魔法方法大盘点

「Python 进阶之路」系列 Day30

写在前面

模块六(常用数据结构与标准库进阶)到今天收官。前面几篇分别讲了 dictsetcollectionsfunctools,今天回到一个更基础但经常被忽视的话题------那些让自定义对象能"表现得像内置类型"的魔法方法。重点讲一个真实会踩的坑:只重写 __eq__ 而不管 __hash__,Python 会做一件你可能没意识到的事。


一、是什么:让自定义对象融入Python语法体系的协议

魔法方法(dunder methods):以双下划线开头结尾的方法,Python 在特定语法操作时会自动调用它们,让自定义类能够像内置类型一样融入 Python 的语法体系。

  • __repr__ :对象的"官方"字符串表示,面向开发者调试,repr(obj) 和交互式解释器直接显示对象时调用
  • __str__ :对象的"非正式"字符串表示,面向最终用户,str(obj)/print(obj) 调用;没定义时会退回用 __repr__
  • __eq__ :定义 == 运算符的行为,判断两个对象是否"值相等"(不是同一个对象)
  • __hash__ :定义对象的哈希值,让对象能作为 dict 的 key 或放进 set(呼应 Day26/27)
  • __len__ :定义 len() 函数的行为

二、为什么:鸭子类型哲学的具体体现

这些协议的设计目的,是让自定义对象不需要继承某个特定接口,只要实现了对应的魔法方法,就能自然地融入 Python 的内置语法------这正是 Day15 讲的鸭子类型哲学的具体体现。

__repr____str__ 的核心区别在于目标读者不同:__repr__ 追求"无歧义、能复现对象"(官方建议理想情况下 eval(repr(obj)) 能重建出一个等价对象,虽然不总是可行),__str__ 追求"好看、易读",是给最终用户看的展示形式。


三、怎么用

1. reprstr:两种字符串表示

不写任何魔法方法时,默认的显示很不友好:

python 复制代码
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Point(1, 2)
print(p)   # <__main__.Point object at 0x10337d790>
flowchart LR A[调用print或str] --> B{有没有<br/>定义__str__} B -->|有| C[使用__str__的结果] B -->|没有| D[退回使用<br/>__repr__的结果] E[调用repr或交<br/>互式显示] --> F[固定使用<br/>__repr__的结果]
python 复制代码
class Point2:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f"Point2(x={self.x}, y={self.y})"   # 面向开发者
    def __str__(self):
        return f"({self.x}, {self.y})"                # 面向用户

p2 = Point2(1, 2)
print(p2)         # (1, 2)           ------ print优先用__str__
print(repr(p2))    # Point2(x=1, y=2)  ------ repr()固定用__repr__

没有 __str__ 时,会退回用 __repr__

python 复制代码
class Point3:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f"Point3(x={self.x}, y={self.y})"

print(Point3(1, 2))   # Point3(x=1, y=2) ------ 没有__str__,print()退回用__repr__

2. eqhash:容易踩的一致性坑

默认的 __eq__ 继承自 object,按 id() 比较,两个内容相同的对象默认并不相等:

python 复制代码
class Point4:
    def __init__(self, x, y):
        self.x, self.y = x, y

a, b = Point4(1, 2), Point4(1, 2)
print(a == b)   # False ------ 默认按id比较

class Point5:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        if not isinstance(other, Point5):
            return NotImplemented
        return self.x == other.x and self.y == other.y

c, d = Point5(1, 2), Point5(1, 2)
print(c == d)   # True ------ 重写后按值比较

关键的坑:只重写 __eq__ 不重写 __hash__,这个类会自动变成不可哈希!

python 复制代码
print(Point5.__hash__)   # None ------ Python自动把__hash__设为None
hash(c)
# TypeError: unhashable type: 'Point5'
{c, d}
# TypeError: unhashable type: 'Point5'

原因:Python 约定"相等的对象必须有相同的哈希值"(呼应 Day26 哈希表的约定)。默认的 __hash__ 是基于 id() 的,如果只改了 __eq__(值相等)却保留默认 __hash__(id 不同则哈希值不同),会出现"两个对象 == 判断相等,但哈希值不同"的矛盾状态,导致对象放进 dict/set 后出现诡异的行为。Python 检测到你重写了 __eq__ 却没管 __hash__,会主动把 __hash__ 设为 None,从源头上禁止这种矛盾状态发生,而不是留一个隐藏的坑等你踩。

正确做法:同时重写 __eq____hash__,且逻辑保持一致

python 复制代码
class Point6:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        if not isinstance(other, Point6):
            return NotImplemented
        return self.x == other.x and self.y == other.y
    def __hash__(self):
        return hash((self.x, self.y))   # 相等的对象必须有相同哈希值

e, f = Point6(1, 2), Point6(1, 2)
print(e == f, hash(e) == hash(f))   # True True
print(len({e, f}))                    # 1 ------ 正确地被当成同一个元素去重

3. len 与 dataclass

__len__ 让自定义类支持 len()

python 复制代码
class MyCollection:
    def __init__(self, items):
        self.items = items
    def __len__(self):
        return len(self.items)

print(len(MyCollection([1, 2, 3, 4])))   # 4

如果只是想要基本的 __init__/__repr__/__eq__@dataclass 装饰器能自动生成,不用手写这些魔法方法:

python 复制代码
from dataclasses import dataclass

@dataclass
class Point7:
    x: int
    y: int

g, h = Point7(1, 2), Point7(1, 2)
print(g)          # Point7(x=1, y=2) ------ 自动生成的__repr__
print(g == h)      # True ------ 自动生成的__eq__按值比较

四、面试追问

Q1:__repr____str__ 的区别是什么?

__repr__ 面向开发者,追求无歧义、理想情况下能复现对象,repr() 和交互式解释器直接显示对象时固定调用它;__str__ 面向最终用户,追求好看易读,print()/str() 优先调用它,没有定义 __str__ 时会退回用 __repr__

Q2:为什么重写了 __eq__ 通常也要重写 __hash__

因为 Python 约定"相等的对象必须有相同的哈希值",这是哈希表正确工作的前提(Day26 讲过)。默认的 __hash__ 基于 id(),如果只重写了按值比较的 __eq__ 而不管 __hash__,会出现"两个对象值相等但哈希值不同"的矛盾状态,导致对象放进 dict/set 后出现异常行为。

Q3:只重写 __eq__ 不重写 __hash__ 会发生什么?

Python 会自动把这个类的 __hash__ 设为 None,让它变成不可哈希。尝试对这个类的实例调用 hash(),或者把它放进 set、当作 dict 的 key,都会直接抛出 TypeError------这是 Python 主动帮你规避"相等但哈希值不同"这种矛盾状态的机制,而不是留一个隐藏的坑等你运行时才发现。

Q4:__hash__ 的默认实现是什么,重写时要遵守什么约定?

默认实现基于对象的 id(),保证同一个对象哈希值全程不变、不同对象大概率哈希值不同。重写 __hash__ 时必须遵守的约定是:只要两个对象通过 __eq__ 判断相等,它们的 __hash__ 结果也必须相等,通常直接对参与相等比较的那些字段组成的元组调用 hash() 即可满足这个约定。

Q5:除了这几个,Python 里还有哪些常见的魔法方法?

呼应前面几个模块讲过的内容:__new__/__init__ 负责对象创建的两个阶段(Day12)、__enter__/__exit__ 支撑上下文管理器协议(Day06)、__iter__/__next__ 支撑迭代器协议(Day07)。这些魔法方法背后是同一套设计思路------只要实现了对应的协议方法,自定义对象就能自然接入 Python 相应的语法机制,不需要继承特定的接口。


下一篇预告

模块六到这里全部完成。Day31 开始进入模块七------面试高频题串讲,第一篇讲 is vs == 的区别,以及字符串驻留机制到底是怎么回事。

相关推荐
程序员cxuan1 小时前
Claude Fable 5.1 的提示词又被扒了
人工智能·后端·程序员
2601_962885721 小时前
如何用 Python 做 A 股全市场扫描选股?(多条件筛选实战)
开发语言·python
步行cgn1 小时前
Spring Boot 绑定简单 Bean 详解
java·spring boot·后端
未秃头的程序猿1 小时前
分库分表一年后,我复盘了当时最该想清楚的三件事
java·数据库·后端
零域码客1 小时前
一文掌握 jQuery + Flask 全栈核心知识点(前端交互 + 后端接口 + 前后端联调)
前端·python·ajax·flask·jquery·前后端联调·web全栈
岁月如歌77861 小时前
顺序消息与幂等消费完全指南:从队列有序到接口幂等
java·后端·架构
合橱瑰1 小时前
从“假智能”到“真闭环”:Go 向量推理引擎的零硬编码调优实践
后端·go
SamDeepThinking1 小时前
不改逻辑、不拆方法:仅靠「调整代码顺序」提升可读性
java·后端·程序员
泡海椒1 小时前
适配老旧项目:JQuick-Java兼容Java8+环境改造迁移实战指南
后端