运算符重载入门:Python中的魔法方法
运算符重载,说白了就是让「+」「-」「*」「/」这些符号能处理你自定义的对象。Python 里没有隐式的运算符重载——你想让两个对象相加,就得自己告诉解释器怎么加。
我刚开始学的时候觉得这玩意儿挺玄乎。后来在项目中写了一个向量类,发现直接用 v1 + v2 比写 v1.add(v2) 舒服太多了。嗯,这就是运算符重载的魅力。
什么是魔法方法?
Python 里有一类特殊方法,名字前后各带两个下划线,比如 __add__、__sub__。它们不直接调用,而是由 Python 解释器在特定场景下自动触发。
你写 a + b,Python 实际执行的是 a.__add__(b)。你写 a - b,实际执行的是 a.__sub__(b)。这就是魔法方法的运作机制。
核心概念:魔法方法 = 运算符与自定义对象之间的桥梁。你定义了 __add__,你的对象就能用 +。
基础运算符与对应的魔法方法
下面这张表列出了最常用的运算符和它们的魔法方法。我建议你先把这些记住,剩下的遇到再查。
| 运算符 | 魔法方法 | 含义 |
|---|---|---|
+ |
__add__(self, other) |
加法 |
- |
__sub__(self, other) |
减法 |
* |
__mul__(self, other) |
乘法 |
/ |
__truediv__(self, other) |
真除法 |
// |
__floordiv__(self, other) |
整除 |
% |
__mod__(self, other) |
取模 |
** |
__pow__(self, other) |
幂运算 |
== |
__eq__(self, other) |
等于 |
< |
__lt__(self, other) |
小于 |
实战:定义一个二维向量类
光说不练假把式。我们来写一个 Vector2D 类,让它支持加法、减法和相等判断。
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
# 两个向量相加
return Vector2D(self.x + other.x, self.y + other.y)
def __sub__(self, other):
# 两个向量相减
return Vector2D(self.x - other.x, self.y - other.y)
def __eq__(self, other):
# 判断两个向量是否相等
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Vector2D({self.x}, {self.y})"
# 测试一下
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
v3 = v1 + v2
print(v3) # Vector2D(4, 6)
print(v1 - v2) # Vector2D(2, 2)
print(v1 == Vector2D(3, 4)) # True
你看,代码写起来很自然。v1 + v2 直接返回一个新向量,不用调用什么 add 方法。这就是运算符重载的价值——让代码读起来像数学表达式。
我的习惯:定义 __repr__ 方法,方便调试时直接打印对象内容。没有它,你看到的会是 <__main__.Vector2D object at 0x...>,毫无意义。
反向运算符:当左操作数不支持时
有个坑你可能会踩到。假设你写了 2 * v1,但 int 类型不知道该怎么乘 Vector2D。这时候 Python 会尝试调用 v1.__rmul__(2)。
反向运算符的魔法方法名就是在原方法名前加一个 r:__radd__、__rsub__、__rmul__ 等等。
class Vector2D:
# ... 前面的代码不变 ...
def __rmul__(self, scalar):
# 标量从右边乘向量
return Vector2D(self.x * scalar, self.y * scalar)
v = Vector2D(3, 4)
result = 2 * v
print(result) # Vector2D(6, 8)
为什么会需要这个?因为 Python 先尝试 int.__mul__(2, v),发现 int 不知道怎么处理 Vector2D,就回头调用 Vector2D.__rmul__(v, 2)。没有 __rmul__,2 * v 就会抛出 TypeError。
我曾经踩过的坑:写了一个分数类,只定义了 __mul__,结果 3 * Fraction(1, 2) 报错了。排查了半天才发现忘了定义 __rmul__。记住:左操作数类型不匹配时,Python 会尝试右操作数的反向方法。
就地运算符:+= 和 -= 的魔法
你写 a += b 时,Python 会调用 a.__iadd__(b)。这个方法的特殊之处在于:它应该修改自身并返回自身,而不是创建新对象。
class Vector2D:
# ... 前面的代码不变 ...
def __iadd__(self, other):
self.x += other.x
self.y += other.y
return self # 返回自身
v = Vector2D(3, 4)
v += Vector2D(1, 2)
print(v) # Vector2D(4, 6) — 同一个对象被修改了
注意 __iadd__ 和 __add__ 的区别:__add__ 返回新对象,__iadd__ 修改自身。如果你不定义 __iadd__,Python 会退而求其次,用 __add__ 创建一个新对象再赋值给左操作数。功能上没问题,但性能差一些。
知识结构图
下面这张图把运算符重载的核心脉络梳理了一遍。建议你保存下来,写代码时对照着看。
比较运算符:不只是等于和小于
Python 的 == 和 != 默认比较的是对象的内存地址。你想想看,两个内容相同的 Vector2D(3, 4),用 == 比较居然返回 False,这显然不合理。
所以我们需要定义 __eq__。更妙的是,Python 的 functools.total_ordering 装饰器可以帮你自动补全剩下的比较方法。
from functools import total_ordering
@total_ordering
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __lt__(self, other):
# 按长度比较
return (self.x**2 + self.y**2) < (other.x**2 + other.y**2)
# 有了 @total_ordering,__le__、__gt__、__ge__ 自动生成
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
print(v1 > v2) # True,因为 v1 长度 5 > v2 长度约 2.236
我建议:只定义 __eq__ 和 __lt__,然后加上 @total_ordering。少写代码,少出错。
避坑指南
运算符重载虽然好用,但有几个地方容易翻车。我列出来,你写代码时多留个心眼。
- 类型检查不能少:
__add__里假设other也是Vector2D,万一传进来一个整数呢?建议加isinstance判断,或者抛出明确的异常。 - 不要改变操作数:
__add__应该返回新对象,而不是修改self。修改自身是__iadd__的职责。 - 反向运算符容易忘:写完
__mul__顺手把__rmul__也写了,不然2 * obj会报错。 - 保持语义一致:
__add__应该做加法相关的事,别在里面塞日志、发网络请求。魔法方法的调用频率很高,副作用会让人困惑。
我曾经犯过的错:在一个金融计算类里,__add__ 方法里偷偷调了数据库保存操作。结果循环里做累加时,每次加法都写一次数据库,性能直接崩了。魔法方法要保持纯粹,只做运算,不做副作用。
小结
运算符重载的核心就三件事:基础运算符、反向运算符、就地运算符。记住对应的魔法方法名,写代码时按需实现。不用贪多,用多少写多少。
我个人习惯是先写一个最小可用的版本,只实现 __add__ 和 __eq__,跑通后再补其他方法。这样不容易漏掉类型检查,也不会一开始就把代码写复杂。