「Python 进阶之路」系列 Day30
写在前面
模块六(常用数据结构与标准库进阶)到今天收官。前面几篇分别讲了 dict、set、collections、functools,今天回到一个更基础但经常被忽视的话题------那些让自定义对象能"表现得像内置类型"的魔法方法。重点讲一个真实会踩的坑:只重写 __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. repr 与 str:两种字符串表示
不写任何魔法方法时,默认的显示很不友好:
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>
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. eq 与 hash:容易踩的一致性坑
默认的 __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 == 的区别,以及字符串驻留机制到底是怎么回事。