Skip to content

3 对象的好处和局限性

在编程语言的课程中,我们这样编程:定义数据类型及其变体,在此之上定义操作这些结构 体的各种“服务”,所谓服务也即对这些数据结构的各种变种分情况进行处理的子程序。这种 编程风格有时被称为“过程式”或“函数设计”的(注意这里的“函数”并不是指“无副作用的”! )。

在《程序语言:应用和解释》中,我们用define-type来定义数据类型及其变体, 用type-case实现对变种按情况处理的子程序。这种编程方法在其他语言中也很常见:C( 联合体)、Pascal(变体类型)、ML 和 Haskell 的代数数据类型、纯 Scheme 的带标记数 据。

那么,面向对象编程究竟给我们提供了什么呢?它的缺点是什么呢?事实证明,使用面向对 象的语言并不意味着程序就是“面向对象”的。许多 Java 程序就不是,或者至少是牺牲了对 象的某些基本好处的。

本章基于 William R. Cook 2009 年的《On Understanding Data Abstraction, Revisited》(再谈对数据抽象的理解)一文。

本独立章节的目的是,暂时从逐步构建 OOP 的步骤中抽身,转而对比面向对象和过程式编 程,从而明确每种方法各自的优缺点。有趣的是,我们迄今为止构建的简单对象系统完全足 够研究对象的基本好处和局限性了——委托、类、继承等都是有趣的特性,但对于对象来说都 不是本质的。

3.1 抽象数据类型

我们先来讨论抽象数据类型(ADT)。ADT 是隐藏其表示、只提供对值的操作的数据类型。

例如,整数的集合ADT 可以定义如下:

adt Set 是
  empty : Set
  insert : Set x Int -> Set
  isEmpty? : Set -> Bool
  contains? : Set x Int -> Bool

这种整数集 ADT 有许多可能的表示。例如,可以使用 Scheme 的表来实现它:

(define empty '())

(define (insert set val)
  (if (not (contains? set val))
      (cons val set)
      set))

(define (isEmpty? set) (null? set))

(define (contains? set val)
  (if (null? set) #f
      (if (eq? (car set) val)
          #t
          (contains? (cdr set) val))))

客户程序可以使用 ADT 值,而无需知道底层的表示法:

> (define x empty)
> (define y (insert x 3))
> (define z (insert y 5))
> (contains? z 2)
#f
> (contains? z 5)
#t

我们也可以用另一种表示方式来实现 ADT 集合,比如使用 PLAI 的 define-type 机制来创 建一个变体类型,将集合编码为链表。

(define-type Set
  [mtSet]
  [aSet (val number?) (next Set?)])

(define empty (mtSet))

(define (insert set val)
  (if (not (contains? set val))
      (aSet val set)
      set))

(define (isEmpty? set) (equal? set empty))

(define (contains? set val)
  (type-case Set set
    [mtSet () #f]
    [aSet (v next)
          (if (eq? v val)
              #t
              (contains? next val))]))

前面的示例客户程序运行照旧,即使现在底层表示换掉了:

> (define x empty)
> (define y (insert x 3))
> (define z (insert y 5))
> (contains? z 2)
#f
> (contains? z 5)
#t

3.2 用子程序表示

我们也可以把集合看作是由它的特征函数定义:该函数读入一个数字,告诉我们这个数 字是否是集合的一部分。在这种情况下,集合就是简单的Int -> Bool函数。(PLAI 一书 中,第十二章中在研究环境的子程序表示时有提到。)

空集的特征函数是什么?总是返回假的函数。插入一个新元素所获得的集合呢?

(define empty (λ (n) #f))

(define (insert set val)
          (λ (n)
            (or (eq? n val)
                (contains? set n))))

(define (contains? set val)
  (set val))

由于集合由其特征函数表示,contains?只需将该函数应用于该元素。请注意,客户程序 还是完全不受干扰:

> (define x empty)
> (define y (insert x 3))
> (define z (insert y 5))
> (contains? z 2)
#f
> (contains? z 5)
#t

集合的子程序表示给我们带了了什么?灵活性!例如,我们可以定义所有偶数的集合:

(define even
  (λ (n) (even? n)))

我们前面考虑的任何 ADT 表示,都不能完整地表示这个集合。(为什么?)我们甚至可以 定义非确定的集合:

(define random
  (λ (n) (> (random) 0.5)))

使用子程序表示,我们可以更自由地定义集合,此外它们同样可以与已有的集合操作交互!

> (define a (insert even 3))
> (define b (insert a 5))
> (contains? b 12)
#t
> (contains? b 5)
#t

相反,在上面我们看到的 ADT 表示中,不同的表示法之间不能互操作。列表实现集合的值 不能被结构体实现的操作使用,反之亦然。ADT 从表示中抽象出来,但一次只允许一种表 示

3.3 对象

从本质上讲,函数实现的集合就是对象!请注意对象并抽象出类型:函数实现的 集合的类型非常具体:它是Int -> Bool的函数。当然,正如我们在前面的章节中看到的 ,对象是函数的泛化,它可以有多个方法。

3.3.1 对象的接口

我们可以定义对象接口(interface)的概念,也就是某个对象所有方法的型签(类型 签名,signature):

interface Set 是
  contains? : Int -> Bool
  isEmpty? : Bool

使用我们的简单对象系统实现集合对象:

(define empty
  (OBJECT ()
          ([method contains? (n) #f]
           [method isEmpty? () #t])))

(define (insert s val)
  (OBJECT ()
          ([method contains? (n)
                   (or (eq? val n)
                       (-> s contains? n))]
           [method isEmpty? () #f])))

请注意,empty 是个对象,insert 是返回对象的工厂函数。集合对象实现了 Set 接口 。empty对象不包含任何值,它的isEmpty?返回#tinsert返回一个新对象,它 的contains?方法类似于前文中集合的特征函数,而isEmpty?返回#f

客户程序中,构造集合部分不用变,与集合对象交互部分就必须用消息发送了:

> (define x empty)
> (define y (insert x 3))
> (define z (insert y 5))
> (-> z contains? 2)
#f
> (-> z contains? 5)
#t

请注意,对象接口本质上就是高阶类型:方法是函数,所以传递对象就是传递函数组。这是 高阶函数式编程的推广。面向对象的程序本质上是高阶的。

3.3.2 面向对象编程的原则

原则:对象只能通过其他对象的公共接口来访问它们

一旦创建了对象,比如上面的 z(所绑定的),对它唯一能做的就是通过发送消息进行 交互。不能“打开对象”。对象的任何属性都不可见,可见的只有它的接口。换一种说法:

原则:对象只对自己有详细的了解

这与 ADT 值有本质区别:在type-case的处理中(回忆一下 ADT 实现中 用define-type实现的contains?),我们打开值,从而直接访问其属性。ADT 提供封装 ,但为 ADT 的客户提供;不为其实现提供。对象在这方面更进一步。即使是对象的方法, 其实现也不能访问除自身以外对象的属性。

由此我们可以得出另一个基本原则:

原则:对象就是所有对其可能进行的观测的集合,这些观测通过对象接口定义

这是一条强原则,它表明,如果两个对象在对于特定实验(即一组观测)表现相同,那么它 们应该是不可区分的。这意味着使用等值判定操作(如指针相等)违反了 OOP 的这个原则 。使用 Java 中的==,我们可以区分即使是行为一致的两个对象。

3.3.3 可扩展性

上述原则可以被认为是 OOP 的本质特征。正如 Cook 所说:“ 任何允许区分多个抽象表示 的编程模型都不是面向对象的 ”。

组件对象模型(COM)是实践中最纯粹的 OO 编程模型之一。COM 遵守上述所有的原则:没 有内置的相等性,没有办法确定某个对象是否是某个类的实例。因此 COM 程序是高度可扩 展的。

请注意,对象的可扩展性实际上完全独立于继承!(我们的语言甚至还没有类。)它来自对 接口的使用。

3.3.4 那 Java 呢?

Java 不是一种纯粹的面向对象的语言,并不是因为它有原始类型(primitive type,也有 称作内置类型、基础类型或者基本类型),而是因为它支持的许多操作违反了我们上面描述 的原则。Java 内置支持相等==instanceof、转换为类类型,这使得两个对象即使行 为一致,也可以被区分。在 Java 中,可以声明一个方法,根据类来接受对象,而不是根据 它们的接口(在 Java 中,类名也是类型)。当然还有就是,Java 允许对象访问其他对象 的内部(公有字段当然可以,但即使私有字段同一类的对象也可以访问!)。

这意味着 Java 也支持 ADT 风格的编程。这没有什么不对的!但重要的是了解这所涉及的 设计上的取舍,然后做出明智的选择。例如,在 JDK 中,某些类在表面上尊重 OO 原则( 允许可扩展性),但其实现使用 ADT 技术(不可扩展,但更高效)。如果你有兴趣,参 见List接口和LinkedList实现。

在 Java 中,“纯 OO”编程基本上就是不使用类名称作为类型(即只在new之后使用类名) ,并且从不使用内置的相等(==)。

3.4 可扩展性问题

面向对象程序设计通常被认为是软件可扩展性方面的灵丹妙药。但是,“可扩展”究竟意味着 什么呢?

可扩展性问题说的是如何定义数据类型(结构+操作),使之能够支持两种形式的扩展:添 加新的表示变体,或添加新的操作。

这里,ADT 的意思遵从 Cook 的用法。然而我们需要澄清,这里对扩展性问题的讨论实际 上将对象与变体类型(variant type)(即代数数据类型(algebraic data types))进行对 比。我们关心的是可扩展的实现。这里不关心界面的抽象。

事实表明,ADT 和对象分别都能很好地支持可扩展性的一个维度,但是在另一维度就不行了 。让我们用一个众所周知的例子来研究此问题:简单表达式的解释器。

3.4.1 ADT

先来考虑 ADT 的做法。表达式的数据类型有三种变体:

(define-type Expr
   [num  (n number?)]
   [bool (b boolean?)]
   [add (l Expr?) (r Expr?)])

接下来定义解释器,这是一个函数,用 type-case 处理抽象语法树:

(define (interp expr)
   (type-case Expr expr
      [num (n) n]
      [bool (b) b]
      [add (l r) (+ (interp l) (interp r))]))

这是一道很好的 PLAI 练习题。举个例子:

> (define prog (add (num 1)
                    (add (num 2) (num 3))))
> (interp prog)
6

扩展:新的操作

先来考虑给表达式添加一个新操作。除了对表达式进行解释,我们还想做类型检查,也就是 确定它将算得的值的类型(在这里,是numberboolean)。这很简单,但是能检测到 解释过程中出现的失败的情况,比如对两个不是数字的东西进行相加操作:

(define (typeof expr)
  (type-case Expr expr
    [num (n) 'number]
    [bool (b) 'boolean]
    [add (l r) (if (and (equal? 'number (typeof l))
                        (equal? 'number (typeof r)))
                   'number
                   (error "类型错误:并非数"))]))

求一下之前那个程序的类型:

> (typeof prog)
'number

我们的类型检查器会拒绝不合理的程序:

> (typeof (add (num 1) (bool #f)))
类型错误:并非数

反思一下这个扩展案例,我们看到一切都很顺利。想要新的操作,我们只需要定义新的函数 。这种扩展是模块化的,因为只需要在一个地方新加定义。

扩展:新的数据

接下来考虑另一个维度的可扩展性:添加新的数据变体。假设我们扩展这里的简单语言,增 加新的表达式:ifc。扩展后数据类型的定义是:

(define-type Expr
  [num  (n number?)]
  [bool (b boolean?)]
  [add (l Expr?) (r Expr?)]
  [ifc (c Expr?) (t Expr?) (f Expr?)])

修改Expr的定义加上这个新变体破坏了所有现有的函数定义!interptypeof都不再 成立,因为它们用type-case对表达式“按类型处理”,但是并没有处理ifc的情况。我们 需要修改它们,加上对ifc的处理:

(define (interp expr)
  (type-case Expr expr
    [num (n) n]
    [bool (b) b]
    [add (l r) (+ (interp l) (interp r))]
    [ifc (c t f)
         (if (interp c)
             (interp t)
             (interp f))]))

(define (typeof expr)
  (type-case Expr expr
    [num (n) 'number]
    [bool (b) 'boolean]
    [add (l r) (if (and (equal? 'number (typeof l))
                        (equal? 'number (typeof r)))
                   'number
                   (error "类型错误:并非数"))]
    [ifc (c t f)
         (if (equal? 'boolean (typeof c))
             (let ((type-t (typeof t))
                   (type-f (typeof f)))
               (if (equal? type-t type-f)
                   type-t
                   (error "类型错误:两个分支的类型不同")))
             (error "类型错误:并非布尔值"))]))

程序是正确的:

> (define prog (ifc (bool false)
                    (add (num 1)
                         (add (num 2) (num 3)))
                    (num 5)))
> (interp prog)
5

这种情况下的可扩展性就不怎么样了。我们必须修改数据类型的定义,然后修改所有的函数 。

总而言之,使用 ADT,添加新的操作(如typeof)是模块化的所以很容易,但添加新的数 据类型(例如ifc)则不是模块化的所以非常麻烦。

3.4.2 OOP

对象在这些场景下表现如何?

我们从面向对象版本的解释器开始:

(define (bool b)
  (OBJECT () ([method interp () b])))

(define (num n)
  (OBJECT () ([method interp () n])))

(define (add l r)
  (OBJECT () ([method interp () (+ (-> l interp)
                                   (-> r interp))])))

请注意,遵循面向对象的设计原则,每个表达式对象都知道如何解释自己。程序中不存在某 个中央解释器能处理所有的表达式。解释程序是通过给该程序发送interp消息来完成:

> (define prog (add (num 1)
                    (add (num 2) (num 3))))
> (-> prog interp)
6

扩展:新的数据

要添加新的数据,比如条件对象 ifc,可以简单地定义新的对象工厂,其中包含该新对象处 理 interp 消息的定义:

(define (ifc c t f)
  (OBJECT () ([method interp ()
                      (if (-> c interp)
                          (-> t interp)
                          (-> f interp))])))

现在可以解释包含条件的程序了:

> (-> (ifc (bool #f)
           (num 1)
           (add (num 1) (num 3))) interp)
4

这表明,与 ADT 相反,使用 OOP 添加新类型的数据是直接的、模块化的:只需创建新对象 即可。对比 ADT,这是明显的优势。

扩展:新的操作

但在得出结论,认为 OOP 是软件可扩展性的灵丹妙药之前,我们必须考虑另一种扩展场景 :添加操作。假设我们和以前一样,需要检查程序的类型。这意味着表达式对象现在还需要 理解“typeof”消息。要做到这一点,我们就必须修改所有的对象定义:

(define (bool b)
  (OBJECT () ([method interp () b]
              [method typeof () 'boolean])))

(define (num n)
  (OBJECT () ([method interp () n]
              [method typeof () 'number])))

(define (add l r)
  (OBJECT () ([method interp () (+ (-> l interp)
                                   (-> r interp))]
              [method typeof ()
                      (if (and (equal? 'number (-> l typeof))
                               (equal? 'number (-> r typeof)))
                          'number
                          (error "类型错误:并非数"))])))

(define (ifc c t f)
  (OBJECT () ([method interp ()
                      (if (-> c interp)
                          (-> t interp)
                          (-> f interp))]
              [method typeof ()
                      (if (equal? 'boolean (-> c typeof))
                          (let ((type-t (-> t typeof))
                                (type-f (-> f typeof)))
                            (if (equal? type-t type-f)
                                type-t
                                (error "类型错误:两个分支的类型不同")))
                          (error "类型错误:并非布尔值"))])))

程序是正确的:

> (-> (ifc (bool #f) (num 1) (num 3)) typeof)
'number
> (-> (ifc (num 1) (bool #f) (num 3)) typeof)
类型错误:并非布尔值

这个可扩展性场景下,我们被迫修改所有的代码才能添加新方法。

总而言之,对对象来说,添加新的数据类型(例如 ifc)模块化所以容易,但添加新的操作 (例如 typeof)不模块化所以麻烦。

请注意,这就是 ADT 的对偶情况!

3.5 不同形式的数据抽象

Cook 的论文更深入地讨论了此类数据抽象之间的比较,不可不看!

ADT 和对象是不同形式的数据抽象,各有优劣。

ADT 的表示类型是私有的,无法篡改或扩展。这对推理(分析)和优化来说是好的。但它( 同时)只允许一种表示。

对象拥有行为接口,因此可以随时定义新的实现。这对灵活性和可扩展性来说是好的。但这 使得分析代码变得困难,并且使某些优化成为不可能。

这两种抽象形式也支持不同形式的模块化扩展。在 ADT 上可以模块化地添加新操作,但是 支持新的数据变体就很麻烦。面向对象的系统可以模块化地添加新的表示法,但添加新的操 作意味着大量的修改。

有一些方法可以绕开此折衷。比如说,在对象的接口中可以公开某些实现细节。这会牺牲一 些可扩展性,但恢复某些优化的可能性。所以,这里根本的问题是设计上的问题:我们究竟 需要什么?

现在你可以明白,为什么许多语言(同时)支持这两种数据抽象。