Python教程

Python学习笔记30:运算符重载

本文主要是介绍Python学习笔记30:运算符重载,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

Python学习笔记30:运算符重载

本系列文章的相关代码都发布在Github:https://github.com/icexmoon/python-learning-notes

一点思辨

关于运算符重载,实现其实并不是很复杂,只是有一些细节需要注意,学完这一部分我更在意的反而是围绕运算符重载的一些思辨。

我接触的最强大的运算符重载应该是C++,基本上可以重载各种各样的运算符,而Java则完全截然相反,完全不允许重载运算符,所以Java代码中会出现很多的xxx.add(xxx)

Java之所以完全摒弃运算符重载,其理由很简单,运算符重载的滥用会让代码的可读性和可维护性降低,而且就算完全没有运算符重载,也不会带来太大的不便。

但是Python似乎是取了个折中的方案,它允许有限度地进行运算符重载:

  1. 不能对内建模块进行运算符重载。
  2. 只能对已有运算符进行重载,用户不能“发明”新的运算符。

而Python的做法到目前来看也相当成功,火热的社区和强大的NumPy之类的第三方模块也说明了这一点。

但即便如此,我们在Python中使用运算符重载依然需要慎重,要明确是否合适,是否必须进行运算符重载,以及这么做会带来多大的好处。

比如假设我们有一个游戏系统:

player1 = Role()
player1 += swaord()
player1 += axe()
player1 += gun()

我们似乎可以给用户的游戏角色通过运算符重载的方式装备武器,这很cool对不对?

如果衡量标准是代码的简洁性的话似乎是这样,但是要知道代码并非越简洁越好,更值得关注的是性能和可读性,而我们上面的运算符重载对性能和可读性有何帮助呢?反而是降低了代码的可读性。

我们原本可以用更接近于自然语言的方式表述:

player1 = Role()
player1.equip(swaord())
player1.equip(axe()) 
player1.equip(gun())

我想用上面这个例子说明的是:我们需要时刻提醒自己,不要为了使用运算符重载而使用用算符重载

实际上,大多数情况下使用运算符重载都不是个靠谱的决定,可以作为参考的是,数学运算相关的领域使用运算符重载更符合直觉,比如向量运算或者矩阵运算。

一元运算符

image-20210510151052579

上面是Python官方手册对Python中的医院运算符的说明,包括两种算术运算符(正、负)以及位运算符(取反)。

关于位取反为何是-(x+1),可以阅读按位取反运算符~

这三种运算在Python中对应的魔术方法分别为:

意义符号魔术方法
正(positive)+__pos__
负(negative)-__neg__
位取反(invert)~__invert__

我们这里使用Python学习笔记27:类序列对象中创建的多维向量类VectorN来说明如何实现一元运算符。

对于算术运算取正,处理很简单:

    def __pos__(self):
        cls = type(self)
        return cls(self)

为了对子类继承更“友好”这里使用了typecls,并非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

官方文档里说了,位运算是针对整数,向量也并不存在位运算,所以这里就不做展示了。

现在我们来看向量如何实现+运算。

二元算术运算符

image-20210510155105435

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会用以下逻辑进行处理:

image-20210510171659503

流程图用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==ba.__eq__(b)b.__eq__(a)id(a)==id(b)
a!=ba.__ne__(b)b.__ne__(a)not(a==b)
a>ba.__gt__(b)b.__lt__(a)
a<ba.__lt__(b)b.__gt__(a)
a>=ba.__ge__(b)b.__le__(a)
a<=ba.__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

可以看到重载之后+=运算不会再生成新的实例,而是就地修改。

好了,运算符重载的相关话题到此完毕,我本来还以为会这篇博客会轻松很多…结果都是错觉。

谢谢阅读。

运算符重载

这篇关于Python学习笔记30:运算符重载的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!