2.4  抽象数据的多重表示

我们已经介绍了数据抽象,这是一种构造系统的方法学,使得程序的很大部分可以独立于实现程序所操作的数据对象的选择来指定。例如,我们在2.1.1节中看到了如何分离设计使用有理数的程序和用计算机语言的基本机制实现有理数的任务。关键的想法是建立一道抽象屏障——在这个例子中是有理数的选择器和构造器(make-ratnumerdenom)——它将有理数的使用方式与它们在列表结构中的底层表示隔离开。类似的抽象屏障将执行有理数算术的过程(add-ratsub-ratmul-ratdiv-rat)的细节与使用有理数的"更高级"过程隔离开。由此产生的程序具有图2.1所示的结构。

这些数据抽象屏障是控制复杂性的有力工具。通过隔离数据的底层表示,我们可以将设计大型程序的任务划分为可以独立执行的更小任务。但这种数据抽象还不够强大,因为谈论一个数据对象的"底层表示"并不总是有意义的。

一方面,一个数据对象可能有不止一种有用的表示,我们可能希望设计能处理多重表示的系统。举一个简单的例子,复数可以用两种几乎等价的方式表示:矩形形式(实部和虚部)和极坐标形式(模和幅角)。有时矩形形式更合适,有时极坐标形式更合适。事实上,完全可以想象一个系统以两种方式表示复数,并且处理复数的过程适用于任何一种表示。

更重要的是,编程系统通常由许多人长时间工作、在随时间变化的需求下设计。在这样的环境中,不可能让每个人都事先就数据表示的选择达成一致。因此,除了将表示与使用隔离的数据抽象屏障外,我们还需要将不同的设计选择彼此隔离的抽象屏障,并允许不同的选择在同一个程序中共存。此外,由于大型程序通常是通过组合独立设计的已有模块来创建的,我们需要允许程序员增量式地将模块合并到更大系统中的约定,也就是说,无需重新设计或重新实现这些模块。

在本节中,我们将学习如何处理可能被程序的不同部分以不同方式表示的数据。这需要构造通用过程——能够操作可能以多种方式表示的数据的过程。我们构建通用过程的主要技术将是以具有类型标签的数据对象为基础,即包含关于如何处理它们的显式信息的数据对象。我们还将讨论数据导向编程,这是一种强大且方便的增量式组装具有通用操作的系统的实现策略。

我们从简单的复数例子开始。我们将看到类型标签和数据导向风格如何使我们能够为复数设计独立的矩形和极坐标表示,同时维护抽象的"复数"数据对象的概念。我们将通过通用选择器(独立于表示方式访问复数各部分)来定义复数的算术过程(add-complexsub-complexmul-complexdiv-complex)。由此产生的复数系统,如图2.19所示,包含两种不同的抽象屏障。"水平"抽象屏障扮演着与图2.1中相同的角色。它们将"更高级"的操作与"更低级"的表示隔离开。此外,还有一道"垂直"屏障,使我们能够独立设计和安装替代表示。

图 2.19:  复数系统中的数据抽象屏障。

在2.5节中,我们将展示如何使用类型标签和数据导向风格来开发一个通用算术包。这提供了可用于处理各种"数"的过程(addmul等),并且可以在需要新类型数时轻松扩展。在2.5.3节中,我们将展示如何在执行符号代数的系统中使用通用算术。

2.4.1  复数的表示

我们将开发一个执行复数算术运算的系统,作为一个使用通用操作的程序的简单但不完全现实的例子。我们首先讨论将复数表示为有序对的两种可行方式:矩形形式(实部和虚部)和极坐标形式(模和幅角)。432.4.2节将展示如何通过类型标签和通用操作使两种表示在同一个系统中共存。

与有理数类似,复数自然地表示为有序对。复数集可以被视为具有两个正交轴——"实"轴和"虚"轴的二维空间(见图2.20)。从这个角度来看,复数z = x + iy(其中i2 = -1)可以被视为平面上实坐标为x、虚坐标为y的点。在这种表示中,复数加法归结为坐标相加:

当乘法复数时,以极坐标形式表示复数(即模和幅角,图2.20中的rA)更为自然。两个复数的乘积是通过将一个复数拉伸另一个复数的长度然后旋转其幅角得到的向量:

因此,复数有两种不同的表示,适用于不同的操作。然而,从编写使用复数的程序的人的角度来看,数据抽象的原则表明,无论计算机使用哪种表示,所有操作复数的操作都应可用。例如,能够找到以矩形坐标指定的复数的模通常是有用的。类似地,能够确定以极坐标指定的复数的实部通常是有用的。

图 2.20:  作为平面上的点的复数。

为了设计这样一个系统,我们可以遵循与2.1.1节中设计有理数包时相同的数据抽象策略。假设复数的操作由四个选择器实现:real-partimag-partmagnitudeangle。还假设我们有两个构造复数的过程: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的复数。

使用这些构造器和选择器,我们可以基于构造器和选择器指定的"抽象数据"实现复数的算术,就像我们在2.1.1节中对有理数所做的那样。如上公式所示,我们可以根据实部和虚部加减复数,而根据模和幅角乘除复数:

(define (add-complex z1 z2)
  (make-from-real-imag (+ (real-part z1) (real-part z2))
                       (+ (imag-part z1) (imag-part z2))))
(define (sub-complex z1 z2)
  (make-from-real-imag (- (real-part z1) (real-part z2))
                       (- (imag-part z1) (imag-part z2))))
(define (mul-complex z1 z2)
  (make-from-mag-ang (* (magnitude z1) (magnitude z2))
                     (+ (angle z1) (angle z2))))
(define (div-complex z1 z2)
  (make-from-mag-ang (/ (magnitude z1) (magnitude z2))
                     (- (angle z1) (angle z2))))

要完成复数包,我们必须选择一种表示,并且必须基于原始数和原始列表结构实现构造器和选择器。有两种明显的方法:我们可以用"矩形形式"将复数表示为序对(实部,虚部),或者用"极坐标形式"表示为序对(模,幅角)。我们该选择哪一种?

为了使不同的选择更具体,想象有两个程序员,Ben Bitdiddle和Alyssa P. Hacker,他们正在独立设计复数系统的表示。Ben选择用矩形形式表示复数。有了这个选择,选择复数的实部和虚部很简单,构造具有给定实部和虚部的复数也很简单。要找到模和幅角,或构造具有给定模和幅角的复数,他使用了将实部和虚部(x, y)与模和幅角(r, A)联系起来的三角关系:

44因此,Ben的表示由以下选择器和构造器给出:

(define (real-part z) (car z))
(define (imag-part z) (cdr z))
(define (magnitude z)
  (sqrt (+ (square (real-part z)) (square (imag-part z)))))
(define (angle z)
  (atan (imag-part z) (real-part z)))
(define (make-from-real-imag x y) (cons x y))
(define (make-from-mag-ang r a) 
  (cons (* r (cos a)) (* r (sin a))))

相反,Alyssa选择用极坐标形式表示复数。对她来说,选择模和幅角很简单,但她必须使用三角关系来获得实部和虚部。Alyssa的表示是:

(define (real-part z)
  (* (magnitude z) (cos (angle z))))
(define (imag-part z)
  (* (magnitude z) (sin (angle z))))
(define (magnitude z) (car z))
(define (angle z) (cdr z))
(define (make-from-real-imag x y) 
  (cons (sqrt (+ (square x) (square y)))
        (atan y x)))
(define (make-from-mag-ang r a) (cons r a))

数据抽象的准则确保了add-complexsub-complexmul-complexdiv-complex的同一套实现既可以与Ben的表示一起工作,也可以与Alyssa的表示一起工作。

2.4.2  带标签的数据

看待数据抽象的一种方式是将其视为"最小承诺原则"的应用。在实现2.4.1节中的复数系统时,我们可以使用Ben的矩形表示或Alyssa的极坐标表示。由选择器和构造器形成的抽象屏障允许我们将具体表示的选择推迟到最后一刻,从而在我们的系统设计中保留最大的灵活性。

最小承诺原则可以推向更极端。如果我们愿意,即使在设计完选择器和构造器之后,我们仍可以保持表示的不确定性,并选择同时使用Ben的表示Alyssa的表示。然而,如果两种表示都包含在同一个系统中,我们需要某种方式来区分极坐标形式的数据和矩形形式的数据。否则,如果我们被要求找到序对(3,4)的magnitude,我们将不知道是回答5(将数解释为矩形形式)还是3(将数解释为极坐标形式)。实现这种区分的一个直接方法是在每个复数中包含一个类型标签——符号rectangularpolar。然后,当我们需要操作复数时,可以使用标签来决定应用哪个选择器。

为了操作带标签的数据,我们将假设我们有过程type-tagcontents,它们从数据对象中提取标签和实际内容(对于复数,是极坐标或矩形坐标)。我们还将假设一个过程attach-tag,它接受一个标签和内容,并产生一个带标签的数据对象。一种直接的实现方式是使用普通的列表结构:

(define (attach-tag type-tag contents)
  (cons type-tag contents))
(define (type-tag datum)
  (if (pair? datum)
      (car datum)
      (error "Bad tagged datum -- TYPE-TAG" datum)))
(define (contents datum)
  (if (pair? datum)
      (cdr datum)
      (error "Bad tagged datum -- CONTENTS" datum)))

使用这些过程,我们可以定义谓词rectangular?polar?,它们分别识别极坐标和矩形数:

(define (rectangular? z)
  (eq? (type-tag z) 'rectangular))
(define (polar? z)
  (eq? (type-tag z) 'polar))

有了类型标签,Ben和Alyssa现在可以修改他们的代码,使得他们两种不同的表示可以在同一个系统中共存。每当Ben构造一个复数时,他将其标记为rectangular。每当Alyssa构造一个复数时,她将其标记为polar。此外,Ben和Alyssa必须确保他们过程的名字不冲突。一种方法是在他的每个表示过程名后附加后缀rectangular,而Alyssa在她的过程名后附加polar。以下是Ben修改后的来自2.4.1节的矩形表示:

(define (real-part-rectangular z) (car z))
(define (imag-part-rectangular z) (cdr z))
(define (magnitude-rectangular z)
  (sqrt (+ (square (real-part-rectangular z))
           (square (imag-part-rectangular z)))))
(define (angle-rectangular z)
  (atan (imag-part-rectangular z)
        (real-part-rectangular z)))
(define (make-from-real-imag-rectangular x y)
  (attach-tag 'rectangular (cons x y)))
(define (make-from-mag-ang-rectangular r a) 
  (attach-tag 'rectangular
              (cons (* r (cos a)) (* r (sin a)))))

以下是Alyssa修改后的极坐标表示:

(define (real-part-polar z)
  (* (magnitude-polar z) (cos (angle-polar z))))
(define (imag-part-polar z)
  (* (magnitude-polar z) (sin (angle-polar z))))
(define (magnitude-polar z) (car z))
(define (angle-polar z) (cdr z))
(define (make-from-real-imag-polar x y) 
  (attach-tag 'polar
               (cons (sqrt (+ (square x) (square y)))
                     (atan y x))))
(define (make-from-mag-ang-polar r a)
  (attach-tag 'polar (cons r a)))

每个通用选择器实现为一个检查参数标签并调用适当过程来处理该类型数据的过程。例如,要获取一个复数的实部,real-part检查标签以确定是使用Ben的real-part-rectangular还是Alyssa的real-part-polar。无论哪种情况,我们使用contents来提取裸的、未标记的数据,并将其发送给矩形或极坐标过程:

(define (real-part z)
  (cond ((rectangular? z) 
         (real-part-rectangular (contents z)))
        ((polar? z)
         (real-part-polar (contents z)))
        (else (error "Unknown type -- REAL-PART" z))))
(define (imag-part z)
  (cond ((rectangular? z)
         (imag-part-rectangular (contents z)))
        ((polar? z)
         (imag-part-polar (contents z)))
        (else (error "Unknown type -- IMAG-PART" z))))
(define (magnitude z)
  (cond ((rectangular? z)
         (magnitude-rectangular (contents z)))
        ((polar? z)
         (magnitude-polar (contents z)))
        (else (error "Unknown type -- MAGNITUDE" z))))
(define (angle z)
  (cond ((rectangular? z)
         (angle-rectangular (contents z)))
        ((polar? z)
         (angle-polar (contents z)))
        (else (error "Unknown type -- ANGLE" z))))

为了实现复数算术运算,我们可以使用与2.4.1节相同的过程add-complexsub-complexmul-complexdiv-complex,因为它们调用的选择器是通用的,所以适用于任何一种表示。例如,过程add-complex仍然是

(define (add-complex z1 z2)
  (make-from-real-imag (+ (real-part z1) (real-part z2))
                       (+ (imag-part z1) (imag-part z2))))

最后,我们必须选择是使用Ben的表示还是Alyssa的表示来构造复数。一个合理的选择是,当我们有实部和虚部时构造矩形数,当我们有模和幅角时构造极坐标数:

(define (make-from-real-imag x y)
  (make-from-real-imag-rectangular x y))
(define (make-from-mag-ang r a)
  (make-from-mag-ang-polar r a))

图 2.21:  通用复数算术系统的结构。

得到的复数系统具有图2.21所示的结构。该系统已被分解为三个相对独立的部分:复数算术运算、Alyssa的极坐标实现和Ben的矩形实现。极坐标和矩形实现可以由Ben和Alyssa分别独立编写,并且它们都可以被实现复数算术过程的第三方程序员作为底层表示使用,通过抽象的构造器/选择器接口。

由于每个数据对象都带有其类型标签,选择器以通用方式操作数据。也就是说,每个选择器被定义为具有依赖于其应用的具体数据类型的行为。注意连接独立表示的通用机制:在给定的表示实现中(例如Alyssa的极坐标包),复数是一个未标记的序对(模,幅角)。当通用选择器操作一个polar类型的数时,它剥去标签并将内容传递给Alyssa的代码。相反,当Alyssa构造一个供一般使用的数时,她用类型标记它,以便更高级的过程能够适当识别。这种在数据对象从一层传递到另一层时剥去和附加标签的准则可以成为重要的组织策略,正如我们将在2.5节中看到的那样。

2.4.3  数据导向编程和可加性

检查数据类型并调用适当过程的一般策略称为基于类型的分派。这是在系统设计中获得模块性的强大策略。另一方面,如2.4.2节那样实现分派有两个显著弱点。一个弱点是通用接口过程(real-partimag-partmagnitudeangle)必须知道所有不同的表示。例如,假设我们想要在复数系统中加入一种新的复数表示。我们需要用一个类型标识这个新表示,然后向每个通用接口过程添加一个子句来检查新类型并应用该表示的适当选择器。

该技术的另一个弱点是,即使各个表示可以单独设计,我们必须保证整个系统中没有两个过程具有相同的名字。这就是为什么Ben和Alyssa不得不从2.4.1节开始更改他们原始过程的名字。

这两个弱点背后的根本问题是,实现通用接口的技术不是可加的。实现通用选择器过程的人必须在每次安装新表示时修改这些过程,而连接各个表示的人必须修改他们的代码以避免名称冲突。在每种情况下,需要对代码进行的修改都是直接的,但仍然必须进行修改,这是不便和错误的来源。对于现有的复数系统来说,这不是一个大问题,但假设不只有两种而是有数百种不同的复数表示。并且假设在抽象数据接口中有许多通用选择器要维护。事实上,假设没有哪个程序员知道所有的接口过程或所有的表示。这个问题是真实的,必须在诸如大规模数据库管理系统这样的程序中解决。

我们需要的是进一步模块化系统设计的方法。这由称为数据导向编程的编程技术提供。要理解数据导向编程的工作原理,首先注意到,每当我们处理一组通用操作(这些操作共用于一组不同类型)时,我们实际上是在处理一个二维表,其中可能操作在一个轴上,可能的类型在另一个轴上。表中的条目是实现每个操作对每种参数类型的过程。在前一节开发的复数系统中,操作名、数据类型和实际过程之间的对应关系分散在通用接口过程中的各个条件子句中。但相同的信息可以组织在一个表中,如图2.22所示。

数据导向编程是设计程序使其直接与此类表打交道的技术。以前,我们将连接复数算术代码与两个表示包的机制实现为一组各执行显式类型分派的过程。在此,我们将接口实现为单个过程,在表中查找操作名和参数类型的组合以找到要应用的正确过程,然后将其应用于参数的内容。如果我们这样做,那么要向系统添加新的表示包,不需要更改任何现有过程;我们只需在表中添加新条目。

图 2.22:  复数系统的操作表。

为了实现这个计划,假设我们有两个过程putget,用于操作操作-类型表:

目前,我们可以假设putget包含在我们的语言中。在第3章(3.3.3节,练习3.24)中,我们将看到如何实现这些以及其他操作表的操作。

以下是数据导向编程如何用于复数系统。开发了矩形表示的Ben,像最初一样实现他的代码。他定义了一组过程,或一个,并通过在表中添加条目(告诉系统如何操作矩形数)将这些过程连接到系统的其余部分。这是通过调用以下过程完成的:

(define (install-rectangular-package)
  ;; 内部过程
  (define (real-part z) (car z))
  (define (imag-part z) (cdr z))
  (define (make-from-real-imag x y) (cons x y))
  (define (magnitude z)
    (sqrt (+ (square (real-part z))
             (square (imag-part z)))))
  (define (angle z)
    (atan (imag-part z) (real-part z)))
  (define (make-from-mag-ang r a) 
    (cons (* r (cos a)) (* r (sin a))))
  ;; 连接到系统的其余部分
  (define (tag x) (attach-tag 'rectangular x))
  (put 'real-part '(rectangular) real-part)
  (put 'imag-part '(rectangular) imag-part)
  (put 'magnitude '(rectangular) magnitude)
  (put 'angle '(rectangular) angle)
  (put 'make-from-real-imag 'rectangular 
       (lambda (x y) (tag (make-from-real-imag x y))))
  (put 'make-from-mag-ang 'rectangular 
       (lambda (r a) (tag (make-from-mag-ang r a))))
  'done)

注意,这里的内部过程与2.4.1节中Ben单独工作时编写的那些过程相同。将它们连接到系统的其余部分无需任何更改。而且,由于这些过程定义在安装过程内部,Ben不必担心与矩形包之外的其他过程发生名称冲突。为了将它们连接到系统的其余部分,Ben在操作名real-part和类型(rectangular)下安装他的real-part过程,其他选择器类似。45接口还定义了供外部系统使用的构造器。46这些与Ben内部定义的构造器相同,只是它们附加了标签。

Alyssa的极坐标包类似:

(define (install-polar-package)
  ;; 内部过程
  (define (magnitude z) (car z))
  (define (angle z) (cdr z))
  (define (make-from-mag-ang r a) (cons r a))
  (define (real-part z)
    (* (magnitude z) (cos (angle z))))
  (define (imag-part z)
    (* (magnitude z) (sin (angle z))))
  (define (make-from-real-imag x y) 
    (cons (sqrt (+ (square x) (square y)))
          (atan y x)))
  ;; 连接到系统的其余部分
  (define (tag x) (attach-tag 'polar x))
  (put 'real-part '(polar) real-part)
  (put 'imag-part '(polar) imag-part)
  (put 'magnitude '(polar) magnitude)
  (put 'angle '(polar) angle)
  (put 'make-from-real-imag 'polar
       (lambda (x y) (tag (make-from-real-imag x y))))
  (put 'make-from-mag-ang 'polar 
       (lambda (r a) (tag (make-from-mag-ang r a))))
  'done)

尽管Ben和Alyssa仍然使用他们原始的定义(具有相同的名字,例如real-part),但这些定义现在是不同过程内部的(见1.1.8节),因此没有名称冲突。

复数算术选择器通过一个称为apply-generic的通用"操作"过程访问表,该过程将通用操作应用于一些参数。Apply-generic在表中以操作名和参数类型进行查找,如果找到,则应用得到的过程:47

(define (apply-generic op . args)
  (let ((type-tags (map type-tag args)))
    (let ((proc (get op type-tags)))
      (if proc
          (apply proc (map contents args))
          (error
            "No method for these types -- APPLY-GENERIC"
            (list op type-tags))))))

使用apply-generic,我们可以如下定义通用选择器:

(define (real-part z) (apply-generic 'real-part z))
(define (imag-part z) (apply-generic 'imag-part z))
(define (magnitude z) (apply-generic 'magnitude z))
(define (angle z) (apply-generic 'angle z))

注意到如果在系统中添加了新的表示,这些过程根本不需要更改。

我们还可以从表中提取构造器,供包外部的程序使用,以从实部和虚部或从模和幅角构造复数。与2.4.2节一样,当我们有实部和虚部时构造矩形数,有模和幅角时构造极坐标数:

(define (make-from-real-imag x y)
  ((get 'make-from-real-imag 'rectangular) x y))
(define (make-from-mag-ang r a)
  ((get 'make-from-mag-ang 'polar) r a))

练习 2.73.  2.3.2节描述了一个执行符号求导的程序:

(define (deriv exp var)
  (cond ((number? exp) 0)
        ((variable? exp) (if (same-variable? exp var) 1 0))
        ((sum? exp)
         (make-sum (deriv (addend exp) var)
                   (deriv (augend exp) var)))
        ((product? exp)
         (make-sum
           (make-product (multiplier exp)
                         (deriv (multiplicand exp) var))
           (make-product (deriv (multiplier exp) var)
                         (multiplicand exp))))
        <此处可添加更多规则>
        (else (error "unknown expression type -- DERIV" exp))))

我们可以将这个程序看作是对要求导表达式类型的分派。在这种情况下,数据的"类型标签"是代数运算符符号(如+),正在执行的操作是deriv。我们可以通过将基本求导过程重写为以下形式来将此程序转换为数据导向风格:

(define (deriv exp var)
   (cond ((number? exp) 0)
         ((variable? exp) (if (same-variable? exp var) 1 0))
         (else ((get 'deriv (operator exp)) (operands exp)
                                            var))))
(define (operator exp) (car exp))
(define (operands exp) (cdr exp))

a.  解释上面做了什么。 为什么我们不能将谓词number?same-variable?吸收到数据导向分派中?

b.  编写和与积的导数过程,以及将它们安装到上述程序使用的表中所需的辅助代码。

c.  选择任何你喜欢的额外求导规则,例如指数规则(练习2.56),并将其安装到这个数据导向系统中。

d.  在这个简单的代数操作器中,表达式的类型是将其绑定在一起的代数运算符。然而,假设我们以相反的方式索引过程,使得deriv中的分派行看起来像

((get (operator exp) 'deriv) (operands exp) var)

需要对求导系统进行哪些相应的更改?

练习 2.74.  Insatiable Enterprises, Inc.是一家高度分散的联合企业,由遍布世界各地的众多独立部门组成。公司的计算设施刚刚通过一个巧妙的网络连接方案互联,使得整个网络在任何用户看来就像一台计算机。Insatiable的总裁在首次尝试利用网络从部门文件中提取管理信息时,沮丧地发现,尽管所有部门的文件都已实现为Scheme中的数据结构,但使用的具体数据结构因部门而异。立即召开了部门经理会议,以寻找一种整合文件的策略,既要满足总部的需求,又要保留各部门现有的自主权。

展示如何通过数据导向编程实现这样的策略。例如,假设每个部门的人事记录由一个单一的文件组成,其中包含一组以员工姓名键控的记录。集合的结构因部门而异。此外,每个员工的记录本身是一个集合(结构也因部门而异),包含以诸如addresssalary等标识符键控的信息。具体来说:

a.  为总部实现一个get-record过程,从指定的人事文件中检索指定员工的记录。该过程应适用于任何部门的文件。解释各个部门的文件应如何结构化。特别地,必须提供什么类型信息?

b.  为总部实现一个get-salary过程,从任何部门人事文件中给定员工的记录返回薪水信息。记录应如何结构化才能使此操作工作?

c.  为总部实现一个find-employee-record过程。它应搜索所有部门的文件,查找给定员工的记录并返回该记录。假设此过程接受员工姓名和所有部门文件的列表作为参数。

d.  当Insatiable收购一家新公司时,需要进行哪些更改才能将新的人事信息纳入中央系统?

消息传递

数据导向编程的关键思想是通过显式处理操作-类型表(如图2.22中的表)来在程序中处理通用操作。我们在2.4.2节中使用的编程风格是通过让每个操作负责自己的分派来组织所需的类型分派。实际上,这相当于将操作-类型表分解为行,每个通用操作过程代表表的一行。

另一种实现策略是将表分解为列,而不是使用对数据类型进行分派的"智能操作",而是使用对操作名进行分派的"智能数据对象"。我们可以通过这样的安排来实现:一个数据对象(如矩形数)被表示为一个过程,该过程接受所需的操作名作为输入并执行指示的操作。在这种准则下,make-from-real-imag可以编写为

(define (make-from-real-imag x y)
  (define (dispatch op)
    (cond ((eq? op 'real-part) x)
          ((eq? op 'imag-part) y)
          ((eq? op 'magnitude)
           (sqrt (+ (square x) (square y))))
          ((eq? op 'angle) (atan y x))
          (else
           (error "Unknown op -- MAKE-FROM-REAL-IMAG" op))))
  dispatch)

相应的apply-generic过程将通用操作应用于一个参数,现在只需将操作名传递给数据对象并让对象完成工作:48

(define (apply-generic op arg) (arg op))

注意,make-from-real-imag返回的值是一个过程——内部的dispatch过程。这就是当apply-generic请求执行操作时被调用的过程。

这种编程风格称为消息传递。这个名称来自这样的形象:一个数据对象是一个接收请求的操作名作为"消息"的实体。我们在2.1.3节中已经看到了一个消息传递的例子,在那里我们看到了如何仅用过程(没有数据对象)来定义conscarcdr。在这里我们看到消息传递不是一个数学技巧,而是组织具有通用操作的系统的有用技术。在本章的剩余部分,我们将继续使用数据导向编程(而不是消息传递)来讨论通用算术运算。在第3章中,我们将回到消息传递,并将看到它可以是构造模拟程序的强大工具。

练习 2.75.  以消息传递风格实现构造器make-from-mag-ang。该过程应与上面给出的make-from-real-imag过程类似。

练习 2.76.  随着具有通用操作的大型系统的发展,可能需要新的数据类型对象或新的操作。对于三种策略——带显式分派的通用操作、数据导向风格和消息传递风格——描述为了添加新类型或新操作必须对系统进行的更改。对于需要经常添加新类型的系统,哪种组织方式最合适?对于需要经常添加新操作的系统,哪种最合适?


43 在实际计算系统中,矩形形式在大多数时候优于极坐标形式,因为矩形和极坐标形式转换中的舍入误差。这就是为什么复数例子不现实的原因。尽管如此,它清晰地说明了使用通用操作设计系统,并为稍后在本章中开发的更重要的系统提供了良好的介绍。

44 这里所指的反正切函数由Scheme的atan过程计算,定义为接受两个参数yx,返回正切为y/x的角。参数的符号确定角的象限。

45 我们使用列表(rectangular)而不是符号rectangular,以允许多参数操作的可能性,这些参数可能不都是同一类型。

46 构造器安装的类型不需要是列表,因为构造器总是用于制造一个特定类型的对象。

47 Apply-generic使用了练习2.20中描述的点尾记法,因为不同的通用操作可能接受不同数量的参数。在apply-generic中,op的值是apply-generic的第一个参数,args的值是剩余参数的列表。

Apply-generic还使用了原语过程apply,它接受两个参数,一个过程和一个列表。Apply应用该过程,使用列表中的元素作为参数。例如,

(apply + (list 1 2 3 4))

返回10。

48 这种组织方式的一个限制是它只允许单参数的通用过程。