违反数据抽象

解释数据抽象违规(DAV)的概念与危害,并通过有理数示例和抽象边界图说明遵循抽象的好处

创建抽象数据类型固然方便,能不用操心实现细节就写程序也很惬意。但是,究竟是什么在阻止我们越过抽象边界、直接使用底层实现呢?

实际上什么都没有。如果你用 cons 而不是 make-rational,或者用 car 而不是 numerator,Racket 不会抱怨。我们在下面复现了有理数 ADT 的代码:

(define (make-rational numer denom)
  (if (= 0 denom)
      (error "Divisor cannot be 0!")
      (cons numer denom)))

(define (numerator rat)
  (car rat))

(define (denominator rat)
  (cdr rat))

严格来说,如果我们做下面这些调用,选择函数仍会返回我们期望的值:

-> (define x (make-rational 3 4))
x
-> (define num (car x))
num
-> (= num (numerator x))
#t

那么,如果能直接用 car,为什么还要用选择函数?这样现在没问题,但当我们日后改变构造函数和选择函数的实现时,问题就来了。绕过抽象、对数据结构的实现方式做假设,叫数据抽象违规(data abstraction violation,DAV)。

示例:违反数据抽象

假设我们写个新函数 expt-rat,它接受一个有理数和要把有理数乘方的指数,返回乘方后的另一个有理数。这是我们写的过程:

(define (expt-rat rat n)
  (make-rational (expt (car rat) n) 
                 (expt (cdr rat) n)))

你能看出 DAV 吗?按有理数现在的实现,expt-rat 应该毫无问题地工作:

-> (define x (make-rational 3 4))
x
-> (expt-rat x 2)
(9 . 16)

但这很危险!如果日后你决定换实现,无法保证代码还能工作。假设我们这样重写实现:

(define (make-rational numer denom)
  (lambda (m) (cond ((equal? m 'numerator) numer)
                    ((equal? m 'denominator) denom)
                    (else (error "bad message to rational")))))

(define (numerator rat)
  (rat 'numerator))

(define (denominator rat)
  (rat 'denominator))

现在我们调用 expt-rat 会发生什么?

-> (define y (make-rational 5 6))
y
-> (expt-rat y 4)
; car: contract violation
;   expected: pair?
;   given: #<procedure>
; [,bt for context]

报错了!上面 expt-rat 的代码假设我们把有理数存成序对,愚蠢地对分子调 car、对分母调 cdr。于是,当我们把这些过程用在 λ 函数上时,Racket 就不干了。

抽象边界

一扇望向实验室的窗户,上面贴着警示胶带,写着"ABSTRACTION BARRIER DO NOT CROSS"

我们基于构造函数 make-rat 和选择函数 numerator、denominator 定义了有理数运算。总的来说,数据抽象的基本思想是:为每类数据对象确定一组基本操作(例如构造函数和选择函数),对这类数据对象的所有操作都用这组操作来表达,并且操作数据时只用这些操作。

我们可以把有理数系统的结构想象成下图这样。横线表示抽象边界,把系统的不同”层级”隔开。在每一层,边界把(上方)使用数据抽象的程序与(下方)实现数据抽象的程序分开。使用有理数的程序只通过有理数包”公开”提供的过程操作有理数:add-rat、sub-rat、mul-rat、div-rat 和 equal-rat?。这些过程又只用构造函数和选择函数 make-rat、numerator、denominator 实现,而它们本身基于序对实现。只要序对能用 cons、car、cdr 操作,序对怎么实现的细节与有理数包的其余部分无关。实际上,每一层的过程就是定义抽象边界、连接不同层级的接口。

数的抽象边界图

这个简单的想法有很多好处。一个好处是让程序更容易维护和修改。任何复杂的数据结构都能用编程语言提供的原始数据结构以多种方式表示。

要理解这为什么如此重要,不妨设想一个数据抽象不存在的世界。当然,表示方式的选择会影响操作它的程序;因此,如果日后要改变表示,所有这类程序可能都得跟着改。对大程序来说,这项任务可能费时又昂贵,除非通过设计把对表示的依赖限制在极少数程序模块里。

幸运的是,如果数据实现时没有违反数据抽象,修改整个程序会很容易——你只需改构造函数和选择函数。