2.7 对象抽象
对象系统让程序员能高效地建立和使用抽象数据表示,它的设计还允许同一个程序里并存多种抽象数据表示。
对象抽象的一个核心概念是泛型函数(generic function)——能接受多种不同类型值的函数。我们会考察三种实现泛型函数的技术:共享接口、类型派发和类型强制转换。在逐步建立这些概念的过程中,我们还会发现 Python 对象系统里一些支持创建泛型函数的特性。
2.7.1 字符串转换
要有效地表示数据,对象值的行为就该像它所代表的那类数据,包括生成自己的字符串表示。在 Python 这类交互式语言里,数据值的字符串表示尤其重要:交互式会话会自动显示表达式值的字符串表示。
字符串是人之间传递信息的基本媒介。字符序列可以显示在屏幕上、打印在纸上、朗读出来、转成盲文,或者用摩斯码发出去。字符串对编程同样基本,因为它们能表示 Python 表达式。
Python 规定所有对象都应生成两种不同的字符串表示:一种供人阅读,一种能被 Python 解释。字符串的构造函数 str 返回人能读懂的字符串。只要可能,repr 函数就返回一个 Python 表达式,对它求值能得到与原对象相等的对象。repr 的文档字符串(docstring)说明了这一性质:
repr(object) -> string
Return the canonical string representation of the object.
For most object types, eval(repr(object)) == object
返回对象的标准字符串表示。
对于大多数对象类型,
eval(repr(object)) == object。
在表达式的值上调用 repr,结果就是 Python 在交互式会话里打印的内容。
>>> 12e12
12000000000000.0
>>> print(repr(12e12))
12000000000000.0如果找不到一种表示能求值得到原值,Python 通常会生成一段用尖括号括起来的描述。
>>> repr(min)
'<built-in function min>'str 构造函数通常和 repr 一致,但有时会给出更好懂的文本表示。比如对日期对象,str 和 repr 的输出就不一样。
>>> from datetime import date
>>> tues = date(2011, 9, 12)
>>> repr(tues)
'datetime.date(2011, 9, 12)'
>>> str(tues)
'2011-09-12'定义 repr 函数带来一个新挑战:我们希望它能正确地作用于所有数据类型,哪怕是实现 repr 时还不存在的类型。我们希望它是一个泛型函数,也就是多态函数(polymorphic function),能作用于数据的许多(poly)不同形式(morph)。
对此,对象系统给出了一个优雅的解决办法:repr 函数总是在它的参数上调用名为 __repr__ 的方法。
>>> tues.__repr__()
'datetime.date(2011, 9, 12)'只要在用户自定义类里实现同一个方法,就能把 repr 的适用范围扩展到我们将来创建的任何类。这个例子也凸显了点表达式的一个好处:它们提供了一种机制,把已有函数的定义域扩展到新的对象类型。
str 构造函数也类似:它在参数上调用名为 __str__ 的方法。
>>> tues.__str__()
'2011-09-12'这些多态函数体现了一条更普遍的原则:有些函数应该适用于多种数据类型。而且,创建这类函数的一种办法,就是让多个类共用一个属性名,但各自给出不同的定义。
2.7.2 特殊方法
在 Python 里,有些特殊名称(special name)会在特定情况下由解释器调用。比如,每当构造一个对象,类的 __init__ 方法都会被自动调用。打印时 __str__ 会自动调用,交互式会话里显示值时 __repr__ 会自动调用。
Python 里还有很多行为对应着特殊名称。下面介绍其中最常用的一些。
真值和假值。 前面我们见过,Python 中的数字有真假值:更确切地说,0 是假值,其他所有数字都是真值。实际上,Python 中所有对象都有真假值。默认情况下,用户自定义类的对象算作真值,但专门的 __bool__ 方法可以改写这一行为。如果一个对象定义了 __bool__ 方法,Python 就调用它来确定真假值。
举个例子,假设我们希望余额为 0 的银行账户是假值。给 Account 类加一个 __bool__ 方法就能实现。
>>> Account.__bool__ = lambda self: self.balance != 0可以调用 bool 构造函数查看对象的真假值;任何对象也都能用在布尔上下文里。
>>> bool(Account('Jack'))
False
>>> if not Account('Jack'):
print('Jack has nothing')
Jack has nothing序列操作。 前面见过,可以调用 len 函数求序列的长度。
>>> len('Go Bears!')
9len 函数通过调用参数的 __len__ 方法求长度。所有内置序列类型都实现了这个方法。
>>> 'Go Bears!'.__len__()
9序列如果没有提供 __bool__ 方法,Python 就用它的长度确定真假值:空序列是假值,非空序列是真值。
>>> bool('')
False
>>> bool([])
False
>>> bool('Go Bears!')
True__getitem__ 方法由元素选择运算符调用,但也能直接调用。
>>> 'Go Bears!'[3]
'B'
>>> 'Go Bears!'.__getitem__(3)
'B'可调用对象。 在 Python 里,函数是一等对象,所以能像数据一样传递,也能像其他对象一样拥有属性。Python 还允许我们定义能像函数那样「调用」的对象,只要包含一个 __call__ 方法。有了它,我们就能定义行为像高阶函数的类。
举个例子,看下面这个高阶函数,它返回一个函数,把某个常量加到自己的参数上。
>>> def make_adder(n):
def adder(k):
return n + k
return adder
>>> add_three = make_adder(3)
>>> add_three(4)
7我们可以创建一个 Adder 类,定义 __call__ 方法来提供同样的功能。
>>> class Adder(object):
def __init__(self, n):
self.n = n
def __call__(self, k):
return self.n + k
>>> add_three_obj = Adder(3)
>>> add_three_obj(4)
7这里,Adder 类的行为像 make_adder 这个高阶函数,add_three_obj 的行为像 add_three 函数。数据和函数之间的界线,又被我们模糊了一分。
算术运算。 特殊方法还能定义内置运算符作用于用户自定义对象时的行为。为了做到这种通用性,Python 对每个运算符都遵循特定的协议。比如,求值含 + 运算符的表达式时,Python 会检查表达式左右两个操作数上的特殊方法。它先看左操作数上有没有 __add__ 方法,再看右操作数上有没有 __radd__ 方法。只要找到其中一个,就调用它,并把另一个操作数的值作为参数传进去。后面的小节会给出一些例子。想了解更细的读者,Python 文档完整列出了运算符的方法名称。《Dive into Python 3》有一章讲特殊方法名称,介绍了其中许多特殊方法名称的用法。
2.7.3 多重表示
抽象屏障让我们把数据的使用和表示分开。不过在大型程序里,谈论某个数据类型的「底层表示」未必总有意义。其一,一个数据对象可能有不止一种有用的表示,而我们也许想设计出能处理多种表示的系统。
举个简单的例子,复数可以用两种几乎等价的方式表示:直角坐标形式(实部和虚部)和极坐标形式(幅度和角度)。有时直角坐标形式更合适,有时极坐标形式更合适。事实上,完全可以设想这样一个系统:复数同时用两种形式表示,而操作复数的函数对任何一种表示都能工作。下面我们就实现这样一个系统。顺带一提,我们开发的这个系统用泛型操作对复数做算术运算,只是一个简单而不切实际的示例程序。复数类型 其实已经内置在 Python 里,但这里我们实现自己的版本。
让数据有不止一种表示,这种想法很常见。大型软件系统往往由许多人长期协作设计,需求也在不断变化。在这种环境里,根本没法让所有人事先就数据表示的选择达成一致。除了把表示与使用隔离开的数据抽象屏障,我们还需要一些抽象屏障,把不同的设计选择彼此隔离,并让它们能在同一个程序里共存。
我们从抽象的最高层开始实现,再逐步走向具体的表示。复数是数值(Number),数值可以相加、相乘。数值怎么相加、怎么相乘,由 add 和 mul 这两个方法名抽象出来。
>>> class Number:
def __add__(self, other):
return self.add(other)
def __mul__(self, other):
return self.mul(other)这个类要求 Number 对象有 add 和 mul 方法,却没有定义它们,也没有 __init__ 方法。Number 不是用来直接实例化的,而是作为各种具体数值类的超类(superclass)。下一步,我们要为复数恰当地定义 add 和 mul。
复数可以看作二维空间中的一个点,这个空间有两个相互垂直的轴:实轴和虚轴。从这个角度看,复数 c = real + imag * i(其中 i * i = -1)就是平面上的一个点,横坐标是 real,纵坐标是 imag。复数相加,就是把各自的 real 和 imag 坐标分别相加。
复数相乘时,用极坐标形式来表示会更自然,即表示为幅度(magnitude)和角度(angle)。两个复数的乘积是一个向量:把一个复数按另一个复数的长度拉伸,再旋转另一个复数的角度,就得到它。
Complex 类继承自 Number,描述了复数的算术运算。
>>> class Complex(Number):
def add(self, other):
return ComplexRI(self.real + other.real, self.imag + other.imag)
def mul(self, other):
magnitude = self.magnitude * other.magnitude
return ComplexMA(magnitude, self.angle + other.angle)这个实现假定已经存在两个表示复数的类,对应复数的两种自然表示:
ComplexRI用实部和虚部构造一个复数。ComplexMA用幅度和角度构造一个复数。
接口。 对象属性是消息传递的一种形式,它让不同的数据类型能以不同方式响应同一条消息。一组能引发不同类做出相似行为的共享消息,是一种强大的抽象手段。接口(interface)是一组共享的属性名,外加对它们行为的规范。就复数而言,实现算术所需的接口包含四个属性:real、imag、magnitude 和 angle。
要让复数的算术运算正确,这些属性必须保持一致。也就是说,直角坐标(real、imag)和极坐标(magnitude、angle)必须描述复平面上的同一个点。Complex 类规定了如何用这些属性来 add 和 mul 复数,也就隐式定义了这个接口。
属性。 要求两个或更多属性值之间始终保持固定关系,是个新问题。一种解决办法是只存一种表示的属性值,需要时再计算另一种表示。
Python 有一个简单特性,可以用零参数函数即时算出属性。@property 装饰器(decorator)让函数无需调用表达式语法(表达式后面跟圆括号)就能被调用。ComplexRI 类存储 real 和 imag 属性,按需计算 magnitude 和 angle。
>>> from math import atan2
>>> class ComplexRI(Complex):
def __init__(self, real, imag):
self.real = real
self.imag = imag
@property
def magnitude(self):
return (self.real ** 2 + self.imag ** 2) ** 0.5
@property
def angle(self):
return atan2(self.imag, self.real)
def __repr__(self):
return 'ComplexRI({0:g}, {1:g})'.format(self.real, self.imag)有了这个实现,复数算术所需的全部四个属性都不用调用表达式就能访问,而且修改 real 或 imag 会反映到 magnitude 和 angle 上。
>>> ri = ComplexRI(5, 12)
>>> ri.real
5
>>> ri.magnitude
13.0
>>> ri.real = 9
>>> ri.real
9
>>> ri.magnitude
15.0同样,ComplexMA 类存储 magnitude 和 angle,但在查找 real 和 imag 时才去计算它们。
>>> from math import sin, cos, pi
>>> class ComplexMA(Complex):
def __init__(self, magnitude, angle):
self.magnitude = magnitude
self.angle = angle
@property
def real(self):
return self.magnitude * cos(self.angle)
@property
def imag(self):
return self.magnitude * sin(self.angle)
def __repr__(self):
return 'ComplexMA({0:g}, {1:g} * pi)'.format(self.magnitude, self.angle/pi)修改 magnitude 或 angle,会立刻反映到 real 和 imag 属性上。
>>> ma = ComplexMA(2, pi/2)
>>> ma.imag
2.0
>>> ma.angle = pi
>>> ma.real
-2.0复数的实现到此就完成了。Complex 中每个算术函数,两个参数都可以用任意一个实现复数的类。
>>> from math import pi
>>> ComplexRI(1, 2) + ComplexMA(2, pi/2)
ComplexRI(1, 4)
>>> ComplexRI(0, 1) * ComplexRI(0, 1)
ComplexMA(1, 1 * pi)用接口来编码多种表示,有几个吸引人的优点。每种表示的类都能单独开发;它们只需就共用的属性名以及这些属性的行为条件达成一致。接口还是可添加的。如果别的程序员想往同一个程序里加入复数的第三种表示,只需再写一个具有相同属性的类。
数据的多种表示,和本章开头讲的数据抽象关系密切。有了数据抽象,我们能在不改变程序含义的前提下更换数据类型的实现。有了接口和消息传递,我们能在同一个程序里拥有多种不同的表示。两种情况都是靠一组名称及其对应的行为条件所定义的抽象,才获得这种灵活性。
2.7.4 泛型函数
泛型函数是能作用于不同类型参数的方法或函数。前面已经见过不少例子。Complex.add 方法就是泛型的,因为它的 other 既可以取 ComplexRI,也可以取 ComplexMA。这种灵活性来自我们确保 ComplexRI 和 ComplexMA 共用同一个接口。用接口和消息传递,只是实现泛型函数的众多办法之一。本节我们再考察另外两种:类型派发和类型强制转换。
假设除了复数类,我们还实现一个 Rational 类来精确表示分数。它的 add 和 mul 方法,和本章前面出现过的 add_rational、mul_rational 函数表达的是同样的计算。
>>> from fractions import gcd
>>> class Rational(Number):
def __init__(self, numer, denom):
g = gcd(numer, denom)
self.numer = numer // g
self.denom = denom // g
def __repr__(self):
return 'Rational({0}, {1})'.format(self.numer, self.denom)
def add(self, other):
nx, dx = self.numer, self.denom
ny, dy = other.numer, other.denom
return Rational(nx * dy + ny * dx, dx * dy)
def mul(self, other):
numer = self.numer * other.numer
denom = self.denom * other.denom
return Rational(numer, denom)我们通过加入 add 和 mul 方法,实现了 Number 超类的接口。于是,就能用熟悉的运算符来对分数做加法和乘法。
>>> Rational(2, 5) + Rational(1, 10)
Rational(1, 2)
>>> Rational(1, 4) * Rational(2, 3)
Rational(1, 6)不过,我们还没法把分数和复数相加,尽管在数学上这种组合是明确定义的。我们希望以一种受控的方式引入这种跨类型操作,既支持它,又不严重破坏抽象屏障。我们想要的结果之间存在矛盾:一方面,希望把一个复数和一个分数相加,并且用一个泛型的 __add__ 方法对所有数值类型都做出正确处理;另一方面,又希望尽量把复数和分数各自关注的问题分开,以保持程序模块化。
类型派发。 实现跨类型操作的一种办法,是根据函数或方法参数的类型来选择行为。类型派发的思路是:写一个函数,检查收到的参数类型,再执行与这些类型相匹配的代码。
内置函数 isinstance 接受一个对象和一个类。如果对象的类就是给定的类,或者继承自给定的类,它就返回真值。
>>> c = ComplexRI(1, 1)
>>> isinstance(c, ComplexRI)
True
>>> isinstance(c, Complex)
True
>>> isinstance(c, ComplexMA)
False类型派发的一个简单例子是 is_real 函数,它对每种复数类型使用不同的实现。
>>> def is_real(c):
"""返回 c 是否为虚部为零的实数。"""
if isinstance(c, ComplexRI):
return c.imag == 0
elif isinstance(c, ComplexMA):
return c.angle % pi == 0
>>> is_real(ComplexRI(1, 1))
False
>>> is_real(ComplexMA(2, pi))
True类型派发并不总是用 isinstance。对算术运算,我们给 Rational 和 Complex 实例加一个字符串值属性 type_tag。当两个值 x 和 y 的 type_tag 相同时,可以直接用 x.add(y) 把它们结合起来;否则就需要跨类型操作。
>>> Rational.type_tag = 'rat'
>>> Complex.type_tag = 'com'
>>> Rational(2, 5).type_tag == Rational(1, 2).type_tag
True
>>> ComplexRI(1, 1).type_tag == ComplexMA(2, pi/2).type_tag
True
>>> Rational(2, 5).type_tag == ComplexRI(1, 1).type_tag
False要把复数和分数结合起来,我们写一些同时依赖两者表示的函数。下面我们依赖这样一个事实:Rational 可以近似地转换成一个实数的 float 值。转换结果就能和一个复数结合。
>>> def add_complex_and_rational(c, r):
return ComplexRI(c.real + r.numer/r.denom, c.imag)乘法也涉及类似的转换。在极坐标形式里,复平面上的实数总有一个正的幅度。角度 0 表示正数,角度 pi 表示负数。
>>> def mul_complex_and_rational(c, r):
r_magnitude, r_angle = r.numer/r.denom, 0
if r_magnitude < 0:
r_magnitude, r_angle = -r_magnitude, pi
return ComplexMA(c.magnitude * r_magnitude, c.angle + r_angle)加法和乘法都可交换,所以交换参数顺序时,可以复用同一套跨类型操作的实现。
>>> def add_rational_and_complex(r, c):
return add_complex_and_rational(c, r)
>>> def mul_rational_and_complex(r, c):
return mul_complex_and_rational(c, r)类型派发的作用,是确保在恰当的时候使用这些跨类型操作。下面我们改写 Number 超类,让它的 __add__ 和 __mul__ 方法使用类型派发。
我们用 type_tag 属性来区分参数的类型。也可以直接用内置的 isinstance 方法,但标签能简化实现。用类型标签还说明了一点:类型派发未必和 Python 对象系统绑在一起,它是在异构领域上创建泛型函数的一种通用技术。
__add__ 方法考虑两种情况。首先,如果两个参数的类型标签相同,它就假定第一个参数的 add 方法能接受第二个参数。否则,它检查一个存放跨类型实现的字典 adders,看里面有没有能对这两个类型标签做加法的函数。如果有,cross_apply 方法会找到并施加它。__mul__ 结构类似。
>>> class Number:
def __add__(self, other):
if self.type_tag == other.type_tag:
return self.add(other)
elif (self.type_tag, other.type_tag) in self.adders:
return self.cross_apply(other, self.adders)
def __mul__(self, other):
if self.type_tag == other.type_tag:
return self.mul(other)
elif (self.type_tag, other.type_tag) in self.multipliers:
return self.cross_apply(other, self.multipliers)
def cross_apply(self, other, cross_fns):
cross_fn = cross_fns[(self.type_tag, other.type_tag)]
return cross_fn(self, other)
adders = {("com", "rat"): add_complex_and_rational,
("rat", "com"): add_rational_and_complex}
multipliers = {("com", "rat"): mul_complex_and_rational,
("rat", "com"): mul_rational_and_complex}在 Number 类的这个新定义里,所有跨类型实现都在 adders 和 multipliers 字典中,以类型标签对为索引。
这种基于字典的类型派发是可扩展的。Number 的新子类只要声明一个类型标签,再把跨类型操作加进 Number.adders 和 Number.multipliers,就能把自己接入这套系统。它们也可以在子类里定义自己的 adders 和 multipliers。
虽然我们给系统增加了一些复杂度,但现在可以在加法和乘法表达式里混合使用不同类型了。
>>> ComplexRI(1.5, 0) + Rational(3, 2)
ComplexRI(3, 0)
>>> Rational(-1, 2) * ComplexMA(4, pi/2)
ComplexMA(2, 1.5 * pi)强制转换。 在完全不相关的类型上施加完全不相关的操作,这种一般情形下,显式实现跨类型操作虽然繁琐,却已是最好的办法。好在,利用类型系统里可能潜藏的其他结构,我们有时能做得更好。不同的数据类型往往并非完全独立,可能有办法把一种类型的对象看作另一种类型。这个过程叫强制转换(coercion)。例如,要把一个分数和一个复数做算术运算,我们可以把分数看作虚部为零的复数。这样一来,就能用 Complex.add 和 Complex.mul 结合它们。
一般来说,实现这个想法的办法是设计强制转换函数,把一种类型的对象转换成另一种类型的等价对象。下面是一个典型的强制转换函数,把分数转换成虚部为零的复数:
>>> def rational_to_complex(r):
return ComplexRI(r.numer/r.denom, 0)Number 类的另一种定义,通过尝试把两个参数强制转换成同一类型来完成跨类型操作。coercions 字典用类型标签对索引所有可能的强制转换,表示对应的值把第一种类型的值转换成第二种类型。
一般来说,不可能把任意一种类型的数据对象都强制转换成其他所有类型。例如,没法把任意的复数转换成分数,所以 coercions 字典里不会有这样的转换实现。
coerce 方法返回两个类型标签相同的值。它检查参数的类型标签,与 coercions 字典中的条目比对,再用 coerce_to 把其中一个参数转换成另一个参数的类型。coercions 里只需一个条目就能搭成整套跨类型算术系统,取代了类型派发版 Number 里的四个跨类型函数。
>>> class Number:
def __add__(self, other):
x, y = self.coerce(other)
return x.add(y)
def __mul__(self, other):
x, y = self.coerce(other)
return x.mul(y)
def coerce(self, other):
if self.type_tag == other.type_tag:
return self, other
elif (self.type_tag, other.type_tag) in self.coercions:
return (self.coerce_to(other.type_tag), other)
elif (other.type_tag, self.type_tag) in self.coercions:
return (self, other.coerce_to(self.type_tag))
def coerce_to(self, other_tag):
coercion_fn = self.coercions[(self.type_tag, other_tag)]
return coercion_fn(self)
coercions = {('rat', 'com'): rational_to_complex}相比显式定义跨类型操作,这套强制转换方案有一些优势。虽然仍要写强制转换函数来关联各种类型,但每一对类型只需一个函数,而不必为每组类型、每个泛型操作各写一个函数。我们依赖的事实是:类型之间恰当的转换只取决于类型本身,与具体要施加的操作无关。
进一步的优势来自对强制转换的扩展。有些更精巧的强制转换方案不只是把一种类型转换成另一种,而是尝试把两种不同的类型各自转换成第三种公共类型。比如菱形和矩形:彼此都不是对方的特例,但都能看作四边形。强制转换的另一种扩展是迭代强制转换,即一种数据类型经由若干中间类型转换成另一种。比如整数可以先转成分数,再把分数转成实数。这样链式地强制转换,能减少程序所需的强制转换函数总数。
尽管有这些优势,强制转换也有潜在的缺点。其一,强制转换函数在应用时可能丢失信息。在我们的例子里,分数是精确表示,一旦转换成复数就成了近似值。
有些编程语言内置了自动强制转换系统。事实上,早期版本的 Python 在对象上就有 __coerce__ 特殊方法。最终,内置强制转换系统的复杂度没能抵得上它的用处,于是被移除了。取而代之的是,具体的运算符会按需对参数做强制转换。