本系列文章的相关代码都发布在Github:https://github.com/icexmoon/python-learning-notes
关于运算符重载,实现其实并不是很复杂,只是有一些细节需要注意,学完这一部分我更在意的反而是围绕运算符重载的一些思辨。
我接触的最强大的运算符重载应该是C++,基本上可以重载各种各样的运算符,而Java则完全截然相反,完全不允许重载运算符,所以Java代码中会出现很多的xxx.add(xxx)
。
Java之所以完全摒弃运算符重载,其理由很简单,运算符重载的滥用会让代码的可读性和可维护性降低,而且就算完全没有运算符重载,也不会带来太大的不便。
但是Python似乎是取了个折中的方案,它允许有限度地进行运算符重载:
而Python的做法到目前来看也相当成功,火热的社区和强大的NumPy之类的第三方模块也说明了这一点。
但即便如此,我们在Python中使用运算符重载依然需要慎重,要明确是否合适,是否必须进行运算符重载,以及这么做会带来多大的好处。
比如假设我们有一个游戏系统:
player1 = Role() player1 += swaord() player1 += axe() player1 += gun()
我们似乎可以给用户的游戏角色通过运算符重载的方式装备武器,这很cool对不对?
如果衡量标准是代码的简洁性的话似乎是这样,但是要知道代码并非越简洁越好,更值得关注的是性能和可读性,而我们上面的运算符重载对性能和可读性有何帮助呢?反而是降低了代码的可读性。
我们原本可以用更接近于自然语言的方式表述:
player1 = Role() player1.equip(swaord()) player1.equip(axe()) player1.equip(gun())
我想用上面这个例子说明的是:我们需要时刻提醒自己,不要为了使用运算符重载而使用用算符重载。
实际上,大多数情况下使用运算符重载都不是个靠谱的决定,可以作为参考的是,数学运算相关的领域使用运算符重载更符合直觉,比如向量运算或者矩阵运算。
上面是Python官方手册对Python中的医院运算符的说明,包括两种算术运算符(正、负)以及位运算符(取反)。
关于位取反为何是
-(x+1)
,可以阅读按位取反运算符~
这三种运算在Python中对应的魔术方法分别为:
意义 | 符号 | 魔术方法 |
---|---|---|
正(positive) | + | __pos__ |
负(negative) | - | __neg__ |
位取反(invert) | ~ | __invert__ |
我们这里使用Python学习笔记27:类序列对象中创建的多维向量类VectorN
来说明如何实现一元运算符。
对于算术运算取正,处理很简单:
def __pos__(self): cls = type(self) return cls(self)
为了对子类继承更“友好”这里使用了
type
和cls
,并非VectorN
。
这里只是利用当前实例克隆了一个实例并返回,需要注意的是取正实质上生成了一个值与原实例完全相同的新实例,并非直接返回原实例本身,其实这点对于不可改变的数据类型来说并不是很重要(VectorN
实现了散列化,是不可改变的类型),但依然需要清楚这一点区别。
我们验证一下:
from vector_n import VectorN v1 = VectorN([i for i in range(6)]) v2 = +v1 print(v1 is v2) print(v1 == v2) # False # True
结果也正说明了这一点。
我们再看算术运算取负:
def __neg__(self): cls = type(self) return cls((-i for i in self))
也很简单,这里利用生成器(-1 for i in self)
的方式初始化新实例。
测试一下:
v3 = -v1 print(v3) print(-v3 == v1) # (-0.0, -1.0, -2.0, -3.0, -4.0, -5.0) # True
官方文档里说了,位运算是针对整数,向量也并不存在位运算,所以这里就不做展示了。
现在我们来看向量如何实现+
运算。
Python官方文档介绍了这么几种二元算术运算符:
意义 | 符号 | 魔术方法 |
---|---|---|
加(add) | + | __add__ |
减(subtraction) | - | __sub__ |
乘(multiplication) | * | __mul__ |
除(true division) | \ | __truediv__ |
整除(floor division) | \\ | __floordiv__ |
取余(mod) | % | __mod__ |
矩阵乘法(matrix multiplication) | @ | __matmul__ |
实际上对于二元运算符,每一个对应多个魔术方法,除了上面列出的通常的以外,还有右结合魔术方法以及就地处理的魔术方法,这点会在后边的示例中说明。
+
运算符我们知道,向量之和等于向量在各个方向上的投影之和,按这个思路实现也并不困难:
def __add__(self,other): cls = type(self) return cls(item1+item2 for item1,item2 in zip(self,other))
这里使用了Python学习笔记27:类序列对象中介绍的
zip
函数。
测试一下:
v4 = VectorN(10 for i in range(6)) v5 = v4+v1 print(v5) # (10.0, 11.0, 12.0, 13.0, 14.0, 15.0) v6 = VectorN(10 for i in range(3)) print(v1+v6) # (10.0, 11.0, 12.0)
可以看到,对于元素相等的VectorN
对象相加,结果符合预期,但是对于元素数目不相等的,就很奇怪了。
对于这个问题可以有两种对待方式,具体取决于你的设计和具体情况需要。
第一种方式简单粗暴,对于数目不相等的可迭代对象,我们直接抛出异常:
def __add__(self,other): if(len(self)!=len(other)): raise TypeError("{!r}'s length require equal to {!r}".format(other,self)) cls = type(self) return cls(item1+item2 for item1,item2 in zip(self,other))
进行测试:
v6 = VectorN(10 for i in range(3)) print(v1+v6) # Traceback (most recent call last): # File "D:\workspace\python\python-learning-notes\note30\test.py", line 18, in <module> # print(v1+v6) # File "D:\workspace\python\python-learning-notes\note30\vector_n.py", line 132, in __add__ # raise TypeError("{!r}'s length require equal to {!r}".format(other,self)) # TypeError: VectorN(10.0, 10.0, 10.0)'s length require equal to VectorN(0.0, 1.0, 2.0, 3.0, 4.0, ...)
第二种方式我们可以用相对"宽容"的方式进行处理,即对缺少元素的可迭代对象,我们用零来进行补位,然后再进行向量加法:
def __add__(self, other): cls = type(self) return cls(item1+item2 for item1, item2 in itertools.zip_longest(self, other, fillvalue=0))
测试一下:
v6 = VectorN(10 for i in range(3)) print(v1+v6) # (10.0, 11.0, 12.0, 3.0, 4.0, 5.0)
还有一点需要注意,在前面其实已经强调过了,目前我们对+
运算符的重载其实是可以处理任意可迭代对象的,只要那个可迭代对象里的元素可以和浮点数相加就不会出问题。比如这样:
print(v1+[1,2,3]) # (1.0, 3.0, 5.0, 3.0, 4.0, 5.0)
但是我们如果交换一下运算符两边的元素:
print([1,2,3]+v1) # Traceback (most recent call last): # File "D:\workspace\python\python-learning-notes\note30\test.py", line 22, in <module> # print([1,2,3]+v1) # TypeError: can only concatenate list (not "VectorN") to list
可以看到,异常信息为内建容器list
仅支持同list
进行加运算,不接受其他类型。
而如果你希望这种情形下依然能正常运算,解决方式其实也简单,只要实现此类运算的“右结合”版本即可。
+
运算对应的右结合运算魔术方法为__radd__
。
实现方式也很简单:
def __radd__(self, other): return self + other
这里我们只需要简单地利用“左结合”的加运算即可,因为向量加法显而易见是符合交换律的,即a+b=b+a
,所以完全可以用这种方式实现右结合的运算版本。
测试一下:
print([1,2,3]+v1) # (1.0, 3.0, 5.0, 3.0, 4.0, 5.0)
没问题了,但是我们还需要讨论一下Python对于处理此类问题的底层逻辑。
对于二元运算,Python会用以下逻辑进行处理:
流程图用Visio绘制,工程文件我同样会放在Github同名目录下。
现在应该很明确了,上面的示例中,当+
运算符左侧的list
的重载方法检测到右边的对象不是list
类型后,会返回一个NotImplemented
,然后Python解释器就会尝试嗲用右侧对象的“右结合”重载方法(这里是__radd__
),然后正确获得结果。
这里的
NotImplemented
只是一个类似于Null
的常量实例,而非异常。
从内建容器list
不支持非list
类型的+
运算我们也可以得到一些启示:Python官方并不是很赞成对非同类型的对象进行二元算术运算,因为你如果运算的两端是两种不同的类型,那结果应该以哪种类型为准?具体到我们这个示例,结果为什么一定要是多维向量呢,为啥不能是列表?当然,这里是内建容器,当然不可能是列表,但如果是用户自定义列表呢?
这就会产生一些歧义,有可能会给后续的维护带来麻烦。
所以更严谨的做法是像官方内建类型那样,在重载方法中只处理相同类型的对象:
def __add__(self, other): cls = type(self) if not isinstance(other, cls): return NotImplemented return cls(item1+item2 for item1, item2 in itertools.zip_longest(self, other, fillvalue=0))
测试:
print([1,2,3]+v1) # Traceback (most recent call last): # File "D:\workspace\python\python-learning-notes\note30\test.py", line 20, in <module> # print(v1+[1,2,3]) # TypeError: unsupported operand type(s) for +: 'VectorN' and 'list'
进行类型限定后还有个额外好处,就是完全不需要实现“右结合”版本的运算符重载。
*
运算对于向量的乘法,其实可以是两个向量看做矩阵,进行矩阵乘法。
但这里只简单将向量与实数相乘,进行运算。
def __mul__(self, other): if not isinstance(other, numbers.Real): return NotImplemented cls = type(self) return cls(item*other for item in self) def __rmul__(self, other): return self*other
实现起来同样没什么难度,测试一下:
print(10*v1) # (0.0, 10.0, 20.0, 30.0, 40.0, 50.0)
这里需要注意的是,因为是向量和实数进行乘法,所以肯定是要同时实现__mul__
和__rmul__
的。此外,为了可以包括所有的实数类型,进行类型检测的时候使用的是numbers.Real
这个抽象类型。
解释器对比较运算符的调用与算术运算符有点不太一样,具体符合以下规律:
运算 | 正向调用 | 反向调用 | 后备机制 |
---|---|---|---|
a==b | a.__eq__(b) | b.__eq__(a) | id(a)==id(b) |
a!=b | a.__ne__(b) | b.__ne__(a) | not(a==b) |
a>b | a.__gt__(b) | b.__lt__(a) | 无 |
a<b | a.__lt__(b) | b.__gt__(a) | 无 |
a>=b | a.__ge__(b) | b.__le__(a) | 无 |
a<=b | a.__le__(b) | b.__ge__(a) | 无 |
在执行比较运算符运算的时候,解释器会先尝试正向调用,如果不存在或者返回NotImplemented
则尝试反向调用,对于==
和!=
运算还存在一个备用机制,对于==
来说是用对象的唯一标识符进行比较,对于!=
来说是进行==
运算后取反。
在之前的学习中我们已经实现了向量比较,但是并没有限定类型:
def __eq__(self, other): return len(self) == len(other) and all(num1 == num2 for num1, num2 in zip(self, other))
在当时我们说过,这可能是一种灵活性的体现,但也可能是缺陷。
但如果现在用Python官方的风格来衡量,更接近于缺陷,比如我们来看官方如何看待此类问题:
print([1,2]==(1,2)) # False
显然官方是进行了类型考量,对于相似但类型不同的,逻辑运算会返回False
。
我们可以按照官方的此类做法进行修改:
def __eq__(self, other): if not isinstance(other, VectorN): return NotImplemented return len(self) == len(other) and all(num1 == num2 for num1, num2 in zip(self, other))
测试一下:
l1 = [range(6)] print(v1 == l1) # False
还记得之前我们创建的那个二维向量吗,我们这里再引入那个二维向量进行比较:
from vector import Vector v7 = VectorN([1,2]) v8 = Vector(1,2) print(v7==v8) # True
很奇怪的结果出现了,明明v8
并非VectorN
类型,却返回的True
。
还记得之前的解释器二元运算调用逻辑吗,我们在VectorN
的__eq__
中对待不同类型只是返回NotImplement
,并非直接返回False
,所以解释器会继续尝试调用右侧Vector
类型的重载,显而易见的返回了True
。
其实我个人觉得直接返回
False
而非NotImplement
更简洁明了,但那样似乎不太符合Python的整个调用逻辑。
增量赋值运算符指的是+=
或者*=
这一类运算符。
对这一类运算符的重载注意事项并不像二元运算那样多,但有一点值得注意:“新建实例”or“就地修改”。
我们来看下面这个例子:
v1_alis = v1 v1+=VectorN(10 for i in range(6)) print(v1) print(v1_alis) print(v1 is v1_alis) # (10.0, 11.0, 12.0, 13.0, 14.0, 15.0) # (0.0, 1.0, 2.0, 3.0, 4.0, 5.0) # False
我们现在并没有对VectorN
进行+=
运算符的重载,但是我们依然可以调用,但是需要注意的是,结果很明确的说明了此时+=
运算后产生的是一个新的实例,并非原本的实例。
这是因为在没有重载+=
运算符的时候,解释器会自动调用v1=v1+Vector(10 for i in range(6))
这种方式来实现调用,而相应的二元算术运算结果自然是生成一个新的实例。
如果我们不是想生成新实例,而是想“就地修改”,那就需要重载增量赋值运算对应的魔术方法。
这里我们不继续使用向量类进行说明,因为向量类是散列化不可变的类型,这里使用一个用户自定义列表类型说明:
from collections import UserList class CustomerList(UserList): def __iadd__(self, other): if not isinstance(other, CustomerList): return NotImplemented if len(self)!=len(other): raise TypeError("{!r} and {!r} need same length".format(self,other)) for i in range(len(self)): self[i] += other[i] return self
- 这里只是作为示例说明如何实现增量赋值运算符重载,并无实际意义,现实中也不能实现此类无意义的重载。
- 增量运算符重载如果是就地修改,则必须返回
self
。
测试情况如下:
from customer_list import CustomerList c1 = CustomerList([1,2,3]) c1_alis = c1 c2 = CustomerList(10 for i in range(3)) c1 += c2 print(c1) print(c1_alis) print(c1 is c1_alis) # [11, 12, 13] # [11, 12, 13] # True
可以看到重载之后+=
运算不会再生成新的实例,而是就地修改。
好了,运算符重载的相关话题到此完毕,我本来还以为会这篇博客会轻松很多…结果都是错觉。
谢谢阅读。