本节目标:掌握常见魔术方法(dunder method)的职责边界,理解
__str__与__repr__的分工、__eq__与__hash__的联动、容器协议、可调用协议、上下文管理协议与运算符重载,并知道哪些运算符不该重载。
适用版本:Python 3.12+(实测 3.14.6)
6.3 魔术方法与运算符重载
我们自定义的类,凭什么能被 print、被放进 set、被 len()、被 +?答案是魔术方法(dunder method,形如 __xxx__)。它们不是「黑魔法」,而是 Python 数据模型的公开接口:实现了对应的方法,你的对象就自动获得对应的语言能力。本节逐一拆解最常用的一批。
6.3.1 str 与 repr 的分工
两个方法都返回字符串,但受众不同:__repr__ 面向开发者,目标是「能重建对象的表达式」;__str__ 面向用户,目标是可读。
class Money:
def __init__(self, amount, currency="CNY"):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __str__(self):
return f"{self.amount:.2f} {self.currency}"
m = Money(12.5)
print("str:", str(m))
print("repr:", repr(m))
print("f-string:", f"{m}")
print("in list:", [m])
输出:
str: 12.50 CNY
repr: Money(12.5, 'CNY')
f-string: 12.50 CNY
规律:交互式回显、容器打印、repr() 都走 __repr__;str() 和 f-string 默认走 __str__。如果只实现 __repr__,__str__ 会退回到它。所以至少实现 __repr__,调试时才有清晰的现场。
6.3.2 eq 与 hash 的联动
定义了 __eq__,Python 会把 __hash__ 自动设为 None,对象立刻不可哈希——因为「相等」和「哈希一致」必须配套,否则会破坏 set/dict 的不变量:
class User:
def __init__(self, uid, name):
self.uid = uid
self.name = name
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.uid == other.uid
u1 = User(1, "a")
print("eq:", u1 == User(1, "b"))
print("__hash__ is None:", User.__hash__ is None)
{User(1, "a")}
# TypeError: cannot use 'User' as a set element (unhashable type: 'User')
输出:
eq: True
__hash__ is None: True
要放回 set,就显式实现 __hash__,并保证参与相等判断的字段也参与哈希:
class User2:
def __init__(self, uid, name):
self.uid = uid
self.name = name
def __eq__(self, other):
if not isinstance(other, User2):
return NotImplemented
return self.uid == other.uid
def __hash__(self):
return hash(self.uid)
print("with hash:", len({User2(1, "a"), User2(1, "b")})) # 1
注意 __eq__ 返回 NotImplemented(不是 False)用于「类型不认识」,这样 Python 会尝试反向比较。
6.3.3 容器协议:len / getitem / iter / contains
实现这几个方法,对象就能用 len()、下标、in、for:
class Playlist:
def __init__(self, songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __getitem__(self, index):
return self._songs[index]
def __contains__(self, item):
return item in self._songs
def __iter__(self):
return iter(self._songs)
p = Playlist(["A", "B", "C"])
print("len:", len(p))
print("p[0]:", p[0], "p[-1]:", p[-1])
print("slice:", p[1:])
print("'B' in p:", "B" in p)
print("iterate:", [s for s in p])
输出:
len: 3
p[0]: A p[-1]: C
slice: ['B', 'C']
'B' in p: True
iterate: ['A', 'B', 'C']
有个细节:__getitem__ 会顺带提供迭代能力。即使不写 __iter__,for 也会从下标 0 开始一直取到 IndexError 为止:
class OnlyGetItem:
def __init__(self, data): self.data = data
def __getitem__(self, i): return self.data[i]
print(list(OnlyGetItem([10, 20, 30]))) # [10, 20, 30]
in 同理:没有 __contains__ 时会退化为迭代查找。显式实现只是更快。
6.3.4 call:让实例可调用
给类实现 __call__,它的实例就能像函数一样被调用:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
double = Multiplier(2)
print("call:", double(21))
print("callable:", callable(double))
输出:
call: 42
callable: True
这适合「带状态的函数」:Multiplier(2) 把配置存进实例,double 则是个记住了倍率的可调用对象,比闭包更易读、可携带方法。
6.3.5 上下文管理协议:enter 与 exit
with 语句依赖 __enter__ / __exit__。前者在进入时调用,返回值绑定给 as 后的名字;后者在退出时调用,负责清理:
class Timer:
def __init__(self, name): self.name = name
def __enter__(self):
print(f"[enter] {self.name}")
return self
def __exit__(self, exc_type, exc, tb):
print(f"[exit] {self.name} exc={exc_type}")
return False # 不吞异常
with Timer("job") as t:
print("working:", t.name)
输出:
[enter] job
working: job
[exit] job exc=None
即使 with 块里抛了异常,__exit__ 也一定会执行,这保证资源被释放。__exit__ 返回 True 会「吞掉」异常,返回 False(或 None)则让异常继续向上传播。这里 raise ValueError("x") 会正常抛出,被外层 except 捕获。上下文管理器是第 7 章的重点,这里先建立印象。
6.3.6 运算符重载:add / radd / iadd
运算符在背后就是魔术方法。a + b 会先尝试 a.__add__(b),不行再试 b.__radd__(a):
class Vec:
def __init__(self, x, y): self.x, self.y = x, y
def __repr__(self): return f"Vec({self.x}, {self.y})"
def __add__(self, other):
if isinstance(other, Vec):
return Vec(self.x + other.x, self.y + other.y)
return NotImplemented
def __radd__(self, other):
if other == 0: # 让 sum() 能工作
return self
return NotImplemented
def __iadd__(self, other):
self.x += other.x; self.y += other.y
return self
print("add:", Vec(1, 2) + Vec(3, 4))
print("radd with 0:", 0 + Vec(1, 2))
print("sum:", sum([Vec(1, 1), Vec(2, 2)]))
输出:
add: Vec(4, 6)
radd with 0: Vec(1, 2)
sum: Vec(3, 3)
sum() 从整数 0 开始累加,第一次就是 0 + Vec(...),所以 __radd__ 里必须特判 other == 0。返回 NotImplemented 而不是抛异常,是为了让 Python 有机会尝试对方的反向方法;两边都返回 NotImplemented 时,才由解释器抛出 TypeError——例如 Vec(1, 2) + 5:
TypeError: unsupported operand type(s) for +: 'Vec' and 'int'
不要重载语义不直观的运算符:__add__ 表示「相加」,别用它做「追加到列表」之类的事。像 Meters(1) + 5 这种类型不匹配就该返回 NotImplemented,让错误显式暴露。
6.3.7 format 与 f-string
f-string 里的格式说明符(: 后面那段)会交给 __format__。自定义它,就能给对象定义专属格式化:
class Duration:
def __init__(self, seconds): self.seconds = seconds
def __str__(self): return f"{self.seconds}s"
def __format__(self, spec):
if spec == "m:ss":
m, s = divmod(self.seconds, 60)
return f"{m}:{s:02d}"
if spec == "human":
h, rem = divmod(self.seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}小时{m}分{s}秒"
return format(str(self), spec)
d = Duration(3725)
print(f"{d}")
print(f"{d:m:ss}")
print(f"{d:human}")
print(f"{d:>12}")
输出:
3725s
62:05
1小时2分5秒
3725s
最后一行把未知的 >12 透传给 str 的对齐格式化,是常见的兼容写法。有了 __format__,时间、金额、坐标这类值都能在 f-string 里「按需换格式」。
小结
__repr__面向开发者(可重建),__str__面向用户(可读);只写一个就写__repr__。- 定义
__eq__会把__hash__置为None,对象随即不可哈希;要放回set必须显式实现__hash__,且哈希字段与相等字段保持一致。 __len__/__getitem__/__iter__/__contains__让对象变成容器;__getitem__单独存在时也能提供迭代与in。__call__让实例可调用,适合「带状态的函数」。__enter__/__exit__支撑with,无论是否抛异常__exit__都会执行,返回True可吞掉异常。- 运算符重载用
__add__/__radd__/__iadd__,类型不匹配时返回NotImplemented;不要重载语义不直观的运算符。 __format__决定 f-string 里:之后的格式,可实现专属格式化并兼容透传。
到这里,面向对象这一章就完整了:类与属性查找、继承与组合、魔术方法,构成了 Python 对象模型的三根支柱。下一章我们转向错误处理——with 背后的上下文管理协议、异常层次与自定义异常,都建立在今天这些魔术方法之上,从 异常层次与 try/except/else/finally
开始。
阅读导航:上一节:6.2 继承、组合与 dataclass · 下一节:7.1 异常层次与 try/except/else/finally 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。