在 Python 编程中,运算符重载经常使用 isinstance 做测试。一般来说,库应该利用动态类型(提高灵活性),避免显式测试类型,而是直接尝试操作,然后处理异常,这样只要对象支持所需的操作即可,而不必一定是某种类型。但是,Python 抽象基类允许一种更为严格的鸭子类型,Alex Martelli 称之为“白鹅类型”,编写重载运算符的代码时经常能用到。因此,如果你跳过了第 11 章,一定要去读读。
运算符特殊方法的主要参考资料是“Data Model”一章(https://docs.python.org/3/reference/datamodel.html)。这是权威资料,不过如“Python 3 文档的缺陷”所述,现在有个明显的缺陷,12 即建议“如果定义 __eq__() 方法,同时也要定义 __ne__() 方法”。实际上,在 Python 3 中,继承自 object 类的 __ne__ 方法能满足绝大多数需求,因此一般很少实现 __ne__ 方法。Python 标准库中 numbers 模块文档的“9.1.2.2. Implementing the arithmetic operations”一节(https://docs.python.org/3/library/numbers.html#implementing-the-arithmetic-operations)也值得一读。
12这个缺陷现在已经修正了。——编者注
与之相关的一个技术是泛函数,由 Python 3 的 @singledispatch 装饰器支持(参见 7.8.2 节)。在 David Beazley 与 Brian K. Jones 的著作《Python Cookbook(第 3 版)中文版》中,“9.20 通过函数注解来实现方法重载”秘笈使用一些高级元编程(涉及元类)通过函数注解实现了基于类型的分派。Martelli、Ravenscroft 与 Ascher 的《Python Cookbook(第 2 版)中文版》一书有个有趣的诀窍(2.13 节,Erik Max Francis 提供),展示了如何重载 << 运算符,在 Python 中模仿 C++ 的 iostream 句法。这两本书中还有一些其他关于运算符重载的示例,我只提了两个重要的诀窍。
functools.total_ordering 函数是个类装饰器(Python 2.7 及以上版本可用),它能为只定义了几个比较运算符的类自动生成全部比较运算符。请参阅 functools 模块的文档(https://docs.python.org/3/library/functools.html#functools.total_ordering)。
如果你对动态类型语言的运算符方法分派机制感兴趣,推荐阅读两篇具有重大意义的论文:Dan Ingalls(Smalltalk 团队的创始成员)写的“A Simple Technique for Handling Multiple Polymorphism”(https://wiki.illinois.edu//wiki/download/attachments/273416327/ingalls.pdf),以及 Kurt J. Hebel 与 Ralph Johnson(Johnson 是《设计模式:可复用面向对象软件的基础》的作者之一,因此出了名)合写的“Arithmetic and Double Dispatching in Smalltalk-80”(https://wiki.illinois.edu//wiki/download/attachments/273416327/double-dispatch.pdf)。这两篇论文深入分析了动态类型语言(如 Smalltalk、Python 和 Ruby)的多态。
Python 没有使用这两篇论文中所述的双重分配处理运算符。Python 使用的正向运算符和反向运算符更便于用户定义的类支持双重分派,但是这种方式需要解释器做些特殊处理。与之相比,经典的双重分派是一般性的技术,Python 和任何面向对象语言都能使用,而且不止适用于中缀运算符。其实,Ingalls、Hebel 和 Johnson 描述双重分派使用的示例完全不同。
本章开篇引用的那段话,以及“杂谈”中引用的两段话,均出自“The C Family of Languages: Interview with Dennis Ritchie, Bjarne Stroustrup, and James Gosling”一文(http://www.gotw.ca/publications/c_family_interview.htm),刊登于 Java Report, 5(7), July 2000 和 C++ Report, 12(7), July/August 2000 上。如果你对编程语言设计感兴趣,那么这篇文章非常值得一读。
杂谈
运算符重载的优缺点
如本章开头引用的那段话所述,James Gosling 决定不让 Java 支持运算符重载。在那次访谈中(“The C Family of Languages: Interview with Dennis Ritchie, Bjarne Stroustrup, and James Gosling”,http://www.gotw.ca/publications/c_family_interview.htm),他说:
大约 20% 到 30% 的人觉得运算符重载是罪恶之源;有些人对运算符的重载惹怒了很多人,因为他们使用
+做列表插入,导致生活一团糟。这类问题大都源于一个事实:世界上有成千上万个运算符,但是只有少数几个适合重载。因此,我们要挑选,但是有时所作的决定违背直觉。Guido van Rossum 为运算符重载采取了一种折中方式:不放任用户随意创建运算符,如
<=>或:-),这样防止了用户对运算符的异想天开,而且能让 Python 解析器保持简单。此外,Python 还禁止重载内置类型的运算符,这个限制也能增强可读性和可预知的性能。Gosling 接着说道:
社区中约有 10% 的人能正确地使用和真正关心运算符重载,对这些人来说,运算符重载是极其重要的。这部分人几乎专门处理数字,在这一领域中,为了符合人类的直觉,表示法特别重要,因为他们进入这一领域时,直觉中已经知道 + 的意思,他们知道“a + b”中的 a 和 b 可以是复数、矩阵或其他合理的东西。
表示法方面的问题不能低估。下面以金融领域为例说明。在 Python 中,可以使用下述公式计算复利:
interest = principal * ((1 + rate) ** periods - 1)不管涉及什么数字类型,这种表示法都成立。因此,如果是做重要的金融工作,你要确保
periods是整数,rate、interest和principal是精确的数字(Python 中decimal.Decimal类的实例),这样上述公式就能完好运行。但是在 Java 中,如果把
float换成精度不定的BigDecimal,就无法再使用中缀运算符,因为中缀运算符只支持基本类型。在 Java 中,支持BigDecimal数字的公式要这样写:BigDecimal interest = principal.multiply(BigDecimal.ONE.add(rate) .pow(periods).subtract(BigDecimal.ONE));显然,使用中缀运算符的公式更易读,至少对大多数人来说如此。13 为了让中缀运算符表示法支持非基本类型,运算符必须能重载。Python 是门高级语言,易于使用,支持运算符重载可能就是它这些年在科学计算领域得到广泛使用的主要原因。
当然,语言不支持运算符重载也有好处。对极为重视性能和安全的低级系统语言而言,这无疑是正确的决定。新近出现的 Go 语言在这方面效仿了 Java,它不支持运算符重载。
但是,重载的运算符,如果使用得当,的确能让代码更易于阅读和编写。对现代的高级语言来说,这是个好功能。
惰性计算一瞥
如果仔细看示例 13-9 中的调用跟踪,会发现生成器表达式做惰性计算的证据。示例 13-19 再次列出那些调用跟踪,不过加上了一些标注。
示例 13-19 与示例 13-9 一样
>>> v1 + 'ABC' Traceback (most recent call last): File "<stdin>", line 1, in <module> File "vector_v6.py", line 329, in __add__ return Vector(a + b for a, b in pairs) # ➊ File "vector_v6.py", line 243, in __init__ self._components = array(self.typecode, components) # ➋ File "vector_v6.py", line 329, in <genexpr> return Vector(a + b for a, b in pairs) # ➌ TypeError: unsupported operand type(s) for +: 'float' and 'str'❶
Vector调用的components参数是一个生成器表达式。这一步没问题。❷
components生成器表达式传给array构造方法。在这里,Python 尝试迭代生成器表达式,因此会计算第一个元素a + b。这里抛出了TypeError。❸ 异常向上冒泡,到达
Vector构造方法调用,在这里报告出来。这表明,生成器表达式在最后时刻才会计算,而不是在源码中定义它的位置计算。
与之相比,如果像
Vector([a + b for a, b in pairs])这样调用Vector构造方法,那么这里就会抛出异常,因为列表推导会尝试构建一个列表,以便作为参数传给Vector()调用。此时,根本不会触及Vector.__init__的定义体。第 14 章会详细讨论生成器表达式,但是我不想让示例中偶然出现的惰性计算迹象漏过去。
13我的朋友 Mario Domenech Goulart,CHICKEN Scheme 编译器(http://www.call-cc.org)的核心开发者,可能不会同意这一说法。