我们已经介绍过数据抽象,这是一种构造系统的方法学,它能够使程序中的大部分描述与其所操作的数据对象的具体表示无关,比如一个有理数程序的设计与有理数的实现相分离。这里的关键是构筑数据抽象屏障——在有理数的例子中即有理数的构造函数(make_rat
)和获取有理数分子分母的选择函数(numer
、denom
)——它能将有理数的使用方式与其借助于表结构的具体表示形式隔离开。
数据抽象屏障是控制复杂性的强有力工具,然而这种类型的数据抽象还不够强大有力。从一个另一个角度看,对于一个数据对象可能存在多种有用的表示方式,且我们希望所设计的系统能够处理多种表示形式。比如,复数就可以表示为两种几乎等价的形式:直角坐标形式(实部和虚部)和极坐标形式(模和幅角)。有时采用直角坐标更方便,有时采用幅角更方便。我们希望设计的过程能够对具有任意表示形式的复数工作。
我们从简单的复数实例开始,看看如何为复数设计出直角坐标表示和极坐标表示,而又维持一种抽象的“复数”数据对象的概念。做到这一点的方式就是基于通用型选择函数(real_part
、img_part
、magnitude
、angle
)来定义复数的算术运算(add_complex
、sub_complex
、mul_complex
和div_complex
),使这些选择函数能访问一个复数的各个部分,无论复数采用的是什么表示方式。采用这种方法设计的复数系统如下图所示:
上图中包含两种不同的抽象屏障。“水平”抽象屏障所扮演的角色如我们在有理数中讲的相同,他们将“高层”操作与“底层”表示隔离开。与此同时,还存在一道“垂直”屏障,它使我们能够隔离不同的设计,并且还能够安装其他的表示方式。
2.4.1 复数的表示
复数表示为有序对有两种可能表示方式:直角坐标形式(实部和虚部)和极坐标形式(模和幅角)。我们将复数集合设想为一个带有两个坐标轴(“实”轴和“虚”轴)的两维空间,如下图所示。按照这一观点,复数\(z = x + iy\)(其中\(i^2=-1\))可看作这个平面上的一个点,其中的实坐标是\(x\)而虚坐标为\(y\)。在这种表示下,复数的加法就可以归结为两个坐标相加:
当需要乘两个复数时,更自然的考虑是采用复数的极坐标形式,此时复数用一个模和一个幅角表示(上图中的\(r\)和\(A\))。两个复数的乘积也是一个向量,得到它的方式是模相乘,幅角相加。
虽说复数的两种不同表示方式适合不同的运算,但从开发人员角度来看,数据抽象原理希望所有复数操作都应该可以使用,而无论计算机所用的具体表示形式是什么。例如我们常常需要取得一个复数的模,即使它原本采用的是复数的直角坐标表示;同样我们也常常需要得到复数的实部,即使它采用的是极坐标形式。
我们沿用之前有理数的设计策略,假定所有复数运算的实现都基于如下四个选择函数:real_part
、img_part
、magnitude
和angle
;还要假定有两个构造复数的过程:make_from_real_imag
根据实部和虚部返回一个(基于某种表示的)复数,make_from_mag_ang
根据模和幅角描述返回一个(基于某种表示的)复数。这些过程的性质是。对于任何复数\(z\)(不管其基于何种表示方式),下面两者:
make_from_real_imag(real_part(z), imag_part(z))
和
make_from_mag_ang(magnitude(z), angle(z))
产生出的复数都应该等于\(z\)(且保持原来的表示方式)。
我们可以利用这些构造函数和选择函数来刻画“抽象数据”,从而实现复数的算术。正如上面公式中所述,复数的加法和减法采用实部和虚部方式描述,而乘法和除法采用模和幅角的方式描述。
def add_complex(z1, z2):
return make_from_real_imag(real_part(z1) + real_part(z2), imag_part(z1) + imag_part(z2))
def sub_complex(z1, z2):
return make_from_real_imag(real_part(z1) - real_part(z2), imag_part(z1) - imag_part(z2))
def mul_complex(z1, z2):
return make_from_mag_ang(magnitude(z1) * magnitude(z2), angle(z1) + angle(z2))
def div_complex(z1, z2):
return make_from_mag_ang(magnitude(z1) / magnitude(z2), angle(z1) - angle(z2))
为了完成这一复数包,我们必须选择一种表示方式。我们假定有两个程序员Ben和Hacker。Ben选择了复数的直角坐标形式,Alyssa选择了复数的极坐标形式。对于选择直角坐标形式的Ben而言,此时实部和虚部的获取是直截了当的,但为了得到模和幅角,或需要在给定模和幅角下构造复数时,他利用了下面的三角关系:
这些公式建立起实部和虚部对偶\((x, y)\)与模和幅角对偶\((r, A)\)之间的联系。Ben基于这种表示给出了下面这几个选择函数和构造函数:
import math
def real_part(z):
return z[0]
def imag_part(z):
return z[1]
def magnitude(z):
return math.sqrt(real_part(z) ** 2 + imag_part(z) ** 2)
def angle(z):
return math.atan2(imag_part(z), real_part(z))
def make_from_real_imag(x, y):
return [x, y]
def make_from_mag_ang(r, a):
return [r * math.cos(a), r * math.sin(a)]
下列是我们对Ben的直角坐标表示方法的测试结果:
complex_1 = make_from_real_imag(math.sqrt(3)/2, 1/2) # (sqrt(3)/2, 1/2)
complex_2 = make_from_real_imag(1/2, math.sqrt(3)/2) # (1/2, sqrt(3)/2)
print(add_complex(complex_1, complex_2))
# [1.3660254037844386, 1.3660254037844386], 对应(sqrt(3)/2 + 1/2, sqrt(3)/2 + 1/2)
print(mul_complex(complex_1, complex_2))
# [6.123233995736765e-17, 0.9999999999999998],对应(0, 1)
但在另一边,对于选择了复数的极坐标形式的Alyssa而言,选取模和幅角的操作直截了当,但必须通过三角关系去得到实部和虚部。Alyssa基于复数的极坐标形式所给出的选择函数和构造函数如下:
def real_part(z):
return magnitude(z) * math.cos(angle(z))
def imag_part(z):
return magnitude(z) * math.sin(angle(z))
def magnitude(z):
return z[0]
def angle(z):
return z[1]
def make_from_real_imag(x, y):
return [math.sqrt(x**2 + y**2), math.atan2(y, x)]
def make_from_mag_ang(r, a):
return [r, a]
下列是我们对Alyssa的极坐标表示方法的测试结果:
complex_1 = make_from_mag_ang(1, math.pi/6) # (1, pi/6)
complex_2 = make_from_mag_ang(1, math.pi/3) # (1, pi/3)
print(add_complex(complex_1, complex_2))
# [1.9318516525781366, 0.7853981633974483], 对应(sqrt(6) + sqrt(2))/2, arctan(sqrt(3)/2 + 1/2, sqrt(3)/2 + 1/2))
print(mul_complex(complex_1, complex_2))
# [1, 1.5707963267948966] 对应(1, pi/2)
数据抽象的规则保证了add_complex
、sub_complex
、mul_complex
和div_complex
的同一套实现对于Ben的表示或者Alyssa的表示都能正常工作。
2.4.2 带标志数据
认识数据抽象的一种方式是将其看作“最小允诺原则”的一个应用。在 2.4.1 节中我们可以选择采用Ben的直角坐标表示形式或者Alyssa的极坐标表示形式,由选择函数和构造函数形成的抽象屏障,使我们可以把为自己所用数据对象选择具体表示形式的事情尽量往后推,而且还能保持系统设计的最大灵活性。
最小允诺原则还可以推进到更极端的情况,我们可以在完成了对选择函数和构造函数的设计,并决定了同时使用Ben的表示和Alyssa的表示之后,依然维持所用表示方式的不确定性。如果要在同一个系统中包含这两种不同表示,那么就需要采用一种方式将极坐标形式的数据和直角坐标形式的数据区分开。
完成这种区分的一种方式,就是在每个复数里包含一个类型标志部分——符号rectangular
或者polar
,我们在操作复数时可以借助此标志来确定使用的选择函数。
为了能对带标志数据进行各种操作,我们假定有过程type_tag
和contents
,它们分别从数据对象中提取类型标志和实际内容(在复数的例子中即极坐标或者直角坐标)。还要假定一个过程attach_tag
,它以一个标志和实际内容为参数,生成出一个带标志的数据对象。实现这些的直接方式就是采用普通的表结构:
def attach_tag(type_tag, contents):
return [type_tag, contents]
def type_tag(datum):
if isinstance(datum, list):
return datum[0]
else:
raise ValueError("Bad tagged dataum -- TYPE-TAG", datum)
def contents(datum):
if isinstance(datum, list):
return datum[1]
else:
raise ValueError("Bad tagged dataum -- CONTENTS", datum)
利用这些过程,我们就可以定义出谓词is_rectangular
和is_polar
,它们分别辨识直角坐标的和极坐标的复数:
def is_rectangular(z):
return type_tag(z) == "rectangular"
def is_polar(z):
return type_tag(z) == "polar"
有了类型系统之后,Ben和Alyssa现在就可以修改自己的代码,使他们的两种不同表示能共存于一个系统中了。当Ben构造一个复数时,总为它加上标志,说明采用的是直角坐标:
def real_part_rectangular(z):
return z[0]
def imag_part_rectangular(z):
return z[1]
import math
def magnitude_rectangular(z):
return math.sqrt(real_part_rectangular(z)**2 +
imag_part_rectangular(z)**2)
def angle_rectangular(z):
return math.atan2(imag_part_rectangular(z),
real_part_rectangular(z))
def make_from_real_imag_rectangular(x, y):
return attach_tag("rectangular", [x, y])
def make_from_mag_ang_rectangular(r, a):
return attach_tag("rectangular", [r * math.cos(a), r * math.sin(a)])
Alyssa构造复数时,总将其标志设为极坐标:
def real_part_polar(z):
return magnitude_polar(z) * math.cos(angle_polar(z))
def imag_part_polar(z):
return magnitude_polar(z) * math.sin(angle_polar(z))
def magnitude_polar(z):
return z[0]
def angle_polar(z):
return z[1]
def make_from_real_imag_polar(x, y):
return attach_tag("polar",
[math.sqrt(x**2 + y**2),
math.atan2(y, x)])
def make_from_mag_ang_polar(r, a):
return attach_tag("polar", [r, a])
每个通用型选择函数都需要考虑到可能存在的两种复数表示情况,故它需要先检查参数的标志,然后调用处理该类数据的适当过程。例如为了得到一个复数的实部,real_part
需要通过检查确定是使用Ben
的real_part_rectangular
还是Alyssa
的real_part_polar
。在这两种情况下,我们都用contents
提取出原始的无标志数据,并将它送给所需的直角坐标过程或极坐标过程:
def real_part(z):
if is_rectangular(z):
return real_part_rectangular(contents(z))
elif is_polar(z):
return real_part_polar(contents(z))
else:
raise ValueError("Unknown type -- REAL-PART", z)
def imag_part(z):
if is_rectangular(z):
return imag_part_rectangular(contents(z))
elif is_polar(z):
return imag_part_polar(contents(z))
else:
raise ValueError("Unknown type -- IMAG-PART", z)
def magnitude(z):
if is_rectangular(z):
return magnitude_rectangular(contents(z))
elif is_polar(z):
return magnitude_polar(contents(z))
else:
raise ValueError("Unknown type -- MAGNITUDE", z)
def angle(z):
if is_rectangular(z):
return angle_rectangular(contents(z))
elif is_polar(z):
return angle_polar(contents(z))
else:
raise ValueError("Unknown type -- ANGLE", z)
在实现复数算术运算时,我们仍然可以采用取自2.4.1节的同样过程add_complex
、sub_complex
、mul_complex
和div_complex
,因为它们所调用的选择函数都是通用型的,对任何表示都能工作,例如过程add_complex
仍然是:
def add_complex(z1, z2):
return make_from_real_imag(real_part(z1) + real_part(z2),
imag_part(z1) + imag_part(z2))
最后,我们还必须选择是采用Ben的表示还是Alyssa的表示构造复数。一种合理的选择是,手头有实部和虚部时,构造函数的返回采用直角坐标表示;手头有模和幅角时,构造函数的返回采用极坐标表示:
def make_from_real_imag(x, y):
# 手头有实部和虚部时,构造函数的返回采用直角坐标表示
return make_from_real_imag_rectangular(x, y)
def make_from_mag_angle(r, a):
# 手头有模和幅角时,构造函数的返回采用极坐标表示
return make_from_mag_ang_polar(r, a)
下面是我们对这样的复数系统进行的测试结果:
complex_1 = make_from_mag_ang_polar(1, math.pi/6) # (1, pi/6)
complex_2 = make_from_mag_ang_polar(1, math.pi/3) # (1, pi/3)
print(add_complex(complex_1, complex_2))
# ['rectangular', [1.3660254037844388, 1.3660254037844386]], 对应(sqrt(3)/2 + 1/2, sqrt(3)/2 + 1/2)
实际上,这样得到的复数系统所具有的结构如下所示:
可见这一系统已经分解为三个相对独立的部分:复数算术运算、Alyssa的极坐标实现和Ben的直角坐标实现。极坐标或直角坐标的实现可以是Ben和Alyssa独立工作写成的东西,这两部分又被第三个程序员作为基础表示,用于在抽象构造函数和选择函数的界面(interface)之上实现各种复数算术过程。
2.4.3 数据导向的程序设计和可加性
检查一个数据项的类型,并据此去调用某个适当过程称为基于类型的分派。在系统设计中,这是一种获得模块性的强有力策略。而在另一方面,像2.4.2节那样实现的分派有两个明显的弱点。第一个弱点是,其中的这些通用型界面过程(real_part
、imag_part
、magnitude
和angle
)必须知道所有的不同表示。举例来说,假定现在希望为前面的复数系统增加一种表示,我们就必须将这一新表示方式标识为一种新类型,而且要在每个通用界面过程里增加一个子句,检查这一新类型。
第二个弱点是,即使这些独立的表示形式可以分别设计,我们也必须保证在系统里不存在两个名字相同的过程。正因如此,Ben和Alyssa必须去修改原来在2.4.1节中给出的那些过程的名字。
位于这两个弱点之下的基础问题是,上面这种实现通用型界面的技术不具有可加性。在每次增加一种新表示形式时,使用通用选择函数的人都必须修改他们的过程,而那些做独立表示的界面的人也必须修改代码以避免名字冲突问题,这非常不方便,且容易引进错误。
现在我们需要的是一种能够将系统设计进一步模块化的方法。一种称为数据导向的程序设计的编程技术提供了这种能力。在这种编程技术中,我们可以将处理针对不同类型的一些公共通用操作视为处理一个二维表格,其中一维包含着所有的可能操作,另一维就是所有的可能类型。在前一节开发的复数系统里,我们也可以将同样的信息组织为一个表格,如下图所示:
在我们上面所实现的复数系统中,采用的方式是用一些过程做为复数算术与两个表示包之间的界面,并且让这些过程中的每一个去做基于类型的显式分派。而数据导向的程序设计则意味着我们可以把这一界面实现为一个过程,让它用操作名和参数类型的组合到表格中查找,以便找出应该调用的适当过程。
参考
- [1] Abelson H, Sussman G J. Structure and interpretation of computer programs[M]. The MIT Press, 1996.