本系列文章的相关代码都发布在Github:https://github.com/icexmoon/python-learning-notes
一点思辨
关于运算符重载,实现其实并不是很复杂,只是有一些细节需要注意,学完这一部分我更在意的反而是围绕运算符重载的一些思辨。
我接触的最强大的运算符重载应该是C++,基本上可以重载各种各样的运算符,而Java则完全截然相反,完全不允许重载运算符,所以Java代码中会出现很多的xxx.add(xxx)
。
但是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__ |
我们这里使用中创建的多维向量类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))
这里使用了中介绍的
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
可以看到重载之后+=
运算不会再生成新的实例,而是就地修改。
好了,运算符重载的相关话题到此完毕,我本来还以为会这篇博客会轻松很多...结果都是错觉。
谢谢阅读。
文章评论