目录

运算符重载

本章将讨论:

  • Python 如何处理中缀运算符中不同类型的操作数
  • 使用鸭子类型或显式类型检查处理不同类型的操作数
  • 中缀运算符如何表明自己无法处理操作数
  • 众多比较运算符(如 ==、 >、 <=,等等)的特殊行为
  • 增量赋值运算符(如 +=)的默认处理方式和重载方式

1. 运算符重载基础

Python 限制:

  • 不能重载内置类型的运算符
  • 不能新建运算符,只能重载现有的
  • 某些运算符不能重载——is、 and、 or 和 not(不过位运算符 &、 | 和 ~ 可以)

运算符的基本规则:

  1. 就地运算符(增量赋值运算符)必须返回 self
  2. 除就地运算符外,其他运算符始终返回一个新对象,即要创建并返回合适类型的新实例

2. 一元运算符

2.1 一元运算符简介

|运算符|方法|说明|示例| |: —|: —|: —|: —| |-|__neg__|一元取负算术运算符|如果 x 是 -2,那么 -x == 2| |+|__pos__|一元取正算术运算符|通常 x == +x,但也有一些例外| |~|__invert__|对整数按位取反,定义为 ~x == -(x+1)|如果 x 是 2,那么 ~x == -3| |abs()|__abs__|取绝对值|…|

2.2 x 和 +x 何时不相等

decimal.Decimal 类:

  • 场景: 在不同精度的上下文中计算 +x,那么 x != +x
  • 原因: 虽然每个 +one_third 表达式都会使用 one_third 创建一个新 Decimal 实例,但是 会使用当前算术运算上下文的精度
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> import decimal
>>> ctx = decimal.getcontext() #  获取当前全局算术运算的上下文引用
>>> ctx.prec = 40
>>> one_third = decimal.Decimal('1') / decimal.Decimal('3')
>>> one_third
Decimal('0.3333333333333333333333333333333333333333')
>>> one_third == +one_third
True
>>> ctx.prec = 28
>>> one_third == +one_third
False
>>> +one_third
Decimal('0.3333333333333333333333333333')

collections.Counter:

1
2
3
4
5
6
7
8
9
>>> ct = Counter('abracadabra')
>>> ct
Counter({'a':  5, 'r':  2, 'b':  2, 'd':  1, 'c':  1})
>>> ct['r'] = -3
>>> ct['d'] = 0
>>> ct
Counter({'a':  5, 'b':  2, 'c':  1, 'd':  0, 'r':  -3})
>>> +ct
Counter({'a':  5, 'b':  2, 'c':  1})

3. 中缀运算符

|运算符|正向方法|反向方法|就地方法|说明| |: —|: —|: —|: —|: —| |+|__add__|__radd__|__iadd__|加法或拼接| |-|__sub__|__rsub__|__isub__|减法| |*|__mul__|__rmul__|__imul__|乘法或重复复制| |/|__truediv__|__rtruediv__|__itruediv__|除法| |//|__floordiv__|__rfloordiv__|__ifloordiv__|整除| |%|__mod__|__rmod__|__imod__|取模| |divmod()|__divmod__|__rdivmod__|__idivmod__|返回由整除的商和模数组成的元组| |**,pow()|__pow__|__rpow__|__ipow__|取幂 *| |@|__matmul__|__rmatmul__|__imatmul__|矩阵乘法 #| |&|__and__|__rand__|__iand__|位与| ||__or__|__ror__|__ior__|位或| |^|__xor__|__rxor__|__ixor__|位异或| |«|__lshift__|__rlshift__|__ilshift__|按位左移| |»|__rshift__|__rrshift__|__irshift__|按位右移|

3.1 中缀运算符分派机制

** a + b 执行步骤**

  1. 如果 a 有 __add__ 方法,且返回值不是 NotImplemented,调用 a.__add__(b)返回结果
  2. 如果 a 没有 __add__ 方法,或者调用 __add__ 方法返回 NotImplemented,检查 b 有没有 __radd__ 方法,如果有,而且没有返回 NotImplemented,调用 b.__radd__(a),然后返回结果
  3. 如果 b 没有 __radd__ 方法,或者调用 __radd__ 方法返回 NotImplemented,抛出 TypeError, 并在错误消息中指明操作数类型不支持
  4. 说明:
  • 右向(right)特殊方法(又称反向方法)提供了一种后备机制
  • 如果中缀运算符方法抛出异常,就会终止运算符分派机制
  • 一般来说,如果中缀运算符的正向方法只处理与 self 属于同一类型的操作数, 那就无需实现对应的反向方法,因为按照定义,反向方法是为了处理类型不同的操作数

/images/fluent_python/overload_add.png

NotImplemented 和 NotImplementedError

  • NotImplemented:
    • 特殊的单例值
    • 如果中缀运算符特殊方法不能处理给定的操作数,那么要把它返回(return)给解释器
  • NotImplementedError:
    • 是一种异常
    • 抽象类中的占位方法把它抛出(raise),提醒子类必须覆盖

3.2 加法运算符 + 示例

1
2
3
4
5
6
7
8
9
def __add__(self, other):
    try:
        pairs = itertools.zip_longest(self, other, fillvalue=0.0)
        return Vector(a + b for a, b in pairs)
    except TypeError:
        return NotImplemented

def __radd__(self, other):
    return self + other

示例分析:

  • __radd__ 直接委托 __add__,前提是运算符可交换
  • 如果由于类型不兼容而导致运算符特殊方法无法返回有效的结果,那么应该返回 NotImplemented, 而不是抛出 TypeError,这样另一个操作数所属的类型还有机会执行运算,即Python 会尝试调用反向方法
  • 如果反向方法返回 NotImplemented,那么 Python 会抛出 TypeError,并返回一个标准的错 误消息,例如” unsupported operand type(s) for +: Vector and str"
  • 为了遵守鸭子类型精神,不能测试 other 操作数的类型,或者它的元素的类型。应该要捕获异常, 然后返回 NotImplemented

3.2 乘法运算符 * 示例

1
2
3
4
5
6
7
8
def __mul__(self, scalar):
    if isinstance(scalar, numbers.Real):
        return Vector(n * scalar for n in self)
    else:
        return NotImplemented

def __rmul__(self, scalar):
    return self * scalar

示例分析:

  • 白鹅类型的实际运用: 显式检查抽象类型 numbers.Real 抽象基类
  • numbers.Real 抽象基类涵盖了我们所需的全部数值类型,而且还支持以后声明为 numbers.Real 抽象基类的真实子类或虚拟子类的数值类型
  • 鸭子类型更灵活,但显式检查更能预知结果,通过检查抽象基类在灵活性和安全性之间做了很好的折中
  • decimal.Decimal 没有把自己注册为 numbers.Real 的虚拟子类。 因此, Vector 类不会处理 decimal.Decimal 数字

3.3 特殊运算符说明

pow(x, y[, z])

  • 两个参数 == x**y
  • 三个参数 == (x**y) % z,但 pow() 函数更高效

@ 运算符

  • 版本: Python35 引入
  • 作用: 矩阵乘法,点积

4. 比较运算符

4.1 比较运算符分派机制

分派机制对比:

  • 相似: 与中缀运算符分配机制类似,正向方法返回NotImplemented,调用反向方法
  • 区别:
    1. 正向和反向调用使用的是同一系列方法
    • __eq__ 方法,只是把参数对调了;
    • 正向的 __gt__ 方法调用的是反向的 __lt__ 方法,并把参数对调
    1. 对 == 和 != 来说,如果反向调用失败, Python 会比较对象的 ID,而不抛出 TypeError

|分组|中缀运算符|正向方法调用|反向方法调用|后备机制| |: —|: —|: —|: —|: —| |相等性|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)|抛出 TypeError| |排序|a < b|a.__lt__(b)|b.__gt__(a)|抛出 TypeError| |排序|a >= b|a.__ge__(b)|b.__le__(a)|抛出 TypeError| |排序|a <= b|a.__le__(b)|b.__ge__(a)|抛出 TypeError|

版本差异:

  • __ne__
    • Python 3 的后备机制是对 __eq__ 结果的取反,因此对于 != 运算符,无需重载
    • Python 2 不是如此
  • 比较运算符
    • Python 3 抛出 TypeError,并把错误消息设为 ‘unorderable types: int() < tuple()
    • Python 2 中,这些比较的结果很怪异,会考虑对象的类型和 ID,而且无规律可循

5. 就地运算符

分派机制:

  1. 如果类没有实现就地运算符,增量赋值运算符只是语法糖: a += b 的作用与 a = a + b 一样, 对不可变类型来说,这是预期的行为,而且,如果定义了__add__ 方法,不用编写额外的代码, += 就能使用
  2. 如果类实现了就地运算符方法,会就地修改左操作数,而不会创建新对象作为结果, 不可变类型,一定不能实现就地特殊方法

+ 与 +=:

  • + 必需返回类的新实例,+= 必须返回 self,即实例本身
  • 与 + 相比, += 运算符对第二个操作数更宽容,因为
    • + 运算符的两个操作数必须是相同类型,如若不然,结果的类型可能让人摸不着头脑
    • 而 += 的情况更明确,因为就地修改左操作数,所以结果的类型是确定的
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> a = [1,2]
>>> a + (3, 4)      # + 要求两个操作数属于同一类型
Traceback (most recent call last):
  File "/usr/lib/python2.7/site-packages/IPython/core/interactiveshell.py", line 2882, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-24-6ab1cfc1ec6f>", line 1, in <module>
    a + (3, 4)
TypeError:  can only concatenate list (not "tuple") to list
>>> a += (3, 4)     # += 的右操作数可以是任何可迭代对象
>>> a
[1, 2, 3, 4]

延伸阅读

Python:

运算符特殊方法

numbers 模块

functools.total_ordering

blog:

What’s New inPython 3.0

运算符方法分派机制

实用工具

书籍:

《 Python Cookbook(第 3 版)中文版》

  • “9.20 通过函数注解来实现方法重载"秘笈使用一些高级元编程(涉及元类)通过函数注解实现了基于类型的分派

《 Python Cookbook(第 2 版)中文版》

  • 2.13 节,展示了如何重载 « 运算符,在 Python 中模仿 C++ 的 iostream 句法

附注

运算符重载

  • 对极为重视性能和安全的低级系统语言而言,不支持运算符重载这无疑是正确的决定
  • 但是,重载的运算符,如果使用得当,的确能让代码更易于阅读和编写。对现代的高级语言来说,这是个好功能