Skip to content

2.2 数据抽象

INFO

译者:mancuojclcs

来源:2.2 Data Abstraction

环顾现实世界,当我们想要在程序中表示各种事物时,会发现绝大多数事物都具有复合结构。例如,一个地理位置包含纬度和经度坐标。为了表示位置,我们希望编程语言具备将经度和纬度耦合在一起形成一个(pair)的能力。这种复合数据(compound data)值在程序中可以作为一个独立的概念单元来操作,但同时也包含两个可以单独考虑的部分。

使用复合数据使我们能够提高程序的模块化程度。如果能将地理位置作为一个整体值来操作,就能将程序中利用位置进行计算的部分,与位置具体的表示细节隔离开来。这种将程序中处理数据表示的部分处理数据操作的部分隔离开来的通用技术,是一种强大的设计方法论,称为数据抽象(data abstraction)。数据抽象使程序更易于设计、维护和修改。

数据抽象在性质上与函数抽象相似。当我们创建函数抽象时,函数实现的细节被隐藏,只要行为一致,函数本身可以被任何其他函数替换。换句话说,我们构建的抽象将函数的使用方式函数的实现细节分离开来。同理,数据抽象将复合数据值的使用方式与其构造细节隔离开来

数据抽象的基本理念是构造程序,使其操作抽象数据。也就是说,程序在使用数据时,应尽可能少地对数据做假设。同时,具体的数据表示被定义为程序中独立的一部分。

程序的这两个部分——操作抽象数据的部分和定义具体表示的部分——通过一小组函数连接起来,这组函数用具体的表示实现了抽象数据。为了说明这一技术,我们将通过设计一组处理有理数的函数来进行演示。

2.2.1 示例:有理数

有理数是整数之比,它构成了实数的一个重要子类。像 1/317/29 这样的有理数通常写成:

py
<分子>/<分母>

其中 <分子><分母>整数值的占位符。这两个部分对于精确描述有理数的值都是必不可少的。实际上,直接进行整数相除会产生 float(浮点)近似值,从而丢失整数的精确度。

py
>>> 1/3
0.3333333333333333
>>> 1/3 == 0.333333333333333300000  # 整数除法得到是近似值
True

然而,我们可以通过将分子和分母组合在一起,创建有理数的精确表示

我们从函数抽象中得知,可以在程序的某些部分尚未实现之前就开始高效编程。让我们先假设已经有了从分子和分母构造有理数的方法,以及给定一个有理数提取其分子和分母的方法。我们要进一步假设,这个构造函数和选择函数通过以下三个函数提供:

  • rational(n, d) 返回分子为 n、分母为 d 的有理数
  • numer(x) 返回有理数 x 的分子
  • denom(x) 返回有理数 x 的分母

我们在这里使用了一个强大的程序设计策略:一厢情愿(wishful thinking)。我们还没有说明有理数是如何表示的,也没有说明 numerdenomrational 应该如何实现。即便如此,如果确实定义了这三个函数,我们现在就已经可以进行有理数的加法、乘法、打印和相等性测试了:

py
>>> def add_rationals(x, y):
        nx, dx = numer(x), denom(x)
        ny, dy = numer(y), denom(y)
        return rational(nx * dy + ny * dx, dx * dy)

>>> def mul_rationals(x, y):
        return rational(numer(x) * numer(y), denom(x) * denom(y))

>>> def print_rational(x):
        print(numer(x), '/', denom(x))

>>> def rationals_are_equal(x, y):
        return numer(x) * denom(y) == numer(y) * denom(x)

现在,我们已经通过选择函数 numerdenom 以及构造函数 rational 定义了有理数的操作,但我们还没有定义这些函数本身。我们需要某种方法将分子和分母粘合成一个复合值。

2.2.2 对

为了使我们能够实现数据抽象的具体层,Python 提供了一种称为列表(list)的复合结构。列表可以通过将表达式放在方括号内并用逗号分隔来构造。这种表达式称为列表字面量

py
>>> [10, 20]
[10, 20]

访问列表元素有两种方式。第一种是我们熟悉的多重赋值法,它将列表解包成元素并绑定到不同的名称上。

py
>>> pair = [10, 20]
>>> pair
[10, 20]
>>> x, y = pair
>>> x
10
>>> y
20

访问列表中元素的第二种方法是使用元素选择运算符,同样使用方括号表示。与列表字面量不同,紧跟在另一个表达式之后的方括号表达式不会求值为 list 值,而是从前一个表达式的值中选择一个元素。

py
>>> pair[0]
10
>>> pair[1]
20

Python 中的列表(以及大多数其他编程语言中的序列)是 0 索引(0-indexed)的,这意味着索引 0 选择第一个元素,索引 1 选择第二个,依此类推。支持这种索引惯例的一个直觉是:索引代表元素距离列表开头的偏移量

元素选择运算符对应的函数称为 getitem ,它也使用 0 索引位置从列表中选择元素。

py
>>> from operator import getitem
>>> getitem(pair, 0)
10
>>> getitem(pair, 1)
20

双元素列表并不是 Python 中表示“对”的唯一方法。任何将两个值捆绑为一个值的方式都可以视为“对”。列表是一种常用的方法。列表也可以包含超过两个元素,我们将在本章稍后探讨这一点。

表示有理数:现在我们可以将有理数表示为两个整数的“对”:一个分子和一个分母。

py
>>> def rational(n, d):
        return [n, d]

>>> def numer(x):
        return x[0]

>>> def denom(x):
        return x[1]

结合我们之前定义的算术运算,我们可以用这些定义好的函数来操作有理数了。

py
>>> half = rational(1, 2)
>>> print_rational(half)
1 / 2
>>> third = rational(1, 3)
>>> print_rational(mul_rationals(half, third))
1 / 6
>>> print_rational(add_rationals(third, third))
6 / 9

如上面的例子所示,我们的有理数实现并没有将有理数化简为最简分数(lowest terms)。我们可以通过修改 rational 的实现来弥补这个缺陷。如果我们有一个计算两个整数最大公约数(GCD)的函数,就可以在构造“对”之前将分子和分母化简。和许多常用工具一样,Python 标准库中已经存在这样的函数。

py
>>> from fractions import gcd
>>> def rational(n, d):
        g = gcd(n, d)
        return (n//g, d//g)

译注:在 Python 3.5+ 中,gcd 已移动到 math 模块,具体可以查看 Python 文档。此处保留原文 fractions 模块的用法以符合原文语境。)

地板除运算符 // 表示整数除法,它会向下取整除法结果的小数部分。因为我们知道 g 能整除 nd,所以在此例中整数除法是精确的。这个修改后的 rational 实现确保了有理数总是以最简形式表示。

py
>>> print_rational(add_rationals(third, third))
2 / 3

这一改进完全是通过修改构造函数完成的,而没有修改任何实现实际算术运算的函数。

2.2.3 抽象屏障

在继续探讨更多复合数据和数据抽象的示例之前,让我们先来思考一下有理数示例所引出的一些问题。我们是依据构造函数 rational 以及选择函数 numerdenom 来定义各种运算的。总的来说,数据抽象的核心思想是:确定一组基本操作,通过这组操作来表达对某类数值的所有处理行为,并且在处理数据时使用这些操作。通过这种方式限制对操作的使用,我们就能更容易地修改抽象数据的底层表示,而无需改变程序的行为。

对于有理数而言,程序的不同部分使用不同的操作来处理有理数,如下表所示:

程序的哪一部分...把有理数当作...仅使用...
使用有理数进行计算整体数据值add_rationalmul_rational, rationals_are_equal, print_rational
创建有理数或实现有理数运算分子和分母rational, numer, denom
实现有理数的选择函数和构造函数双元素列表列表字面量和元素选择操作

在上述每一层中,最后一列列出的函数都构筑了一道抽象屏障 (abstraction barrier)。这些函数由上一层调用,并使用下一层的抽象来实现

当程序的某个部分本可以使用高层级函数,却反而使用了低层级函数时,就发生了抽象屏障违犯 (abstraction barrier violation)。例如,一个计算有理数平方的函数,最好基于 mul_rational 来实现,因为 mul_rational 不会对有理数的具体实现做任何假设。

py
>>> def square_rational(x):
        return mul_rational(x, x)

如果直接引用分子和分母,就会违反一层抽象屏障:

py
>>> def square_rational_violating_once(x):
        return rational(numer(x) * numer(x), denom(x) * denom(x))

如果假设有理数是用双元素列表表示的,那就会违反两层抽象屏障:

py
>>> def square_rational_violating_twice(x):
        return [x[0] * x[0], x[1] * x[1]]

抽象屏障使程序更易于维护和修改。依赖于特定数据表示的函数越少,当我们想要改变这种表示时,需要修改的地方也就越少。上面这三种 square_rational 的实现行为都是正确的,但只有第一种对未来的变化具有鲁棒性 (robustness,译注:我更愿意称其为健壮性)。即使我们要改变有理数的表示方式,square_rational 函数也无需更新。相比之下,只要选择函数或构造函数的签名发生变化,square_rational_violating_once 就需要修改;而只要有理数的底层实现发生变化,square_rational_violating_twice 就需要更新。

2.2.4 数据的属性

抽象屏障塑造了我们思考数据的方式。有理数的有效表示并不局限于任何特定的实现(比如双元素列表);它只要是由 rational 返回,并能传递给 numerdenom 的值即可。此外,构造函数和选择函数之间必须保持某种恰当的关系。也就是说,如果我们用整数 nd 构造了一个有理数 x,那么 numer(x)/denom(x) 必须等于 n/d

一般而言,我们可以用一组选择函数和构造函数,加上一些行为条件 (behavior conditions) 来表达抽象数据。只要满足行为条件(如上面的除法性质),这些选择函数和构造函数就构成了一种数据的有效表示。抽象屏障之下的实现细节可以改变,但只要行为不变,数据抽象就依然有效,任何使用该数据抽象编写的程序也将保持正确。

这种观点具有广泛的适用性,包括我们用于实现有理数的“对 (pair)”值。我们从未详细说明“对”到底是什么,只说语言提供了创建和处理双元素列表的手段。我们需要实现“对”的行为仅仅是它将两个值粘合在一起。若将其表述为行为条件,即:

  • 如果一个对 p 是由值 xy 构造的,那么 select(p, 0) 返回 x,而 select(p, 1) 返回 y

我们实际上并不需要 list 类型来创建“对”。相反,我们可以实现两个函数 pairselect,它们能像双元素列表一样很好地满足上述描述。

py
>>> def pair(x, y):
        """返回一个表示“对”的函数"""
        def get(index):
            if index == 0:
                return x
            elif index == 1:
                return y
        return get

>>> def select(p, i):
        """返回对 p 中索引 i 处的元素"""
        return p(i)

通过这个实现,我们可以创建和操作对。

py
>>> p = pair(20, 14)
>>> select(p, 0)
20
>>> select(p, 1)
14

这种高阶函数的使用,与我们就“数据应该是什么样”的直觉概念完全不同。尽管如此,这些函数足以在我们的程序中表示“对”。函数足以表示复合数据

展示这种函数式表示法的目的,并非在于说明 Python 实际上是这样工作的(出于效率原因,列表的实现要直接得多),而是在于说明它可以这样工作。这种函数式表示虽然晦涩,但却是表示“对”的一种完全适当的方式,因为它满足了“对”所需要满足的唯一条件。数据抽象的实践使我们能够轻松地在不同的表示之间进行切换。

基于 MIT 许可发布