运算符重载入门: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__ 创建一个新对象再赋值给左操作数。功能上没问题,但性能差一些。

知识结构图

下面这张图把运算符重载的核心脉络梳理了一遍。建议你保存下来,写代码时对照着看。

运算符重载核心知识结构 魔法方法 基础运算符 反向运算符 就地运算符 __add__ __sub__ __mul__ __truediv__ __radd__ __rsub__ __rmul__ __rtruediv__ __iadd__ __isub__ __imul__ __itruediv__ 💡 定义顺序:先定义基础运算符,再考虑反向运算符,最后按需添加就地运算符 不定义反向运算符时,左操作数类型不匹配会抛出 TypeError

比较运算符:不只是等于和小于

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__,跑通后再补其他方法。这样不容易漏掉类型检查,也不会一开始就把代码写复杂。