2.7 对象抽象

对象系统允许程序员高效地构建和使用抽象数据表示。 它还允许抽象数据的多种表示在同一个程序中共存。

对象抽象中的一个中心概念是泛型函数,它可以接受多种不同类型的值。 我们将考虑实现泛型函数的三种不同技术:共享接口、类型分派和强制类型转换。 在构建这些概念的过程中,我们还将发现Python对象系统支持创建泛型函数的特性。

2.7.1 字符串转换

为了有效地表示数据,对象值的行为应该与它要表示的数据类型类似,包括生成自身的字符串表示。 数据值的字符串表示在交互式语言(如Python)中特别重要,因为Python会在交互式会话中自动显示表达式值的字符串表示。

字符串值为人类之间的信息交流提供了基本的媒介。 字符序列可以在屏幕上呈现,打印到纸上,大声朗读,转换成盲文,或作为莫尔斯电码广播。 字符串也是编程的基础,因为它们可以表示Python表达式。

Python规定所有对象都应该产生两种不同的字符串表示形式:一种是人类可解释的文本,另一种是Python可解释的表达式。 字符串的构造函数str返回一个人类可读的字符串。 在可能的情况下,repr函数返回一个计算结果为相等对象的Python表达式。 repr的文档字符串解释了此属性

repr(object) -> string

Return the canonical string representation of the object.
For most object types, 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时不存在的数据类型。我们希望它是一个通用的或多态的函数,可以应用于许多(多)不同形式的数据(形态)。

对象系统在这种情况下提供了一个优雅的解决方案:repr函数总是在其参数上调用一个名为__repr__的方法。

>>> tues.__repr__()
'datetime.date(2011, 9, 12)'

通过在用户定义的类中实现相同的方法,我们可以将repr的适用性扩展到将来创建的任何类。这个例子强调了点表达式的另一个好处,即它们提供了一种机制,可以将现有函数的域扩展到新的对象类型。

str构造函数以类似的方式实现:它在其参数上调用一个名为__str__的方法。

>>> tues.__str__()
'2011-09-12'

这些多态函数是一个更普遍原则的例子:某些函数应该适用于多种数据类型。此外,创建这样一个函数的一种方法是在每个类中使用具有不同定义的共享属性名。

2.7.2 特殊方法

在Python中,某些特殊的名称会在特殊的情况下由Python解释器调用。 例如,类的__init__方法在构造对象时自动调用。 __str__方法在打印时被自动调用,__repr__在交互式会话中被调用以显示值。

Python中的许多其他行为都有特殊的名称。 下面描述了一些最常用的方法。

真值和假值。 我们之前已经看到Python中的数字有一个真值;更具体地说,0是假值,其他所有数字都是真值。 事实上,Python中的所有对象都有一个真值。默认情况下,用户定义类的对象被认为是true,但可以使用特殊的__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!')
9

len函数调用其参数的__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会在表达式的左右操作数上检查特殊方法。 首先,Python检查左操作数的值是否有__add__方法,然后检查右操作数的值是否有__radd__方法。 如果找到其中一个方法,将以另一个操作数的值作为参数调用该方法。 下面几节给出了一些例子。 对于想了解更多细节的读者,Python文档描述了操作符的完整方法名集。 深入Python 3有一章是关于特殊方法名的,描述了这些特殊方法名的使用情况。

2.7.3 多个表示

抽象障碍允许我们将数据的使用和表示分开。 然而,在大型程序中,在程序中说数据类型的“底层表示”可能并不总是有意义的。 一方面,一个数据对象可能有不止一种有用的表示,我们可能希望设计能够处理多种表示的系统。

举个简单的例子,复数可以用两种几乎相等的方式表示:一种是矩形形式(实部和虚部),另一种是极坐标形式(大小和角度)。 有时用矩形形式更合适,有时用极坐标形式更合适。 事实上,我们完全可以想象这样一个系统:在这个系统中,复数可以用两种方式表示,而且处理复数的函数可以用任意一种表示方式。 我们在下面实现这样一个系统。 作为附注,我们开发一个对复数执行算术运算的系统,作为一个使用泛型运算的简单但不现实的程序示例。 实际上,Python内置了一个复数类型,但在本例中,我们将实现自己的复数类型。

允许多种数据表示的想法经常出现。 大型软件系统通常是由许多人在很长一段时间内工作而设计的,这些人的需求会随着时间的变化而变化。 在这样的环境中,不可能让每个人事先就数据表示的选择达成一致。 除了将表示与使用隔离开来的数据抽象障碍之外,我们还需要将不同的设计选择彼此隔离开来,并允许不同的选择在一个程序中共存的抽象障碍。

我们将从抽象的最高层次开始实现,并努力实现具体的表示。 复数是一个数,数可以相加,也可以相乘。 通过方法名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的目的不是直接实例化,而是作为各种特定Number类的超类。 我们的下一个任务是定义适合复数的add和mul。

复数可以被认为是二维空间中的一个点,它有两个正交的轴,实轴和虚轴。 从这个角度来看,复数c = real + imag * i(其中i * i = -1)可以认为是平面上水平坐标为实数,垂直坐标为imag的点。 复数相加涉及到相加它们各自的实数坐标和图像坐标。

当复数相乘时,更自然的做法是用极坐标表示复数,比如大小和角度。 两个复数的乘积是一个复数乘以另一个复数的长度的倍数,然后通过另一个复数的角度旋转得到的向量。

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从大小和角度构造一个复数

接口。对象属性是消息传递的一种形式,它允许不同的数据类型以不同的方式响应相同的消息。从不同类中引出类似行为的共享消息集是一种强大的抽象方法。接口是一组共享的属性名,以及它们的行为规范。对于复数,实现算术所需的接口由四个属性组成:实数、图像、大小和角度。

为了使复数算术正确,这些属性必须一致。也就是说,直角坐标(实、像)和极坐标(大小、角度)必须描述复平面上的同一点。Complex类通过确定如何使用这些属性来add和mul复数,隐式地定义了这个接口。

属性。要求两个或多个属性值彼此保持固定的关系是一个新问题。一种解决方案是仅存储一种表示的属性值,并在需要时计算另一种表示。

Python有一个简单的特性,可以从零参数函数动态计算属性。@property装饰器允许调用函数时不需要调用表达式语法(表达式后面的圆括号)。ComplexRI类存储real和imagg属性,并根据需要计算大小和角度。

>>> 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的更改反映在大小和角度上。

>>> 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或magnitude的变化会立即反映在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方法是通用的,因为它可以将一个ComplexRI或ComplexMA作为other的值。 这种灵活性是通过确保ComplexRI和ComplexMA共享一个接口而获得的。 使用接口和消息传递只是用于实现通用函数的几种方法之一。 在本节中,我们将考虑另外两个问题:类型分派和类型强制。

假设,除了我们的复数类之外,我们实现了一个有理数类来精确地表示分数。 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接受一个对象和一个类。 如果对象的类是或继承自给定类,则返回true。

>>> c = ComplexRI(1, 1)
>>> isinstance(c, ComplexRI)
True
>>> isinstance(c, Complex)
True
>>> isinstance(c, ComplexMA)
False

类型分派的一个简单示例是is_real函数,它对每种类型的复数使用不同的实现。

>>> def is_real(c):
        """Return whether c is a real number with no imaginary part."""
        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和具有字符串值的复杂实例提供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可以近似地转换为一个浮点值,该浮点值是一个实数。结果可以与一个复数相结合。

>>> 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方法可以将第二个参数作为参数。 否则,它检查跨类型实现的字典(称为addr)是否包含可以添加那些类型标记参数的函数。 如果有这样的函数,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.adders添加跨类型操作来将自己安装到系统中。他们也可以在一个子类中定义自己的adders和multipliers。

虽然我们已经给系统引入了一些复杂性,但是现在我们可以在加法表达式和乘法表达式中混合类型。

>>> ComplexRI(1.5, 0) + Rational(3, 2)
ComplexRI(3, 0)
>>> Rational(-1, 2) * ComplexMA(4, pi/2)
ComplexMA(2, 1.5 * pi)

强制类型转换。 在完全不相关的操作作用于完全不相关的类型的一般情况下,实现显式的跨类型操作,尽管可能很麻烦,但这是人们所能期望的最好结果。幸运的是,通过利用类型系统中可能隐藏的额外结构,有时我们可以做得更好。 通常,不同的数据类型不是完全独立的,而且可能存在将一种类型的对象视为另一种类型的方法。 这个过程叫做强制类型转换。 例如,如果要求我们对一个有理数和一个复数进行算术组合,我们可以把这个有理数看作一个虚数部分为零的复数。

这样做之后,我们可以使用Complex.add和Complex.mul。 把它们结合起来。

通常来说,我们可以通过设计将一种类型的对象转换为另一种类型的等价对象的强制函数来实现这一思想。 这是一个典型的强制类型转化函数,它将有理数转换为虚数部分为零的复数:

>>> def rational_to_complex(r):
        return ComplexRI(r.numer/r.denom, 0)

Number类的另一种定义通过将两个实参强制为同一类型来执行跨类型操作。coercions字典通过一对类型标记对所有可能的强制类型转换进行索引,指示相应的值将第一个类型的值强制为第二个类型的值。

通常不可能将每种类型的任意数据对象强制转换为所有其他类型。 例如,没有办法将任意复数强制为有理数,因此在coercions字典中不会有这样的转换实现。

coerce方法返回两个具有相同类型标记的值。 它检查参数的类型标记,将它们与矫顽字典中的条目进行比较,并使用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__方法。 最后,内置强制系统的复杂性并不能证明它的使用是合理的,所以它被删除了。 相反,特定的操作符会根据需要对其参数应用强制。

Last updated

Was this helpful?