要想理解面向对象编程, 首先要理解 ADT. 不懂 ADT 的程序员开发出来的类只是名义上的"类"而已 ---- <<代码大全(第二版)>> (McConnell, 2004, p. 126)
什么是抽象数据类型 (Abstract Data Type)
抽象数据类型是指一些 数据 以及对这些数据所进行的 操作 的集合.
ADT = 数据 + 操作
比如一盏灯有 开灯 和 关灯 两种操作, 然后可能有 额定功率 , 负载, 使用寿命 等属性. 这些操作既向程序的其余部分描述了这些数据是什么样的, 也允许程序的其余部分来操控和改变这部分数据.
有一些人可能立马从这段描述中想到了面向对象中的类这个概念. 可以说 ADT 是一种思想, 而类则是编程语言实现 ADT 的一种工具. 而不使用面向对象的方式, 也可以实现 ADT 中所描述的思想.
使用 ADT 的例子
让我们结合一个例子来看看使用 ADT 的好处.
假设你正在编写一个程序, 它能用不同的字体, 大小和文字属性 (如粗体, 斜体等) 来控制显示在屏幕上的文本. 比如通过这个程序我们可以将屏幕上显示的字体变大. 这个程序中相关的数据 (比如字体的字号, 字体名称, 文字属性等) 以及捆绑在其之上的 操作字体的方法(函数) 的集合就是 ADT.
ADT = 相关数据 + 数据操作方法
如果不使用 ADT, 你就只能通过一种拼凑的方法来操纵字体了. 比如直接修改字体对象的内部属性, 或是调用一个外部函数来修改字体大小, 等等.
例如, 如果你需要把字体大小更改为 12 磅(point), 即 16 个像素(pixel), 你可能会写类似这样的代码
currentFont.size = 16;
在没有注释的情况下, 我们不知道这个 16 是屏幕上的像素尺寸, 还是字体的磅数. 所以我们可以使用更加准确的变量名来消除这种歧义.
currentFont.sizeInPixels = 16;
如果你已经定义了一整套用于操控字体的子程序库, 那么代码可能会好看一点:
currentFont.sizeInPixels = PointsToPixels(12);
但是我们不能同时使用 currentFont.sizeInPixels 和 currentFont.sizeInPoints. 因为一旦我们同时设置了 points 和 pixels. 程序就没法判断该使用哪一个了. 如果你在程序的很多地方都需要修改字体大小. 那么这类的语句会遍布整个程序.
如果你需要将程序设置为粗体, 或许会有下面的语句
currentFont.attribute = CurrentFont.attribute | 0x02;
这里使用了一个 按位或 运算以及一个常量 0x02. 如果你的水平再高一点, 也许代码会更加干净一点, 但最好的结果也就如下了:
currentFont.attribute = CurrentFont.attribute | BOLD;
或是这样
currentFont.bold = true;
就修改字体这个功能而言, 这些做法都存在一个限制, 那就是需要调用方亲自去修改数据成员. 这无疑限制了 currentFont 的使用.
因为调用方需要直接访问和修改对象的内部成员数据. 这会造成调用方代码和对象之间产生严重的耦合. 当对象的内部实现细节发生改变时 (比如将 bold 属性更改为布尔属性而不是 int 类型存储.) 那么调用方的代码也需要做出相应的改变.
使用了 ADT 之后, 我们将不直接访问和修改内部数据本身, 而是使用相关的操作来操控它们. 这种将实现细节隐藏起来的方式, 可以降低调用方和对象的耦合. 把关于字体数据类型的信息隐藏起来, 意味着如果数据类型发生改变, 我们仅仅需要改动一处地方而不会影响到整个程序.
例如, 除非你把实现细节隐藏在 ADT 中, 否则我们将字体类型从粗体的表示从布尔类型更改为整数类型, 我们就不可避免地需要去修改程序中每一处设置粗体字体的语句. 而不仅能在一处进行修改. 把信息隐藏起来可以保护程序的其余部分不受到影响, 不管你是把内存中存储的信息放到硬盘上存储, 还是使用另一种编程语言重写操纵字体的子程序, 都不会影响程序的其他部分.
这样一来, 为了定义一个 ADT, 你只需要定义一些用来控制字体的方法, 比如这样子:
currentFont.setSizeInPoints(sizeInPoints);
currentFont.setSizeInPixels(sizeInPixels);
currentFont.setBoldOn();
currentFont.setBoldOff();
currentFont.setItalicOn();
currentFont.setItalicOff();
currentFont.setTypeFace(faceName);
这些方法里的代码可能很短, 就像前面那样使用一行来设置属性. 但是这里的区别在于, 你已经把对字体的操控隔离到了各个方法内部. 这样就为使用和修改字体的其他程序提供了更好的抽象层, 同时也可以针对操控字体的操作发生变化时提供一层保护.
ADT 使我们能够将一个物体抽象化, 并只暴露出该物体可以使用的操作, 比如上面例子中的 字体 对象, 拥有比如调整字体大小, 调整字体加粗 等操作. 使用者无需去关心底层是怎么实现的, 该对象是如何跟踪和调整字体的这些属性并进行调整的.
更多的例子
假设你开发了一套软件来控制一个核反应堆的冷却系统. 你可以为这个冷却系统定义如下的一系列操作, 从而将其视作一个 ADT:
coolingSystem.getTemperature();
coolingSystem.setCirculationRate(rate);
coolingSystem.openValve(valveNumber);
coolingSystem.closeValve(valveNumber);
实现上述操作的代码由具体环境决定(比如环境内拥有的框架, 库, 使用的语言等). 程序的其余部分可以用这些函数来操控冷却系统, 而无须为数据结构的实现, 限制以及变化等内部操作而操心.
下面再列举一些 ADT 以及他们可能提供的操作:
- 巡航控制
-
设置速度
-
获取当前速度
-
恢复之前的速度
-
解散
- 搅拌机
-
开启
-
关闭
-
设置速度
-
启动 "即时粉碎器"
-
停止 "即时粉碎器"
- 油罐
-
填充油罐
-
排空油罐
-
获取油罐体积
-
获取油罐状态
- 列表
-
初始化列表
-
向列表中插入条目
-
从列表中删除条目
-
读取列表的下一个条目
- 堆栈
-
初始化堆栈
-
向堆栈中推入条目
-
从堆栈中弹出条目
-
读取堆栈条目
这里提到的 列表 和 堆栈 不一定指我们常常看到的数据结构, 他们可以是现实中物体的抽象. 例如 列表 可以是一个 excel 表格对象, 其中的 插入条目, 删除条目 可以看成是 插入一个单元格, 以及删除一个单元格. 如果堆栈代表的是一组员工, 那么就应该把它看作是一组员工而不是堆栈; 如果列表代表的是一个演出名单, 那么应该将看作是演出名单而不是列表. 也就是说, 尽可能为 ADT 选择最高的抽象.
在非面向对象的环境中如何使用 ADT
在比如 C 语言这样的非面向对象式编程语言中, 我们也可以实现 ADT. 其中最重要的就是你需要实现一组创建和删除实例的操作. 在面向对象的环境中, 编程语言使用类和对象的概念来帮助你管理多个 ADT 实例的处理. 每个 ADT 实例的数据是独立存在, 调用某个操作只会对对应的实例产生效果. 而在非面向对象的环境中, 我们需要自己处理多个 ADT 实例的处理.
在之前那个字体的例子中, 在面向对象的环境中我们是这样的:
currentFont.setSizeInPoints(sizeInPoints);
currentFont.setSizeInPixels(sizeInPixels);
currentFont.setBoldOn();
currentFont.setBoldOff();
currentFont.setItalicOn();
currentFont.setItalicOff();
currentFont.setTypeFace(faceName);
而在非面向对象的环境中, 我们有的只是一组函数:
setCurrentFontSize(sizeInPoints)
setCurrentFontBoldOn()
setCurrentFontBoldOff()
setCurrentFontItalicOn()
setCurrentFontItalicOff()
setCurrentFontTypeFace(faceName)
当程序中只有一个字体对象时, 这没什么问题. 但是当你想要处理多个字体对象时, 我们就需要增加一些服务操作来创建和删除字体的实例:
createFont(fontId)
deleteFont(fontId)
setCurrentFont(fondId)
这里引入了一个 fontId 的变量, 用于在创建和使用多个 ADT 实例变量时, 能够分别控制每一个实例的一种处理方法. 对于其他方法, 我们可以使用以下三种方法之一来实现对同时实例的处理
-
方法一: 在调用 ADT 的操作时明确指明要操作的对象. 比如将
fontId当作参数传入函数中, 该函数会对fontId对应的字体对象进行操作. 该方法需要给每个字体相关的子程序加上一个fontId参数. -
方法二: 在调用 ADT 的操作时, 将需要处理的数据给它. 比如将
字体对象当作参数传递给函数. 这个和第一个方法的区别在于, 第一个是拿到一个 "身份证" (fontId), 而函数可以通过这个 "身份证" 去找到那个对象. 而当前方法则是将这个对象直接丢给函数去处理. 而无需让函数再去寻找了. 也就无需 "身份证" 了.这个方法的优点是, ADT 中的程序无需通过 fontId 来查找需要处理的数据, 但缺点是将 ADT 内部数据的实现 细节暴露了. 从而增加了调用方利用 ADT 内部实现细节的可能性.
-
方法三: 使用隐含实例 (需要倍加小心). 我们可以设置一个函数, 用来设置当前的实例对象, 比如
setCurrentFont(fondId)将某个实例设置为全局对象. 而后续所有对 ADT 操作的调用都会默认在这个实例上. 对于简单的应用而言, 这么做很简单和快速. 但是对于复杂项目而言, 这种在整个项目范围内对状态的依赖是致命的. 这个意味着, 你在调用那些字体子程序前, 你需要清楚的知道你在对哪个实例进行操作.
总结
总结来说, 抽象数据类型 ADT 是指一组 数据 以及 操作数据的方法 的集合. ADT 可以隐藏内部实现细节, 将调用方和对象解耦合. 让整个程序的代码更加健壮易于修改.
你们说, 今天我们讲了讲 ADT 的概念, 那么大家对于 ADT 的实现 "类" 有没有兴趣? 我们应该如何设计一个好的类. 什么样的类是一个 "好类" ? 我们应该避免哪些 "坏类" ? 如果大家感兴趣, 我会在为未来出一篇文章讲讲, 如何设计一个良好的类.
Reference
-
<<代码大全(第二版)>> (McConnell, 2004, p. 126 - p. 133)
本文使用 文章同步助手 同步