运算符重载的作用是让用户定义的对象使用中缀运算符(如+和|)和一元运算符(如-和~)。
在Python中,这些也算是运算符:
函数调用:()
属性访问:.
元素访问和切片:[]
运算符重载基础
在某些圈子中,运算符重载的名声不好,比如Java之父说过:我决定不支持运算符重载,这完全是个人选择,因为我见过太多C++程序员滥用它。
但是如果使用得当,API会变得更好用,代码变的I易于阅读。
Python施加了一些限制,做好了灵活性、可用性和安全性方面的平衡:
不能重载内置类型的运算符
不能新建运算符,只能重载现有的
某些运算符不能重载:is、and、or、not (但是位运算符&、|、~可以)
下面依次介绍:一元运算符、中缀运算符、比较运算符、增量赋值运算符。
一元运算符
- (__neg__) 一元取负运算符。如果x是-2,那么-x == 2
+ (__pos__)一元取正运算符。通常x == +x,也有一些例外
~ (__invert__)对整数按位取反。定义为~x == -(x+1) ,如果x是2,那么~x ==-3
abs (__abs__) 取绝对值
要实现一元运算符,就实现括号中对应的特殊方法,这些特殊方法都只有一个参数self。要遵守一个原则:始终返回一个新的对象,也就是说不能修改self,要创建并返回合适类型的新实例。对于+和-来说,返回的结果可能是和self同一类型的实例;对于abs来说结果应该是一个标量;但是对于~来说,很难说返回什么是合理的,因为可能不是处理整数的位,例如在ORM中,SQL where子句应该返回反集。
示例,Vector类中实现了__abs__方法,补充上__neg__和__pos__方法
def __abs__(self):
"""绝对值 (向量取模)"""
return math.sqrt(sum(x * x for x in self.__components)) # math.sqrt()求平方根。先用sum求每个分量的平方之和,然后开平方
def __neg__(self):
"""取负值,构建新的实例,每个分量都取反"""
return Vector(-x for x in self)
def __pos__(self):
"""取正值,构建新的实例,传入self各个分量"""
return Vector(self)
补充下在什么时候x和+x不相等:
在Python中几乎所有情况下x == +x,但是在标准库中也有例外的情况,有两例
第一例是decimal.Decimal有关,这是因为使用+时,精度变了
import decimal
ctx = decimal.getcontext() # 获取当前全局算术运算的上下文引用
ctx.prec = 40 # 把算术运算上下文精度设为40
one_third = decimal.Decimal('1') / decimal.Decimal('3')
print(one_third)
print(+one_third)
print(one_third == +one_third) # 比较
ctx.prec = 28 # 把算术运算上下文精度设为28
print(one_third)
print(+one_third)
print(one_third == +one_third) # 比较
打印
0.3333333333333333333333333333333333333333
0.3333333333333333333333333333333333333333
True
0.3333333333333333333333333333333333333333
0.3333333333333333333333333333
False
上面可以看到,在改变上下文精度后,再次与+one_third比较时就不相等了,因为+运算符会返回一个新的实例对象,而这个实例对象是根据当时的上下文精度创建的。
第二例是collection.Counter有关,Counter类实现了几个算术运算符,例如中缀运算符+的作用是把两个Counter计数器加到一起,两边相加时,负值和零值可能会改变。
而对于一元运算符+等同于加上一个空的Counter,因此他产生一个新的Counter且仅保留大于零的计数器。
import collections
ct = collections.Counter('aaaaabbrrdc')
print(ct)
ct['r'] = -3
ct['d'] = 0
# 比较
print(ct)
print(+ct)
打印
Counter({'a': 5, 'b': 2, 'r': 2, 'd': 1, 'c': 1})
Counter({'a': 5, 'b': 2, 'c': 1, 'd': 0, 'r': -3})
Counter({'a': 5, 'b': 2, 'c': 1})
我的理解是,计数器中的元素不能为负数,前面把r和d元素强制修改后,后面又通过+重新加了一个空的Counter,开始了重新计数,把小于等于零的元素都去掉了。
重载加法运算符+
中缀运算符的加法运算符 + 通过__add__实现
下面使用Vector多维向量类
...省略代码
def __add__(self, other):
"""实现向量加法,"""
long_zip = itertools.zip_longest(self, other, fillvalue=0.0) # 使用zip_longest处理两个向量长度不同的情况
return Vector(a + b for a, b in long_zip)
v1 = Vector([1, 2, 3])
v2 = Vector([3, 2, 2])
print(v1 + v2)
v3 = Vector([1])
print(v1 + v3)
打印
(4.0, 4.0, 5.0)
(2.0, 2.0, 3.0)
知识点:
itertools.zip_longest方法是类似zip函数的,返回一个生成器,产生(a, b)形式的元组,如果长度不同,使用fillvalue值填充缺失的元素。
以上不仅能实现同类型相加,还可以相加任何可迭代对象,因为zip_longest接收的参数只要是可迭代就行
注意,如果左操作数是Vector之外的对象,Vector.__add__方法是无法处理的,比如(1,2,3) + v1这种会报错,其实可以使用__radd__来处理,被相加的时候调用
ps:不管是一元运算符还是中缀运算符,实现的特殊方法都一定不能修改操作数self。使用这些运算符的期待结果是新对象。只有增量运算符会就地修改第一个操作数self。
为了支持涉及不同类型的运算,Python为中缀运算符特殊方法提供了特殊的分派机制。
比如对于表达式a+b来说,解释器会执行以下操作:
如果a有__add__方法,而且返回值不是NotImplemented,会调用a.__add__(b)返回结果
如果a没有__add__方法,或者返回值是NotImplemented,然后检查b有没有__radd__方法,如果有,且没有返回NotImplemented,会调用b.__radd__(a)返回结果。
如果b没有__radd__方法,或者调用了__radd__返回了NotImplemented,结果就抛出TypeError 并在错误信息中提示操作数类型不支持
NotImplemented的含义:意为【未实现】。比如在表达式b1 == a1中,b1 .__ eq __(a1)对应的方法返回 NotImplemented,它告诉 Python 尝试调用 a1 .__ eq __(b1)
NotImplemented和NotImplementedError不要搞混:
NotImplemented是特殊的单例值,如果中缀运算符的特殊方法不能处理给它的操作数,那么就把他返回(return)给解释器。
NotImplementedError是一种异常,抽象类中的占位方法把它抛出,提醒子类必须覆盖
__radd__是__add__的“反射”(reflected)版本,或者说“反向”(reversed)版本。比如__sub__是-减法运算符,那么__rsub__就是反向减法
__radd__这种方法,是一种后备机制,如果左操作数没有实现__add__方法,或者虽然实现了,但是返回的是NotImplemented,就是表名左操作数它不知道如何处理右操作数,那么Python就会调用__radd__方法
def __add__(self, other):
"""实现向量加法,"""
long_zip = itertools.zip_longest(self, other, fillvalue=0.0) # 使用zip_longest处理两个向量长度不同的情况
return Vector(a + b for a, b in long_zip)
def __radd__(self, other):
"""实现向量加法,"""
return self + other # 委托给__add__处理
以下的操作会抛出异常,
v1 = Vector([1, 2, 3])
print(v1 + 'ABC')
TypeError: unsupported operand type(s) for +: 'float' and 'str'
这是因为__add__方法的操作里面有了错误,应该抛出NotImplemented才对,而不是TypeError。如果返回NotImplemented,另一个操作数还有机会执行运算,Python会尝试调用反向运算符方法。
我们应该在__add__方法中捕获异常,然后返回NotImplemented。如果反向方法还是返回NotImplemented,这时Python才会抛出TypeError。如果直接不捕获异常,就终止了运算符分派机制。
最终版本:
def __add__(self, other):
"""实现向量加法,"""
try:
long_zip = itertools.zip_longest(self, other, fillvalue=0.0) # 使用zip_longest处理两个向量长度不同的情况
return Vector(a + b for a, b in long_zip)
except TypeError:
return NotImplemented
def __radd__(self, other):
"""实现向量加法,"""
return self + other
重载乘法运算符*
对于Vector([1,2,3]) * 10这是计算标量积(scalar product),会把每个分量乘以10,也叫作叫元素级乘法(elementwise multiplication)
示例,实现乘法
...s省略代码
type_code = 'd' # 类属性。在于实例和字节序列转换时使用。
def __init__(self, components):
self.__components = array(self.type_code, components)
def __mul__(self, other):
if isinstance(other, numbers.Real): # 限制不能使用复数
return Vector(other * x for x in self)
else:
return NotImplemented
def __rmul__(self, other):
return self * other # 委托给__mul__
v1 = Vector([1, 2, 3])
print(v1 * 10)
print(100 * v1)
打印
(10.0, 20.0, 30.0)
(100.0, 200.0, 300.0)
知识点:
因为Vector内部使用的是浮点数数组,所以相乘要保证是非复数,可以是int、bool(int的子类),甚至是fraction.Fraction(分数)
还有一种,分量乘以分量,然后相加。叫做两个向量的点积(dot product)
在Python3.5中,这种操作使用@运算符,意为 矩阵乘法(matrix multiplication),使用特殊方法__matmul__、__rmatmul__和__imatmul__实现。
示例,实现@运算符,矩阵乘积
...代码省略
def __matmul__(self, other):
try:
return sum(a * b for a, b in zip(self, other)) # 实测不能使用zip_longest,fillvalue=1, 参考numpy中不对称的矩阵乘积会报错才对
except TypeError:
return NotImplemented
def __rmatmul__(self, other):
return self @ other
v1 = Vector([1, 2, 3])
v2 = Vector([5, 6, 7])
print(v1 @ v2)
打印
38
中缀运算符一览
上面提到的加法+和乘法* 都属于中缀运算符
补充举例:
a |= 2等价于a = a|2(按位或)
a>>=2等价于a=a>>2(右移3位)
a<<=2等价于a=a<<2(左移3位)
比较运算符
Python解释器中 比较运算符和前面介绍的中缀运算符类型,正反向返回NotImplemented的话,调用反向方法。
但是有两点不同:
正向和反向使用的都是同一系列方法,只是把参数对调了。比如对于==来说,正向反向都是调用__eq__方法,只是参数对调;而正向的__gt__方法调用的是反向的__lt__方法,并把参数对调。
对于==和!=来说,如果反向调用失败,Python会比较对象的ID,而不是抛出TypeError。
Python3的改动:
在Python3中,对于__ne__来说,会直接返回__eq__结果的相反。
Python3对于不同类型的比较运算符,能正常抛出异常TypeError: '>' not supported between instances of 'int' and 'tuple'
然而在Python2中,会比较对象的类型和id,返回结果是FALSE。(整数和元组比较显然无意义,Python2的做法不合理)
示例,完善Vector类的__eq__方法。
...省略代码
def __eq__(self, other):
"""实现可散列,判断相等性"""
if isinstance(other, Vector): # 如果other操作数是Vector实例,
return len(self) == len(other) and all(a == b for a, b in zip(self, other)) # 先比较长度相等,再比较分量相等
else:
return NotImplemented
v1 = Vector([1, 2, 3])
print(v1 == (1, 2, 3))
打印
False
v1 == (1, 2, 3)比较的过程:
为了计算v1==(1,2,3),Python调用Vector.__eq__(v1, (1,2,3))
经过Vector.__eq__方法确认(1,2,3)元组不是Vector实例,因此返回NotImplemented
Python得到NotImplemented后,尝试调用tuple.__eq__((1,2,3), v1)
tuple.__eq__方法不知道Vector是什么,因此返回了NotImplemented
Python得知反向调用也返回了NotImplemented,就会比较对象的ID,作为最后一博。
对于!=运算符,我们不用实现,因为从object继承的__ne__方法的后备行为,__ne__方法会对__eq__返回的结果取反。
object的__ne__方法,与下面代码类似,不过原版是通过C语言实现的:
def __ne__(self, other):
eq_result = self == other
if eq_result is NotImplemented:
return NotImplemented
else:
return not eq_result
不过这种处理办法也有缺陷,x==y成立,不带便x!=y不成立。在Python2中,如果定义了__eq__方法,那么做好定义__ne__方法,这样运算符的行为才能符合预期。但是在Python3中,从object中继承的__ne__方法就够用了,技术不用重载。
增量赋值运算符
如果一个类,没有实现就地运算符,如__iadd__/__imul__等等,增量赋值运算符只能算是语法糖:a += b作用和a = a+b完全一致,对于不可变类型来说,这是正常的行为,并且如果实现了__add__方法,不需要额外的代码,+=就能实现
比如Vector类,就是不可变类型,对于+=或者*=运算符,会产生新的实例,查看实例ID,会发现id变化。
与+相比,+=运算符对于第二个操作数更宽容。+运算符的两个操作数必须是相同类型,而+=的情况更明确,因为就地修改左操作数,所以结果的类型是确定的,一定的左操作数的类型。
比如内置类型list,对于+运算符是把两个列表加到一起,而my_list += x 是把右边可迭代对象中的元素扩展到左边的列表,这和list.extend()行为相同,它的参数可以是任何可迭代对象。
示例,AddableBingoCage扩展BingoCage,支持+和+=
class AddableBingoCage(BingoCage):
def __add__(self, other):
if isinstance(other, Tombola): # +运算符需要保证两个操作数的同类型
return BingoCage(self.inspect() + other.inspect())
else:
return NotImplemented
def __iadd__(self, other):
if isinstance(other, Tombola):
other_iterable = other.inspect()
else:
try:
other_iterable = iter(other) # 尝试把右操作数创建迭代器,这里可能会报异常
except TypeError:
class_name = type(self).__name__
# class_name = self.__class__.__name__ 或者这样
msg = "right operand in += must be {!r} or an iterable"
raise TypeError(msg.format(class_name))
self.load(other_iterable)
return self # 增量赋值的特殊方法,必须返回self
a = AddableBingoCage([1, 2, 3])
b = AddableBingoCage([5, 6, 7])
c = a + b
print(c.inspect())
a += b
print(a.inspect())
a += [8, 9]
print(a.inspect())
a += 123
print(a.inspect())
打印
(1, 2, 3, 5, 6, 7)
(1, 2, 3, 5, 6, 7)
(1, 2, 3, 5, 6, 7, 8, 9)
Traceback (most recent call last):
File "C:/Users/lijiachang/PycharmProjects/collect_demo/Tombola.py", line 114, in __iadd__
other_iterable = iter(other) # 尝试把右操作数创建迭代器,这里可能会报异常
TypeError: 'int' object is not iterable
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "C:/Users/lijiachang/PycharmProjects/collect_demo/Tombola.py", line 137, in <module>
a += 123
File "C:/Users/lijiachang/PycharmProjects/collect_demo/Tombola.py", line 119, in __iadd__
raise TypeError(msg.format(class_name))
TypeError: right operand in += must be 'AddableBingoCage' or an iterable
知识点:
实现增量赋值的特殊方法,最后一定要返回self
__add__方法,要保证两个操作数是同一类型,要创造一个新的实例作为结果返回
__iadd__方法,最后把修改后的self作为结果返回
一般来说,如果中缀运算符的正向方法只处理与self 同一类型的操作数,那么就无需实现对应的反向方法,因为按照定义,反向方法是为了处理类型不同的操作数。
小结
虽然都是用+-符号,但是在一元运算符中
-表示__neg__
+表示__pos__
在中缀运算符中
-表示__sub__
+表示__add__
一元运算符和中缀运算符的结果都应该产生新的对象,而且绝不能修改操作数。
为了支持其他类型,返回特殊的NotImplemented值,让解释器尝试对调操作数,然后调用运算符的反向特殊方法。
如果操作数类型不同,有两种方式处理:1.鸭子类型,直接执行运算,捕获TypeError。2.使用isinstance测试,主要要使用抽象基类,而不是具体类
使用__eq__实现了==后,Python会通过__ne__方法对__eq__取反作为结果判断!=运算符。最后还有后备机制,判断对象ID。
+=对于可变对象,没有产生新对象。对于不可变对象,会产生新的对象。
+ 两边的操作数必须是同一个类型。+=对于右操作数一般是任何可迭代对象。