1 从函数到简单对象

对面向对象编程语言的探索从我们于PLAI(《编程语言:应用和解释》)中所学到的、以及对于什么是对象的直觉开始。

1.1 有状态函数与对象模式

对象的目的是,将状态(可能但不一定是可变的)连同依赖于该状态的行为一起封装在一致的整体中。这里的状态通常被称为字段(field)(或实例变量(instance variable)),而行为是以方法(method)的形式提供。调用方法通常被称为消息传递(message passing):发送消息给对象,如果它理解了,就执行相关的方法。

在Scheme这样的高级编程语言中,我们看到过类似的东西:

  1. (define add
  2. (n)
  3. (m)
  4. (+ m n))))
  5. > (define add2 (add 2))
  6. > (add2 5)
  7. 7

函数add2封装了隐藏状态(n = 2),其行为也依赖于该状态。所以从某种意义上说,闭包是一种对象,他的字段是(函数体中的)自由变量。那么其行为呢?好吧,闭包只有一个行为,通过函数调用触发(从消息传递的角度来看,apply(调用)是函数能理解的唯一消息)。

如果语言支持赋值(set!),那么我们就得到了有状态的函数,可以改变状态:

  1. (define counter
  2. (let ([count 0])
  3. ()
  4. (begin
  5. (set! count (add1 count))
  6. count))))

现在我们可以观察到count状态的变化:

  1. > (counter)
  2. 1
  3. > (counter)
  4. 2

现在,如果我们想要双向计数器呢?该函数必须能够在其状态上执行+1或者-1,取决于……好吧,参数!

  1. (define counter
  2. (let ([count 0])
  3. (cmd)
  4. (case cmd
  5. [(dec) (begin
  6. (set! count (sub1 count))
  7. count)]
  8. [(inc) (begin
  9. (set! count (add1 count))
  10. count)]))))

请注意counter如何使用cmd来区分要执行的操作。

  1. > (counter 'inc)
  2. 1
  3. > (counter 'dec)
  4. 0

这看起来很像有两个方法和一个实例变量的对象,不是吗? 我们再来看一个例子,堆栈。

  1. (define stack
  2. (let ([vals '()])
  3. (define (pop)
  4. (if (empty? vals)
  5. (error "cannot pop from an empty stack") ;无法从空栈中pop
  6. (let ([val (car vals)])
  7. (set! vals (cdr vals))
  8. val)))
  9. (define (push val)
  10. (set! vals (cons val vals)))
  11. (define (peek)
  12. (if (empty? vals)
  13. (error "cannot peek from an empty stack") ;无法从空栈中peek
  14. (car vals)))
  15. (λ (cmd . args)
  16. (case cmd
  17. [(pop) (pop)]
  18. [(push) (push (car args))]
  19. [(peek) (peek)]
  20. [else (error "invalid command")])))) ;无效的命令

这里,我们没有直接在lambda中编写方法体,而是使用了内层的define。另外请注意,我们在lambda的参数中使用了点符号:这样函数就能够接收一个参数(cmd)以及零或多个额外参数(以链表形式在函数体中绑定到args)。

试试看:

  1. > (stack 'push 1)
  2. > (stack 'push 2)
  3. > (stack 'pop)
  4. 2
  5. > (stack 'peek)
  6. 1
  7. > (stack 'pop)
  8. 1
  9. > (stack 'pop)
  10. cannot pop from an empty stack

这代码的模式已经很明显了,可以用来定义类似于对象的抽象。更明确地抽象此模式:

  1. (define point
  2. (let ([x 0])
  3. (let ([methods (list (cons 'x? (λ () x))
  4. (cons 'x! (nx) (set! x nx))))])
  5. (msg . args)
  6. (apply (cdr (assoc msg methods)) args)))))

请注意这里定义的λ,它以一种通用的方式将消息分发到正确的方法。我们首先把所有的方法都放在一个关联链表(即元素为pair的链表)中,将符号(也就是消息)关联到相应的方法。当调用point时,我们(用assoc)查找消息,得到相应的方法。然后调用它。

  1. > (point 'x! 6)
  2. > (point 'x?)
  3. 6

1.2 Scheme中的(第一种)简单对象系统

我们可以用宏在Scheme中嵌入一个遵循上面确定的模式的简单对象系统。

请注意,在本书中我们使用defmac来定义宏。defmac类似于define-syntax-rule,但是它还支持关键字参数,外加标识符捕获(通过#:keywords#:captures可选参数)。

  1. (defmac (OBJECT ([field fname init] ...)
  2. ([method mname args body] ...))
  3. #:keywords field method
  4. (let ([fname init] ...)
  5. (let ([methods (list (cons 'mname (λ args body)) ...)])
  6. (λ (msg . vals)
  7. (apply (cdr (assoc msg methods)) vals)))))

我们还可以定义箭头->符号表示发送消息给对象,例如(-> st push 3)

  1. (defmac (-> o m arg ...)
  2. (o 'm arg ...))

现在就可以使用这个对象系统来定义二维点对象了:

  1. (define p2D
  2. (OBJECT
  3. ([field x 0]
  4. [field y 0])
  5. ([method x? () x]
  6. [method y? () y]
  7. [method x! (nx) (set! x nx)]
  8. [method y! (ny) (set! y ny)])))

这么使用:

  1. > (-> p2D x! 15)
  2. > (-> p2D y! 20)
  3. > (-> p2D x?)
  4. 15
  5. > (-> p2D y?)
  6. 20

1.3 构造对象

到目前为止,我们的对象都是作为独立样本被创建。如果我们想要多个点对象,每个可以有不同的初始坐标呢?

在函数式编程的语境中,我们知道如何正确地创建各种类似的函数:使用高阶函数,带上合适的参数,其作用是返回我们想要的特定实例。例如,从前面定义的add函数中,我们可以获得各种单参数加法函数:

  1. > (define add4 (add 4))
  2. > (define add5 (add 5))
  3. > (add4 1)
  4. 5
  5. > (add5 1)
  6. 6

因为我们的简单对象系统根植于Scheme,所以可以简单地使用高阶函数来定义对象工厂(object factory):

JavaScript,AmbientTalk

  1. (define (make-point init-x init-y)
  2. (OBJECT
  3. ([field x init-x]
  4. [field y init-y])
  5. ([method x? () x]
  6. [method y? () y]
  7. [method x! (new-x) (set! x new-x)]
  8. [method y! (new-y) (set! y new-y)])))

make-point函数的参数是初始坐标,返回新创建的、正确地初始化后的对象。

  1. > (let ([p1 (make-point 5 5)]
  2. [p2 (make-point 10 10)])
  3. (-> p1 x! (-> p2 x?))
  4. (-> p1 x?))
  5. 10

1.4 动态分发

我们的简单对象系统就足以展示面向对象编程的基本特性:动态分发。请注意,在下面的代码中,node(节点)将sum消息发送给每个子节点,并不知道它们是leaf(叶节点)还是node:

  1. (define (make-node l r)
  2. (OBJECT
  3. ([field left l]
  4. [field right r])
  5. ([method sum () (+ (-> left sum) (-> right sum))])))
  6. (define (make-leaf v)
  7. (OBJECT
  8. ([field value v])
  9. ([method sum () value])))
  10. > (let ([tree (make-node
  11. (make-node (make-leaf 3)
  12. (make-node (make-leaf 10)
  13. (make-leaf 4)))
  14. (make-leaf 1))])
  15. (-> tree sum))
  16. 18

尽管看起来很简单,这个对象系统已经足以说明对象的基本抽象机制,以及它和抽象数据类型(abstract data type)的区别。参见第三章

1.5 错误处理

让我们看看,如果发送消息给不知道如何处理它的对象会发生什么:

  1. > (let ([l (make-leaf 2)])
  2. (-> l print))
  3. cdr: contract violation
  4. expected: pair?
  5. given: #f

这个错误信息很糟糕——它将我们的实现策略暴露给程序员,而且没有提示问题在哪。

我们可以改变OBJECT语法抽象的定义,正确地处理未知消息:

  1. (defmac (OBJECT ([field fname init] ...)
  2. ([method mname args body] ...))
  3. #:keywords field method
  4. (let ([fname init] ...)
  5. (let ([methods (list (cons 'mname (λ args body)) ...)])
  6. (λ (msg . vals)
  7. (let ([found (assoc msg methods)])
  8. (if found
  9. (apply (cdr found) vals)
  10. (error "message not understood:" msg))))))) ;未知的消息

我们不再假设在对象的方法表中会有消息关联的方法,而是首先查找并将结果绑定到found;如果找不到方法,found将会是#f。在这种情况下,我们给出有意义的错误信息。

确实好多了:

  1. > (let ([l (make-leaf 2)])
  2. (-> l print))
  3. message not understood: print

本章,我们成功地在Scheme中嵌入了一个简单的对象系统,它显示了词法作用域的一等函数和对象之间的连接。但是,我们还远没有完成,目前的对象系统仍然不完整且非常原始。