数据抽象概览

介绍数据抽象的基本思想、构造函数与选择函数构成的抽象数据类型,并以有理数为例完整演示

什么是数据抽象?

回顾第 1 课——还记得作为黑箱抽象的过程吗?对于用作高阶函数实参的过程,只要它们能工作,你不必知道它们是怎么实现的!这让我们能创建通用的、“可定制”的函数,使代码简洁、可读、灵活。

复合数据的类似概念叫数据抽象(data abstraction)。它是一种方法论,让我们把”如何使用复合数据对象”与”如何用更基本的数据对象构造它”的细节隔离开。换句话说,你不需要懂汽车的发动机原理就能开车。

数据抽象的基本思想是:把使用复合数据对象的程序组织成操作”抽象数据”的形式。也就是说,我们的程序使用数据时,不对数据如何存储或提取做任何假设。这样一来,数据的表示方式是”具体”的,且独立于使用它的程序。

专业程序员写的程序和项目常常面向公众,而公众不一定都懂代码。如果一家科技公司用 Python 写了很棒的程序,他们不会要求客户为了使用产品而知道程序怎么写,甚至懂 Python。那么这些程序员如何让不会编程的人用上他们的作品?靠抽象。这正是编程的精髓。

我们系统中这两部分之间的接口是一组过程,叫构造函数(constructor)和选择函数(selector):

  • 构造函数创建存储我们数据的对象。
  • 选择函数从构造函数创建的对象中提取你要用的数据。

构造函数创建的对象叫抽象数据类型(abstract data type,ADT)。

示例:有理数

为了说明这个技术,我们来看看如何设计一个操作有理数的接口。

有理数是能表示为两个整数之商或分数(p/q)的数,其中 q 非零。例如,3/4 是分母为 4、分子为 3 的有理数,而 π 是无理数。

虽然 Racket 语言本身就支持分数,我们还是试试创建自己的抽象数据类型来表示它。动手写构造函数和选择函数之前,先看看需要哪些信息。

完整表示一个有理数所需的最少数据是分子和分母。所以,我们可以任选一种方式存这两个数。这里选择用序对存:

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

构造函数就这样!它只是个过程,用合适的实参调用时,通过把有理数存进序对来”创建”它。诚然,(3 . 4) 看起来不太像分数,但这正是选择函数的用武之地。怎么从这个抽象数据类型中提取分子和分母?看这里:

(define (numerator rat)
  (car rat))

(define (denominator rat)
  (cdr rat))

第一个选择函数 numerator 接受一个有理数作为实参,对它调用 car 返回分子。第二个选择函数对它调用 cdr 返回分母。至此,我们的抽象数据类型实现完毕!可以这样创建有理数并提取数据:

-> (define x (make-rational 3 4))
x
-> (numerator x)
3
-> (denominator x)
4

看到没有,上面的调用中没有任何东西透露有理数是如何表示的?你已经把数据的表示方式抽象掉了,留下一个几乎谁都能用的干净接口。

抽象数据类型的构造函数和选择函数密不可分。这个有理数实现的选择函数,换到另一种有理数实现上就失效。我们本可以用列表、句子、小数等。抽象的美妙就在于我们不知道。

笔记

测试理解

考虑在平面上表示线段的问题。每条线段表示为一对点:起点和终点。

点表示为一对坐标:

(define (make-point x y) (cons x y))
(define (x-coord point) (car point))
(define (y-coord point) (cdr point))

定义构造函数 make-segment 和选择函数 start-segment、end-segment,用点来定义线段的表示。存储数据的方式任选。

使用 ADT 的过程

接着有理数 ADT,我们写几个尊重实现抽象的过程。有个有用的过程是 print-rat,它让我们能看到有理数在抽象表示下”长什么样”。

-> (define (print-rat rat)
    (word (numerator rat) '/ (denominator rat)))
-> (define x (make-rational 3 4))
x
-> (print-rat x)
3/4

这样我们就可以假装有理数不是序对。 :)

如果不能对有理数做数学运算,它有什么用?这里我们为 ADT 定义了一些简单的算术过程:

(define (add-rat rat1 rat2)
  (make-rational (+ (* (numerator rat1) (denominator rat2))
                    (* (numerator rat2) (denominator rat1)))
                 (* (denominator rat1) (denominator rat2))))

(define (sub-rat rat1 rat2)
  (make-rational (- (* (numerator rat1) (denominator rat2))
                    (* (numerator rat2) (denominator rat1)))
                 (* (denominator rat1) (denominator rat2)))))

(define (mul-rat rat1 rat2)
  (make-rational (* (numerator rat1) (numerator rat2))
                 (* (denominator rat1) (denominator rat2))))

(define (div-rat rat1 rat2)
  (make-rational (* (numerator rat1) (denominator rat2))
                 (* (denominator rat1) (numerator rat2))))

(define (equal-rat? rat1 rat2)
  (= (* (numerator rat1) (denominator rat2))
     (* (numerator rat2) (denominator rat1))))

注意这些过程如何尊重抽象。我们的代码里没有任何地方调用 cons 来创建有理数,或用 car/cdr 选分子分母。不这样做就叫数据抽象违规(data abstraction violation),这个后面再聊。现在,继续看更大更好的例子!

笔记

测试理解

用你上面的线段实现,定义过程 segment-length,它接受一条线段并返回其长度。