数据抽象概览
介绍数据抽象的基本思想、构造函数与选择函数构成的抽象数据类型,并以有理数为例完整演示
什么是数据抽象?
回顾第 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,它接受一条线段并返回其长度。