OOP:表层之下

揭秘面向对象编程的底层实现:消息传递、局部状态变量、继承与委托如何用 lambda 和 let 实现。

表层之下

OOP 视频配图

有了新掌握的环境知识,理解 OOP 的工作原理会容易得多!我们把这部分内容叫做「表层之下」(below the line),因为我们没有借助任何特殊过程(比如 define-class),就实现了 OOP 的功能。

消息传递

先看下面的代码:

(define (make-rectangular x y)
  (define (dispatch m)
    (cond ((eq? m 'real-part) x)
          ((eq? m 'imag-part) y)
          ((eq? m 'magnitude)
           (sqrt (+ (square x) (square y))))
          ((eq? m 'angle) (atan y x))
          (else
            (error "Unknown op -- MAKE-RECTANGULAR" m))))
  dispatch)

在这个例子里,复数对象用一个分派(dispatch)过程表示。这个过程接受一条消息作为参数,返回一个数作为结果。不过,dispatch 也可以不返回数而返回过程,也允许带上额外的参数——这些参数会传给我们称之为「响应消息的方法」的那个东西。

用户这样写:

((acc 'withdraw) 100)

求值这个表达式需要两步:

  1. 用消息 withdraw 作为参数,调用分派过程(名为 acc 的那个)。
  2. 分派过程返回 withdraw 方法过程,再用 100 作为参数调用第二个过程,完成真正的工作。

对象的一切活动都来自调用它的各个方法过程;对象本身唯一的任务,就是收到消息时返回正确的过程。

任何使用消息传递模型的 OOP 系统,都必须有某种「表层之下」的机制,把方法和消息关联起来。Scheme 的过程是一等公民,用分派过程充当这种关联机制非常自然。在别的语言里,对象也可能表示成「消息—方法」对的数组。如果我们把对象当作抽象数据类型,使用对象的程序就不该知道我们恰好把对象表示成了过程。而调用方法要分两步的写法,会破坏这层抽象屏障。为了修复它,我们发明了 ask 过程:

(define (ask object message . args)
  (let ((method (object message))) ; 第一步:调用分派过程
    (if (method? method)
        (apply method args)        ; 第二步:调用方法
        (error "No method" message (cadr method)))))

ask 执行的步骤,本质上和正文里显式两步写法一样。首先,它以消息为参数调用分派过程(即对象本身),这应当返回一个方法(另一个过程);第二步,用传给 ask 的额外参数调用那个方法过程。ask 的过程体看起来比之前的版本复杂,但大部分都是在做错误检查:万一对象不认识我们发去的消息怎么办?这些细节不太重要。ask 用到了两个我们还没讨论过的 Scheme 特性:

形式参数表里的点号表示 ask 接受任意数量的参数。前两个参数关联到形式参数 object 和 message;其余所有参数(零个或多个)放进一个列表,关联到形式参数 args。

过程 apply 接受一个过程和一个参数列表,把这个过程应用到这些参数上。这里需要它的原因是:我们事先不知道方法会得到多少个参数;如果写成 (method args),就等于只给方法传了一个参数——一个列表。

在我们的 OOP 系统中,你通常给实例发消息,但也可以给类发消息,即那些用来查看类变量的消息。给类发消息和给实例发消息一样,都会返回一个方法。因此 ask 对实例和类都适用。(OOP 系统本身还会在你要求创建新实例时,给类发一条 instantiate 消息。)所以,类和每个实例都由一个分派过程表示。类定义的整体结构大致如下:

(define (class-dispatch-procedure class-message)
  (cond ((eq? class-message 'some-var-name) (lambda () (get-the-value)))
        (...)
        ((eq? class-message 'instantiate)
         (lambda (instantiation-var ...)
           (define (instance-dispatch-procedure instance-message)
             (cond ((eq? instance-message 'foo) (lambda ...))
                    (...)
                    (else (error "No method in instance")) ))
             instance-dispatch-procedure))
        (else (error "No method in class")) ))

(请注意,真正的类并不完全长这样。这个简化版省略了许多细节。这里唯一关键的点是:有两个分派过程,一个套在另一个里面。)每个过程里都有一个 cond,为每条允许的消息准备一个子句。每个子句的结果表达式都是一个 lambda 表达式,定义对应的方法。

局部状态(再谈)

上一课你学习了如何给过程一个局部状态变量:把过程定义在另一个建立变量的过程内部。因此可以写成下面这样的例子:

(define new-withdraw
  (let ((balance 100))
    (lambda (amount)
      (if (>= balance amount)
          (begin (set! balance (- balance amount)) balance)
          "Insufficient funds"))))

在 OOP 系统中,局部状态变量有三种:类变量(class variable)、实例变量(instance variable)和实例化变量(instantiation variable)。虽然在表层之上,实例化变量只是实例变量的一种特殊形式,但它们的实现方式不同。下面是类定义的又一个简化视图,这次去掉所有消息传递的内容,聚焦变量:

(define class-dispatch-procedure
  (LET ((CLASS-VAR1 VAL1)
        (CLASS-VAR2 VAL2) ...)
    (lambda (class-message)
      (cond ((eq? class-message 'class-var1) (lambda () class-var1))
            ...
            ((eq? class-message 'instantiate)
             (lambda (INSTANTIATION-VARIABLE1 ...)
               (LET ((INSTANCE-VAR1 VAL1)
                     (INSTANCE-VAR2 VAL2) ...)
                 (define (instance-dispatch-procedure instance-message)
                   ...)
                 instance-dispatch-procedure)))))))

类变量的作用域包括类分派过程、实例分派过程,以及这两个过程中的所有方法。实例变量的作用域不包括类分派过程及其方法。类 instantiate 方法的每次调用都会产生一组新的实例变量——正如书中每个新银行账户都有自己的局部状态变量一样。

为什么类变量和实例变量用 let 实现,而实例化变量不用?原因是:类变量和实例变量的(初始)值由类定义本身给出,这正是 let 的职责——在名字和值之间建立关联。而实例化变量在创建某个具体实例之前没有值,所以我们把这些变量实现为 lambda 的形式参数,通过调用这个 lambda 来创建实例。

继承与委托

继承(inheritance)是这样一种机制:子类的对象可以使用父类的方法。理想情况下,所有这些方法都应当是子类本领的一部分——父类的过程定义会被「拷贝」到子类的 Scheme 实现中。我们 OOP 系统中的实际实现虽然目的相同,用的却是一种略有不同的技术,叫做委托(delegation)。每个对象的分派过程只包含自己类的方法条目,不包含父类的。但每个对象在一个实例变量里持有一个父类的对象。为了方便谈论这些对象和类,我们看一个之前见过的例子:

(define-class (checking-account init-balance)
  (parent (account init-balance))
  (method (write-check amount)
    (ask self 'withdraw (+ amount 0.10)) ))

我们创建这个类的一个实例:

(define Gerry-account (instantiate checking-account 20000))

那么名为 Gerry-account 的对象会有一个名为 my-account 的实例变量,它的值是 account 类的一个实例。(「my-开头的变量」由 define-class 自动创建。)

这个父实例有什么用?如果 Gerry-account 的分派过程不认识某条消息,它就会走到 cond 的 else 子句。在没有父类的对象里,这个子句会报错。但如果对象有父类,else 子句会把消息转交给父类的分派过程:

(define (make-checking-account-instance init-balance)
  (LET ((MY-ACCOUNT (INSTANTIATE ACCOUNT INIT-BALANCE)))
    (lambda (message)
      (cond ((eq? message 'write-check) (lambda (amount) ...))
            ((eq? message 'init-balance) (lambda () init-balance))
            (ELSE (MY-ACCOUNT MESSAGE)) ))))

(当然,这是大幅简化的图景。我们省略了类分派过程等细节。实现中并没有一个真正叫 make-checking-account-instance 的过程;这个过程实际上是该类的 instantiate 方法,正如我们前面解释的那样。)

当我们给 Gerry-account 发 write-check 消息时,它按我们一贯讨论的方式直接处理。但当我们给它发 deposit 消息时,它走到 cond 的 else 子句,消息被委托给父类 account 对象。那个对象(即它的分派过程)返回一个方法,Gerry-account 也把这个方法返回出去。

要理解的关键点是:为什么 else 子句不写成

(else (ask my-parent message))

Gerry-account 的分派过程接受消息作为参数,返回方法作为结果。ask,你也记得,分两步执行:先拿到方法,再调用它。而在分派过程内部,我们只想拿到方法,不想调用它。(某处有一个 ask 的调用正等着 Gerry-account 的分派过程返回一个方法,然后 ask 会去调用它。)

委托技术有一个缺点。当我们让 Gerry-account 存一笔钱时,deposit 方法只能访问 account 类的局部状态变量,访问不到 checking-account 类的。同样,write-check 方法也访问不了 account 类的局部状态变量(如 balance)。这个局限的原因不难看出:每个方法都定义在某个类过程的作用域内,而 Scheme 的词法作用域规则把每个方法限制在其作用域覆盖的变量上。继承和委托的技术区别就在于:基于继承的 OOP 系统没有这个限制。我们可以通过发消息来绕过这个局限——让一个类(子问父,或父问子)返回(或修改)它的某个变量。write-check 方法里的 (ask self 'withdraw ...) 就是一个例子。

更多细节

目前展示的简化版 Scheme 实现,隐藏了实际 OOP 系统中的若干复杂之处。我们到目前为止讲的内容才是实现中最重要的部分,你不该让下面的细节动摇你对核心思想的理解。对这些内容我们只做简短说明,省略繁琐的细节。

一个复杂之处是多继承(multiple inheritance)。我们不能再把未知消息只委托给一个父类,而必须尝试多个父类。真正的 else 子句会调用一个叫 get-method 的过程,它接受任意数量的对象(即分派过程)以及消息作为参数。get-method 依次在每个对象里查找方法;只有所有父类都提供不了方法时,它才报错。(每个父类都会有一个「my-开头」的变量。)

另一个影响 else 子句的复杂之处,是类定义中可能使用 default-method。如果用了这个可选特性,default-method 子句的过程体会成为对象 else 子句的一部分。

创建实例时,instantiate 过程会给它发一条 initialize 消息。每个分派过程都自动带有一个对应的方法。如果 define-class 里用了 initialize 子句,该方法就包含那段代码。但即使没有 initialize 子句,OOP 系统也有自己的初始化任务要做。特别地,初始化必须给 self 变量一个值。每个 initialize 方法都接受 self 的期望值作为参数。如果没有父类或子类参与,self 就是对象自己分派过程的另一个名字。但如果一个实例是某个子实例的「my-开头变量」,那么 self 应当指那个子实例。解决办法是:子类的 initialize 方法用子类自己的 self 作为参数,调用父类的 initialize 方法。(子类从哪里得到它的 self 参数?由 instantiate 过程提供。)

最后,usual 也涉及一些复杂之处。每个对象都有一个 send-usual-to-parent 方法,它基本上复制了 ask 过程的工作,只不过和 else 子句一样,只在父类中查找方法。调用 usual 就会触发这个方法。

计数器示例

现在来实现一个简单的计数器。每次调用 counter,它都会递增自己的局部变量,以及所有计数器共享的全局变量。下面是我们的 counter 类的代码:

(define make-counter
    (let ((glob 0))
        (lambda ()
            (let ((loc 0))
                (lambda ()
                    (set! loc (+ loc 1))
                    (set! glob (+ glob 1))
                    (list loc glob))))))

它的工作方式大致如下:

> (define counter1 (make-counter))
counter1

> (define counter2 (make-counter))
counter2

> (counter1)
(1 1)

> (counter1)
(2 2)

> (counter2)
(1 3)

> (counter1)
(3 4)

类变量 glob 在包裹外层 lambda(代表整个类)的环境中创建。实例变量 loc 在类 lambda 内部、代表类实例的第二个 lambda 外部的环境中创建。

检验理解

上面的例子展示了环境如何支持 OOP 中的状态变量,但它做了简化——实例不是消息传递的分派过程。简言之,它不太真实。

目前我们调用计数器时不带参数,返回该计数器局部变量和全局变量组成的列表。请修改这个类,让计数器有两个方法:local 或 global。每个方法恰好接受一个参数:计数器要递增的量。你的代码应当这样工作:

> (define counter1 (make-counter))
counter1
> (define counter2 (make-counter))
counter2

> ((counter1 'global) 5)
5
> ((counter2 'global) 3)
8
> ((counter1 'local) 2)
2