魔术方法全景
平时写 Python,我们其实一直在和一套"看不见的约定"打交道:print(obj) 能打印出东西、obj[0] 能按下标取值、a + b 能做加法、len(obj) 能算长度。这些写法之所以对自定义类也照样好使,是因…
平时写 Python,我们其实一直在和一套"看不见的约定"打交道:print(obj) 能打印出东西、obj[0] 能按下标取值、a + b 能做加法、len(obj) 能算长度。这些写法之所以对自定义类也照样好使,是因为 Python 会去对象身上找一批名字很特殊的方法——它们都以双下划线开头、双下划线结尾,比如我们已经非常熟悉的 __init__。这类方法俗称魔术方法(Magic Methods),也有人叫它们双下方法(dunder methods)。
魔术方法有一个共同脾气:几乎从不被你手动调用,全部由解释器在特定时机自动触发。你写 a + b,Python 背后找的是 a.__add__;你写 len(a),Python 找的是 a.__len__;你写 print(a),Python 找的是 a.__str__。换句话说,魔术方法就是你自定义类与 Python 语法之间的"接线端子"——接对了线,你的对象就能无缝融入这门语言的各种语法里。
这一章我们沿着"一个对象从出生到销毁"的时间线,把这些端子挨个接一遍:先弄清对象是怎么被造出来的(__new__、__init__),再看对象怎么才能被当函数调用(__call__),然后依次展开字符串表示、比较、算术、容器、类型转换、属性拦截,最后送别对象(__del__)。
1. 对象的诞生:先造壳,再装修
平时写 Employee("阿明", 7),感觉是一步完成的,实际上解释器背地里分了两步走:
- 第一步,调用
__new__:负责向内存要一块空间,把对象的"空壳"造出来; - 第二步,调用
__init__:拿到这个壳之后,往里面填属性,完成"装修"。
我们完全可以亲手把这套流程模拟一遍:
def simulate_instantiation(cls, *args, **kwargs):
# 第一步:请 __new__ 造出空壳(毛坯对象)
obj = cls.__new__(cls, *args, **kwargs)
# 第二步:只有当这个壳确实是 cls(或其子类)的实例时,才会进行装修
if isinstance(obj, cls):
obj.__init__(*args, **kwargs)
# 第三步:交出成品
return obj
class Employee:
def __init__(self, name, level):
self.name = name
self.level = level
def report(self):
print(f"我是 {self.name},职级 L{self.level}")
emp = simulate_instantiation(Employee, "阿明", 7)
emp.report() # 我是 阿明,职级 L7
注意上面那个 isinstance 检查,它不是摆设——__new__ 是有机会"不听话"的,它可以返回一个根本不属于当前类的对象。遇到这种情况,__init__ 会被直接跳过。这个细节正是下一节几个巧妙玩法的根基。
2. 重写 __new__ 的三种经典玩法
2.1 全局只此一个:单例模式
有些东西天然只该存在一份,比如全局配置。让类管住自己:第一次有人来要实例,就造一个存好;之后再来要,直接把存货递出去。
class AppConfig:
_sole = None
def __new__(cls):
if cls._sole is None:
cls._sole = super().__new__(cls)
return cls._sole
cfg_a = AppConfig()
cfg_b = AppConfig()
print(cfg_a is cfg_b) # True —— 无论要多少次,给的都是同一个对象
2.2 造过就不重造:对象池
单例是"一个类一个对象",再进一步就是"同一把钥匙开同一个锁":以参数为钥匙,造过的对象缓存起来,重复请求直接复用。
class TileFactory:
"""游戏里的地砖工厂:同一种地砖只生产一次,之后反复复用"""
_pool = {}
def __new__(cls, tile_id):
if tile_id not in cls._pool:
cls._pool[tile_id] = super().__new__(cls)
return cls._pool[tile_id]
grass_a = TileFactory("grass")
grass_b = TileFactory("grass")
water = TileFactory("water")
print(grass_a is grass_b) # True —— 草地砖是同一块
print(grass_a is water) # False —— 水地砖另造了一块
2.3 偷梁换柱:让 __new__ 返回别的东西
__new__ 并不强制返回本类实例。一旦它返回了"外人",__init__ 就不会执行——我们可以利用这一点做数据兜底:
class ScoreInt:
"""百分制分数:传入负数时,不报错,直接退回一个普通的 0"""
def __new__(cls, value):
if value < 0:
return 0 # 注意:这是 int 类型的 0,不是 ScoreInt 的实例
return super().__new__(cls)
def __init__(self, value):
print("ScoreInt.__init__ 执行了")
self.value = value
good = ScoreInt(88)
print(type(good), good.value) # <class 'ScoreInt'> 88
bad = ScoreInt(-5)
print(type(bad), bad) # <class 'int'> 0,且上面那行 __init__ 的打印没出现!
3. 让实例像函数一样被调用:__call__
Python 里有个词叫可调用对象(Callable),指一切能紧跟一对括号 () 的东西。用内置函数 callable() 可以当场验明正身:
print(callable(len)) # True,内置函数
print(callable(dict)) # True,类本身也是可调用的(调用它就是造实例)
print(callable([1, 2])) # False,列表不能调用
print(callable(lambda x: x)) # True,lambda 表达式
函数是天然的 callable,类也是(所以 Employee("阿明", 7) 本质上是在"调用"这个类)。但普通实例默认不是 callable——想让它也能被一对括号调用,就得在类里实现 __call__:
class Calibrator:
"""仪器校准器:给每次读数加上一个固定偏移量"""
def __init__(self, offset):
self.offset = offset
def __call__(self, reading):
return reading + self.offset
fix = Calibrator(-2)
print(callable(fix)) # True —— 从此实例也" callable "了
print(fix(100)) # 98
print(fix(50)) # 48
这里务必建立一个直觉:fix(100) 和 Calibrator.__call__(fix, 100) 是同一件事。括号里的参数原封不动传给 __call__。
3.1 玩法一:造出"预配置"的函数
闭包能干的事,__call__ 都能干,而且状态放在实例身上,更直观:
class Resizer:
"""图片缩放器:出厂时定好缩放比例"""
def __init__(self, ratio):
self.ratio = ratio
def __call__(self, pixel):
return round(pixel * self.ratio)
shrink = Resizer(0.5)
enlarge = Resizer(2)
print(shrink(1920)) # 960
print(enlarge(1920)) # 3840
3.2 玩法二:自带记忆的回调
函数每次被调用都是"失忆"的,而实例能记住历史。做一个带计数器的审计日志就是几行代码的事:
class AuditTrail:
def __init__(self, tag):
self.tag = tag
self.entries = 0
def __call__(self, message):
self.entries += 1
print(f"[{self.tag}] 第{self.entries}条:{message}")
audit = AuditTrail("支付")
audit("订单已创建") # [支付] 第1条:订单已创建
audit("扣款成功") # [支付] 第2条:扣款成功
4. 把对象打印得体面一点:__str__ 与 __repr__
不实现这两个方法时,print(obj) 吐出来的是类似 <__main__.Employee object at 0x10c8b2f10> 的东西,对人极不友好。两个方法各管一摊:
class Movie:
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
def __str__(self):
"""给普通用户看:说人话"""
return f"《{self.title}》片长 {self.minutes} 分钟"
def __repr__(self):
"""给开发者看:最好能直接复制粘贴来重建对象"""
return f"Movie({self.title!r}, {self.minutes})"
film = Movie("星际漫游", 148)
print(film) # 《星际漫游》片长 148 分钟 —— 走的是 __str__
print(str(film)) # 同上
print(repr(film)) # Movie('星际漫游', 148) —— 走的是 __repr__
# 在交互式解释器里直接敲变量名回车,显示的也是 __repr__ 的结果
实践建议:两个都写。如果图省事只写一个,那就写 __repr__——因为没找到 __str__ 时,Python 会退回来用 __repr__ 顶上;反过来则不行。
5. 让对象支持比较:==、< 一家人
想让两个自定义对象能比大小,就把对应的方法补齐:
| 运算符 | 魔术方法 |
|---|---|
== |
__eq__ |
!= |
__ne__ |
< |
__lt__ |
<= |
__le__ |
> |
__gt__ |
>= |
__ge__ |
class Staff:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def __eq__(self, other):
if not isinstance(other, Staff):
return NotImplemented # 告诉 Python:"比不了",让它另想办法
return self.salary == other.salary
def __lt__(self, other):
if not isinstance(other, Staff):
return NotImplemented
return self.salary < other.salary
def __le__(self, other):
return self < other or self == other
def __gt__(self, other):
return not self <= other
def __ge__(self, other):
return not self < other
def __ne__(self, other):
return not self == other
def __repr__(self):
return f"Staff({self.name!r}, {self.salary})"
lao_wang = Staff("老王", 12000)
xiao_li = Staff("小李", 9500)
print(lao_wang == xiao_li) # False —— 薪资不同
print(lao_wang > xiao_li) # True
print(lao_wang <= xiao_li) # False
# 比较方法就位后,sorted 也能直接用了
team = [xiao_li, lao_wang]
print(sorted(team)) # [Staff('小李', 9500), Staff('老王', 12000)]
这里出现的 NotImplemented 值得多说一句:它不是异常,而是一个信号,意思是"本对象不认识对方,没法比",Python 收到信号后会转而尝试对方的反向方法(比如 b.__gt__(a))。拿它和 NotImplementedError 区分清楚——后者是异常,表示"这功能还没写"。
偷懒的办法:total_ordering
六个比较方法全写一遍确实啰嗦。标准库提供了 @functools.total_ordering,只要给出 __eq__ 和剩下任意一个(通常是 __lt__),其余四个它会自动帮你推导:
from functools import total_ordering
@total_ordering
class Staff:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def __eq__(self, other):
if not isinstance(other, Staff):
return NotImplemented
return self.salary == other.salary
def __lt__(self, other):
if not isinstance(other, Staff):
return NotImplemented
return self.salary < other.salary
def __repr__(self):
return f"Staff({self.name!r}, {self.salary})"
6. 让对象参与四则运算
同样一套思路,把运算符也接管过来。以平面位移为例:
class Move:
"""机器人在平面上的位移(横向 dx,纵向 dy)"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
def __add__(self, other):
"""+:位移叠加"""
if isinstance(other, Move):
return Move(self.dx + other.dx, self.dy + other.dy)
return NotImplemented # 交给 Python 去试 other 的 __radd__
def __sub__(self, other):
"""-:位移相减"""
if isinstance(other, Move):
return Move(self.dx - other.dx, self.dy - other.dy)
return NotImplemented
def __mul__(self, factor):
"""*:位移 × 倍数"""
if isinstance(factor, (int, float)):
return Move(self.dx * factor, self.dy * factor)
return NotImplemented
def __rmul__(self, factor):
"""右乘:倍数 × 位移(3 * m 时,int 不知道怎么乘 Move,就反过来找这里)"""
return self * factor # 直接复用 __mul__
def __truediv__(self, factor):
"""/:位移 ÷ 倍数"""
if isinstance(factor, (int, float)):
return Move(self.dx / factor, self.dy / factor)
return NotImplemented
def __neg__(self):
"""取反:-m,方向调转 180 度"""
return Move(-self.dx, -self.dy)
def __abs__(self):
"""abs(m):位移的直线距离"""
return (self.dx ** 2 + self.dy ** 2) ** 0.5
def __repr__(self):
return f"Move({self.dx}, {self.dy})"
step_a = Move(1, 2)
step_b = Move(3, 4)
print(step_a + step_b) # Move(4, 6)
print(step_b - step_a) # Move(2, 2)
print(step_a * 3) # Move(3, 6)
print(2 * step_a) # Move(2, 4) —— int 不会乘 Move,触发的是 __rmul__
print(-step_a) # Move(-1, -2)
print(abs(step_a)) # 2.23606797749979
__rmul__ 这类"反向"方法容易被忽略:当左操作数不认识右操作数时(比如 2 * step_a,整数类型可不知道怎么乘一个 Move),Python 会退一步,试试右操作数的 __rmul__。这就是上面 2 * step_a 能跑通的原因。
常用算术魔术方法对照表:
| 写法 | 魔术方法 | 含义 |
|---|---|---|
a + b |
__add__ |
加 |
a - b |
__sub__ |
减 |
a * b |
__mul__ |
乘 |
a / b |
__truediv__ |
真除法 |
a // b |
__floordiv__ |
整除 |
a % b |
__mod__ |
取余 |
a ** b |
__pow__ |
幂 |
+a |
__pos__ |
正号 |
-a |
__neg__ |
负号 |
abs(a) |
__abs__ |
绝对值 |
7. 让对象像容器一样工作
列表、字典之所以能 len()、取下标、in 判断、for 遍历,靠的也是一组魔术方法,合称容器协议。自定义类照着实现,就能获得同等待遇:
class Playlist:
"""播放列表:对外的用法和列表几乎一模一样"""
def __init__(self):
self._tracks = []
def add(self, track):
self._tracks.append(track)
def __len__(self):
"""len(playlist)"""
return len(self._tracks)
def __getitem__(self, index):
"""playlist[index],天然支持切片"""
return self._tracks[index]
def __setitem__(self, index, track):
"""playlist[index] = track"""
self._tracks[index] = track
def __delitem__(self, index):
"""del playlist[index]"""
del self._tracks[index]
def __contains__(self, track):
"""track in playlist"""
return track in self._tracks
def __iter__(self):
"""for track in playlist"""
return iter(self._tracks)
def __repr__(self):
return f"Playlist({self._tracks!r})"
my_list = Playlist()
my_list.add("晴天")
my_list.add("海阔天空")
my_list.add("夜曲")
print(len(my_list)) # 3
print(my_list[0]) # 晴天
print(my_list[1:]) # ['海阔天空', '夜曲'] —— 切片也没问题
print("夜曲" in my_list) # True
for track in my_list:
print(track)
# 晴天
# 海阔天空
# 夜曲
8. 类型转换三件套
int()、float()、bool() 这三个内置转换同样有对应的魔术方法可接:
class Percent:
"""百分比数值,比如 85.5 表示 85.5%"""
def __init__(self, value):
self.value = value
def __int__(self):
"""int(p):抹掉小数取整"""
return int(self.value)
def __float__(self):
"""float(p):转成浮点数"""
return float(self.value)
def __bool__(self):
"""bool(p):0% 视为 False,其余为 True"""
return self.value != 0
def __repr__(self):
return f"Percent({self.value})"
p = Percent(85.5)
print(int(p)) # 85
print(float(p)) # 85.5
print(bool(p)) # True
print(bool(Percent(0))) # False
__bool__ 还有个隐藏福利:任何需要真假判断的场合(if obj:)都会用到它。顺带一提,如果只实现 __len__ 不实现 __bool__,Python 会用 len(obj) != 0 来顶替真假判断。
9. 属性访问的"安检门"
对象的属性读取、赋值、删除,其实都能被拦下来做检查。三个方法各守一道门:
__getattr__:访问不存在的属性时触发(兜底);__setattr__:任何属性赋值时触发;__delattr__:删除属性时触发。
一个把属性收进字典管理的会话对象:
class Session:
def __init__(self):
# 注意:这里必须借道 object.__setattr__,否则会陷入无限递归
object.__setattr__(self, "_store", {})
def __getattr__(self, name):
if name in self._store:
return self._store[name]
raise AttributeError(f"'{type(self).__name__}' 身上没有 '{name}' 这个属性")
def __setattr__(self, name, value):
if name.startswith("_"):
# 下划线开头的内部属性走正常通道,绕开拦截,避免递归
object.__setattr__(self, name, value)
else:
self._store[name] = value
def __delattr__(self, name):
if name in self._store:
del self._store[name]
else:
raise AttributeError(f"'{type(self).__name__}' 身上没有 '{name}' 这个属性")
def __repr__(self):
return f"Session({self._store!r})"
sess = Session()
sess.user = "阿明" # 触发 __setattr__,存入 _store
sess.vip = True
print(sess.user) # 阿明 —— 触发 __getattr__
del sess.vip # 触发 __delattr__
print(sess) # Session({'user': '阿明'})
重点警告: __setattr__ 会拦下所有赋值动作,包括它自己内部写的 self.xxx = value——那会再次触发 __setattr__,于是套娃式递归直到爆栈。所以内部赋值必须改走 object.__setattr__(self, name, value) 这条直达通道。
__getattr__ 与 __getattribute__ 的分工
还有个更底层的 __getattribute__,两者名字只差一截,行为却大不相同:
__getattr__:只在属性找不到时才被调用(救场队员);__getattribute__:每一次属性访问都会先路过它(总闸门)。
class Tracer:
def __init__(self):
self.present = 100
def __getattribute__(self, name):
# 总闸门:无论访问什么属性,都会先到这里报到
print(f"有代码正在读取属性: {name}")
# 必须借道 object.__getattribute__,否则自己访问自己,递归到死
return object.__getattribute__(self, name)
def __getattr__(self, name):
# 能走到这里,说明 __getattribute__ 没找到,来问我要个说法
return f"'{name}' 不存在,给个默认值顶一下"
t = Tracer()
print(t.present) # 先打印访问日志,再输出 100
print(t.ghost) # 先触发 __getattribute__(找不到),再落入 __getattr__
__getattribute__ 性能敏感又极易递归,除非真的要监控所有属性访问,一般别碰它。
10. 对象的谢幕:__del__
有生就有死。当对象的引用计数归零、即将被回收时,__del__ 会被调用,适合放一点"临终留言":
class FileHandle:
def __init__(self, path):
self.path = path
print(f"打开文件: {path}")
def __del__(self):
print(f"关闭文件: {self.path}")
fh = FileHandle("report.csv")
del fh # 关闭文件: report.csv
不过要泼一盆冷水:__del__ 被调用的确切时机并不保证(受垃圾回收机制和循环引用影响,解释器退出时也可能干脆不调)。所以千万别把关键资源清理押在它身上——真正可靠的资源管理方案是上下文管理器,后面的章节会专门安排。
11. 常用魔术方法速查表
| 分类 | 方法 | 什么情况下触发 |
|---|---|---|
| 创建 | __new__ |
造出实例本体 |
| 创建 | __init__ |
实例造好后做初始化 |
| 可调用 | __call__ |
obj() |
| 字符串 | __str__ |
print()、str() |
| 字符串 | __repr__ |
repr()、交互式环境直接回显 |
| 比较 | __eq__ |
== |
| 比较 | __ne__ |
!= |
| 比较 | __lt__ |
< |
| 比较 | __le__ |
<= |
| 比较 | __gt__ |
> |
| 比较 | __ge__ |
>= |
| 算术 | __add__ |
+ |
| 算术 | __sub__ |
- |
| 算术 | __mul__ |
* |
| 算术 | __truediv__ |
/ |
| 算术 | __neg__ |
一元 - |
| 算术 | __abs__ |
abs() |
| 容器 | __len__ |
len() |
| 容器 | __getitem__ |
obj[key] |
| 容器 | __setitem__ |
obj[key] = value |
| 容器 | __delitem__ |
del obj[key] |
| 容器 | __contains__ |
in |
| 容器 | __iter__ |
for ... in |
| 转换 | __int__ |
int() |
| 转换 | __float__ |
float() |
| 转换 | __bool__ |
bool()、if obj: |
| 属性 | __getattr__ |
访问不存在的属性 |
| 属性 | __getattribute__ |
访问任意属性 |
| 属性 | __setattr__ |
给属性赋值 |
| 属性 | __delattr__ |
删除属性 |
| 生命周期 | __del__ |
对象被回收 |
习题
一、全局唯一的事件总线
亲手实现一遍单例模式:写一个 EventBus 类,无论谁实例化多少次,拿到的都是同一个对象。
bus_a = EventBus()
bus_b = EventBus()
print(bus_a is bus_b) # 应该输出 True
二、带记忆的里程表
编写一个 Odometer 类:
- 实例化时传入初始读数;
- 每调用一次实例,读数 +1,并返回新读数;
reset()方法把读数归位到初始值;get()方法查看当前读数。
odo = Odometer(1000)
print(odo()) # 1001
odo() # 1002
print(odo.get()) # 1002
odo.reset()
print(odo.get()) # 1000
三、思考题
不运行代码,先脑内推演:下面这段代码输出什么?为什么?
class Bell:
def __call__(self):
print("叮 —— 基础铃声")
class Alarm(Bell):
def __call__(self):
print("嘀嘀!警报铃声")
super().__call__()
alarm = Alarm()
alarm()
四、频率类(综合练习,可借助 AI)
编写一个 Frequency 类,表示"每 span 个单位时间里发生 count 次"的频率(例如 Frequency(1, 2) 表示每 2 秒发生 1 次),要求:
f1 = Frequency(1, 2) # 每 2 秒 1 次
f2 = Frequency(1, 3) # 每 3 秒 1 次
print(f1 + f2) # 5/6 —— 两路事件合并后的频率
print(f1 - f2) # 1/6
print(f1 * f2) # 1/6
print(f1 / f2) # 3/2
print(f1 == f2) # False
print(f1 > f2) # True
print(float(f1)) # 0.5
print(str(f1)) # 1/2
print(repr(f1)) # Frequency(1, 2)
提示:
- 构造时对
count/span做约分,表示就唯一了(Frequency(2, 4)和Frequency(1, 2)应视为相等); - 实现
__str__、__repr__; - 实现
__eq__、__lt__(其余比较可交给@total_ordering推导); - 实现
__add__、__sub__、__mul__、__truediv__; - 实现
__float__。
下一章:09 · 描述符与元类