《Python编程入门》6.3 魔术方法与运算符重载

本节讲清魔术方法如何让自定义对象融入 Python 语法:__str__ 与 __repr__ 的分工、定义 __eq__ 后 __hash__ 被置空的陷阱、用容器协议让对象支持 len 与迭代、__call__ 让实例可调用、上下文管理协议,以及 __add__/__radd__/__iadd__ 运算符重载与 __format__ 配合 f-string 的写法。

本节目标:掌握常见魔术方法(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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时