一门易于使用的高级语言,再加上支持运算符重载,这或许是 Python 在数据科学领域(包括金融和科学应用程序)取得巨大成功的关键原因。这篇文章将学习 Python 的运算符重载机制。
运算符重载入门
运算符重载的作用是让用户定义的对象能够使用中缀运算符(例如 + 和 |)或一元运算符(例如 - 和 ~)。运算符重载如果使用得当,则会把 API 变得更好用,把代码变得更易于阅读。Python 施加了一些限制,在灵活性、可用性和安全性方面取得了很好的平衡:
- 不能改变内置类型的运算符表达的意思
- 不能新建运算符,只能重载现有运算符
- 有些运算符不能重载:is、and、or 和 not(不过按位运算符
&、|和~可以重载)
一元运算符
如下列举了 Python 的一元运算符:
-(由__neg__实现):一元取反算术运算符+(由__pos__实现):一元取正算术运算符~(由__invert__实现):按位取反运算符
另外有时也将内置函数 abs() 列为一元运算符。前文说过,它对应的特殊方法是 __abs__。
支持一元运算符很简单,只需实现相应的特殊方法。这些特殊方法只有一个参数,即 self。特殊方法的实现要符合所在类的逻辑。此外,还应遵守运算符的一个基本规则:始终返回新对象。也就是说,不要修改self,应创建并返回合适类型的新实例。
重载向量加法运算符
如下代码实现了 Vector 向量的加法运算,它得到一个新向量,各分量是两个向量中相应分量之和:
1 | # 在 Vector 类中定义 |
1 | v1 = Vector([3, 4, 5]) |
实现一元运算符和中缀运算符的特殊方法一定不能修改操作数。使用这些运算符的表达式预期结果是创建新对象。只有增量赋值运算符可以修改第一个操作数,即 self。
这种实现方式可以把 Vector 加到元组或任何生成数值的可迭代对象上,这是因为__add__使用了 zip_longest(...),它能处理任何可迭代对象:
1 | v1 = Vector([3, 4, 5]) |
但是如果对调操作数,即如果左侧操作数不是 Vector 对象,那么混合类型的加法就会失败。这涉及到 Python 为中缀运算符特殊方法提供了特殊的分派机制,对于表达式 a + b,解释器将执行以下几步操作:
- 如果 a 有
__add__方法,而且不返回 NotImplemented,就调用a.__add__(b),返回结果 - 如果 a 没有
__add__方法,或者调用__add__方法返回 NotImplemented,就检查 b 有没有__radd__方法,如果有,而且不返回 NotImplemented,就调用b.__radd__(a),返回结果 - 如果 b 没有
__radd__方法,或者调用__radd__方法返回 NotImplemented,就抛出 TypeError,并在错误消息中指明不支持操作数的类型
__radd__ 是 __add__ 的 反射(reflected)版本或 反向(reversed)版本,可以叫作 反向特殊方法。
为了让 Vector 支持混合类型的加法能正确计算,需要实现 Vector.__radd__ 方法。如下是实现代码:
1 | # 在Vector类中定义 |
__radd__通常就是这么简单:直接调用适当的运算符,这里就是委托__add__。任何满足交换律的运算符都能这么做
__radd__ 只是调用 __add__,那么还有一种同效方法:
1 | def __add__(self, other): |
如果由于类型不兼容而导致运算符特殊方法无法返回有效的结果,那么应该返回 NotImplemented,而不是抛出 TypeError。如果返回 NotImplemented,那么另一个操作数所属的类型还有机会执行运算,即 Python 会尝试调用反向方法。
- 为了遵守鸭子类型精神,不能测试操作数 other 或者所含元素的类型。要捕获异常,然后返回 NotImplemented
- 如果解释器还未反转操作数,那么它将尝试去做
- 如果反向方法调用返回 NotImplemented,则 Python 会抛出 TypeError,返回一个标准的错误消息,例如
unsupported operand type(s) for +: Vector and str”
如下是实现 Vector 加法的特殊方法的最终版:
1 | def __add__(self, other): |
总结一下:如果中缀运算符方法抛出异常,那么它将终止运算符分派机制。对于 TypeError,通常最好将其捕获,然后返回 NotImplemented。这样解释器就会尝试调用反向运算符方法,如果操作数是不同的类型,那么对调之后,反向运算符方法可能会正确计算。
值得说明的,一般来说,如果中缀运算符的正向方法(例如 __mul__)只处理与 self 属于同一类型的操作数,那么就无须实现对应的反向方法(例如 __rmul__),因为按照定义,反向方法是为了处理类型不同的操作数。
重载标量乘法运算符
接下来通过乘法运算符为向量提供 标量积 的计算方法,结果是一个新的 vector 实例,各个分量都乘以 x——这也叫元素级乘法(elementwise multiplication)。
1 | class Vector: |
- 如果 scalar 不能转换为 float 值,则说明不知道如何处理,因此返回 NotImplemented,让 Python 尝试在操作数 scalar 上调用
__rmul__方法 __rmul__方法只需执行self * scalar,委托__mul__方法
把 @ 当作中缀运算符使用
NumPy 中的点积一直写作 numpy.dot(a, b)。使用函数调用表示法难以把较长的数学公式使用的数学表示法转换成 Python 代码。Python3.5 实现了 PEP 465—A dedicated infix operator for matrix multiplication。如今,两个 NumPy 数组的点积可以写作 a @ b。
@ 运算符 由特殊方法 __matmul__、__rmatmul__ 和 __imatmul__ 支持,名称取自 matrix multiplication(矩阵乘法)。
1 | va = Vector([1, 2, 3]) |
1 | class Vector: |
- 两个操作数都必须实现
__len__和__iter__ - 两个操作数长度相同
- 对于 Vector 类本身而言(作为 other 的情况),它没有子类化
abc.Sized或abc.Iterable,但是能通过针对二者的 isinstance 检查,因为 Vector 类有必要的方法(这两个抽象基类都实现了__subclasshook__,因此凡是提供__len__和__iter__的对象都满足测试条件)
算术运算符总结
下图总结了 Python 所支持的算术运算符:
比较运算符
Python 解释器对众多比较运算符(==、!=、>、<、>= 和 <=)的处理与前文类似,不过在两个方面有重大区别:
- 正向调用和反向调用使用的是同一系列方法,例如,对
==来说,正向调用和反向调用都是__eq__方法,只是把参数对调了。而正向的__gt__方法调用的是反向的__lt__方法,并把参数对调 - 对于
==和!=,如果缺少反向方法或返回 NotImplemented,那么 Python 就会比较对象的 ID,而不抛出 TypeError
1 | def __eq__(self, other): |
不用实现支持 != 运算符的 __ne__ 方法,因为从 object 继承的 __ne__ 方法的后备行为即可满足需求:
- 只要定义了
__eq__方法,而且不返回NotImplemented,__ne__就会对__eq__返回的结果取反
增量赋值运算符
Vector 类已经支持增量赋值运算符 += 和 *= 了。这是因为当增量赋值运算符的接收者是不可变对象时,将新建实例,重新绑定左侧变量。
如果一个类没有实现表就地运算符,那么增量赋值运算符就只是语法糖:a += b 的作用与 a = a + b 完全一样:
- 对于不可变类型,这是预期行为
- 而且,如果定义了
__add__方法,那么不用编写额外的代码,+=就能使用 - 不可变类型(例如我们定义的 Vector 类)一定不能实现就地更改特殊方法。这是明显的事实,不过还是有必要指出来
然而,如果实现了像 __iadd__ 这样的就地运算符方法,那么计算 a += b 的结果时就会调用就地运算符方法。这种运算符的名称表明,它们会就地修改左侧操作数,而不创建新对象作为结果。
与 + 相比,+= 运算符对第二个操作数更宽容。+ 运算符的两个操作数必须是相同类型(这里是AddableBingoCage),如若不然,结果的类型可能让人摸不着头脑。而 += 的情况更明确,因为就地修改左侧操作数,所以结果的类型是确定的。list 就是这样的行为。
一般而言:
__add__:调用构造函数构建一个新实例,作为结果返回__iadd__:把修改后的 self 作为结果返回