运算符重载的作用是让用户定义的对象使用中缀运算符或一元运算符。在 Python 中,其实函数调用、属性访问和元素访问其实都是运算符,但是这里只讨论一元运算符和中缀运算符。
运算符重载基础
运算符重载如果使用得当,API 会变得好用,代码会变得易于阅读。Python 对运算符重载施加了一些限制,做好了灵活性、可用性和安全性方面的平衡:
- 不能重载内置类型的运算符
- 不能新建运算符,只能重载现有的
- 某些运算符不能重载:is、and、or 和 not
一元运算符
如下是 Python 中的 3 个一元运算符及其对应的特殊方法:
-(__neg__)
:一元取负运算符+(__pos__)
:一元取正运算符~(__invert__)
:对整数按位取反,~x == -(x+1)
另外有时候 abs(…) 函数也被视为一元运算符,它对应的特殊方法是 __abs__
。一元运算符的一个基本原则是:始终返回一个新对象,也就是说,不能修改 self,要创建并返回合适类型的新实例。
如下为 Vector 类实现这几个一元运算符:
1 | def __abs__(self): |
由于 Vector 本身是一个可迭代对象,而且 Vector.init 的参数也是一个可迭代对象,因此它们的实现短小精悍。
重载向量加法运算符
Vector 是序列类型,序列应该支持 + 运算符(用于拼接)以及 * 用于复制。但是这里对于 Vector 类型,我们将使用向量数学运算实现 + 和 * 运算符,这样做更有意义。
1 | def __add__(self, other): |
实现一元运算符和中缀运算符的特殊方法一定不能修改操作数。因为使用这些运算符的表达式期待的结果是新对象,只有增量赋值表达式可能会修改第一个操作数。
目前实现的 Vector,支持把 Vector 添加到元祖或其他生成数字元素的可迭代对象上,但是如果对调操作数,混合类型的加法就会失败。
1 | from vector_v7 import Vector |
也就是说,如果左操作数是 Vector 之外的对象,add 方法无法处理。为了支持涉及不同类型的运算,Python 为中缀运算符特殊方法提供了特殊的分派机制。对于表达式 a + b,解释器会执行以下几步操作:
- 如果 a 有 add 方法,而且返回值不是 NotImplemented,调用 a.add(b),然后返回结果
- 如果 a 没有 add 方法,或者调用 add 方法返回 NotImplemented,检查 b 有没有实现 radd 方法。如果有并且不是返回 NotImplemented,则调用 b.radd(a),然后返回结果
- 如果 b 没有 radd 方法,或者返回的是 NotImplemented,抛出 TypeError,并在错误消息中指明操作数类型不支持
不要把 NotImplemented 和 NotImplementedError 搞混了,如果中缀运算符特殊方法不能处理给定的操作数,那么会返回 NotImplemented,而 NotImplementedError 是一种异常,抽象类中的占位方法把它抛出,提醒子类必须覆盖。
radd 是 add 的反向特殊方法。这里 radd 的实现,直接委托给 add 即可:
1 | def __radd__(self, other): |
如果是由于类型不兼容而导致运算符特殊方法无法返回有效的结果,那么应该返回 NotImplemented,而不是抛出 TypeError,这样另一个操作数所属的类型还有机会执行运算,即 Python 会尝试调用反向方法。
1 | def __add__(self, other): |
如果中缀运算符方法抛出异常,就终止了运算符分派机制。对于 TypeError 来说,通常最好将其捕获,然后返回 NotImplemented,这样解释器会尝试调用反向运算符的方法。
一般来说,如果中缀运算符的正向方法只处理与 self 属于同一类型的操作数,那么就无需实现对应的的反向方法。因为按照定义,反向方法是为了处理类型不同的操作数。
重载标量乘法运算符
接下来将重载 Vector 的乘法运算符,计算向量的标量积。我们可以使用 try 来捕获由于参数类型不支持而抛出的 TypeError,但是由于这里我们清楚地知道参数所支持的类型,因此这里选用白鹅类型,即用 isinstance()
来检查 scalar 的类型:
1 | def __mul__(self, scalar): |
1 | 1, 2, 3]) v1 = Vector([ |
在 Python 编程中,运算符重载经常使用 isinstance
做测试。一般来说,库应该利用动态类型(提高灵活性),避免显式测试类型,而是直接尝试操作,然后处理异常,这样只要对象支持所需要的操作即可,而不必一定是某种类型。但是 Python 抽象基类允许一种更为严格的鸭子测试(也称为白鹅类型),编写运算符重载时经常使用。
众多的比较运算符
Python 解释器对众多的比较运算符(==、!=、>、<、>=、<=)的处理与前文类似,但是也存在两个重大区别:
- 正向和反向调用使用的是同一系列的方法。例如对于 == 来说,正反向调用都是
__eq__
方法,只是把参数对调了。而正向的__gt__
方法的方向调用是的__lt__
方法,并把参数对调 - 对于 == 和 != 来说,如果反向调用失败,Python 会比较对象的 ID,而不抛出 TypeError
这里我们重新实现 Vector 的 __eq__
方法,以解决对参数类型过于宽容的问题:
1 | def __eq__(self, other): |
我们并不需要实现 __ne__
方法,因为从 object 继承的 __ne__
方法的后备行为满足了我们的需求,其行为是 not (a == b)
。由于正确地实现了 __eq__
方法,因此 __ne__
会对 __eq__
返回的结果取反。一般来说,从 object 继承的 __ne__
实现够用了,几乎不用重载。
增量赋值运算符
如果一个类没有实现就地运算符,那么增量赋值运算符只是语法糖:a += b 的作用与 a = a + b
完全一样。对于不可变类型来说,这是预期行为。而且如果定义了 __add__
方法,就不需要编写额外的代码,+= 就能使用了。但是如果实现了就地运算符,例如 __iadd__
,那么它们会就地修改左操作数,而不会创建新的对象作为结果。对于不可变类型,一定不能实现就地特殊方法。
一般来说,就地运算符比中缀运算符对第二个操作数更加宽容。以 +
为例,+
运算符的两个操作数必须是相同类型,否则结果的类型无从确定,而 +=
的情况更加明确,因为它就地修改左操作数,所以结果的类型是确定的。
在实现增量赋值特殊方法时,必须返回 self。