可以工作的类 — 类的基础:抽象数据类型

作者:Wupei  |  发表时间:  |  所属分类:编程思想

转自: 《代码大全2》 第六章 可以工作的类

在计算时代的早期,程序员基于语句思考编程问题。到了20世纪七八十年代,程序员开始基于子程序去思考编程。进入21世纪,程序员以类为基础思考编程问题。

类是由一组数据和子程序构成的集合,这些数据和子程序共同拥有一组内聚的、明确定义的职责。类也可以只是由一组子程序构成的集合,这些子程序提供一组内聚的服务,哪怕其中并未涉及共用的数据。成为高效程序员的一个关键就在于,当你开发程序任一部分的代码时,都能安全地忽视程序中尽可能多的其余部分。而类就是实现这一目标的首要工具。

本章将就如何创建高质量的类提供一些精辟的建议。如果你是刚刚开始接触面向对象的概念,那会觉得本章的内容比较难懂。所以请一定先阅读第5章“软件构建中的设计”,然后再阅读第6.1节“类的基础:抽象数据类型(ADT)”。之后,你就应该可以比较轻松地阅读剩余各节了。如果你已经对类的基础知识比较熟悉,那么可以略读第6.1节后深入阅读第6.2节关于类接口的论述。另外,在本章最后一节“更多资源”中还包含对其他一些初级读物、高级读物以及与特定编程语言相关的资料介绍。

Class Foundations: Abstract Data Types (ADTs)

类的基础:抽象数据类型

抽象数据类型(ADT, abstract data type)是指一些数据以及对这些数据所进行的操作的集合。这些操作既向程序的其余部分描述了这些数据是怎么样的,也允许程序的其余部分改变这些数据。“抽象数据类型”概念中“数据”一词的用法有些随意。一个ADT可能是一个图形窗体以及所有能影响该窗体的操作;也可以是一个文件以及对这个文件进行的操作;或者是一张保险费率表以及相关操作等等。

要想理解面向对象编程,首先要理解ADT。不懂ADT的程序员开发出来的类只是名义上的“类”而已——实际上这种“类”只不过就是把一些稍有点儿关系的数据和子程序堆在一起。然而在理解ADT之后,程序员就能写出在一开始很容易实现、日后也易于修改的类来。

传统的编程教科书在讲到抽象数据类型时,总会用一些数学中的事情打岔。这些书往往会像这么写:“你可以把抽象数据类型想成一个定义有一组操作的数学模型。”这种书会给人一种感觉,好像你从不会真正用到抽象数据类型似的——除非拿它来催眠。

把抽象数据类型解释得这么空洞是完全丢了重点。抽象数据类型可以让你像在现实世界中一样操作实体,而不必在低层的实现上摆弄实体,这多令人兴奋啊。你不用再向链表中插入一个节点了,而是可以在电子表格中添加一个数据单元格,或向一组窗体类型中添加一个新类型,或给火车模型加挂一节车厢。深入挖掘能在问题领域工作(而非在底层实现领域工作)的能量吧!

Example of the Need for an ADT

需要用到ADT的例子

为了展开讨论,这里先举一个例子,看看ADT在什么情况下会非常有用。有了例子之后我们将继续深入细节探讨。

假设你正在写一个程序,它能用不同的字体、字号和文字属性(如粗体、斜体等)来控制显示在屏幕上的文本。程序的一部分功能是控制文本的字体。如果你用一个ADT,你就能有捆绑在相关数据上的一组操作字体的子程序——有关的数据包括字体名称、字号和文字属性等。这些子程序和数据集合为一体,就是一个ADT。

如果不使用ADT,你就只能用一种拼凑的方法来操纵字体了。举例来说,如果你要把字体大小改为12磅(point),即高度碰巧为16个像素(pixel),你就要写类似这样的代码:

currentFont.size = 16

如果你已经开发了一套子程序库,那么代码可能会稍微好看一些:

currentFont.size = PointsToPixels(12)

或者你还可以给该属性起一个更特定的名字,比如说:

currentFont.sizeOnPixels = PointsToPixels(12)

但你是不能同时使用currentFont.sizeInPixels和currentFont.sizeInPoints,因为如果同时使用这两项数据成员,currentFont就无从判断到底该用哪一个了。而且,如果你在程序的很多地方都需要修改字体的大小,那么这类语句就会散布在整个程序之中。

如果你需要把字体设为粗体,你或许会写出下面的语句,这里用到了一个逻辑or运算符和一个16进制常量0x02:

currentFont.attribute = CurrentFont.attribute or 0x02

如果你够幸运的话,也可能代码会比这样还要干净些。但使用拼凑方法的话,你能得到的最好结果也就是写成这样:

currentFont.attribute = CurrentFont.attribute or BOLD

或者是这样:

currentFont.bold = True

就修改字体大小而言,这些做法都存在一个限制,即要求调用方代码直接控制数据成员,这无疑限制了currentFont的使用。

如果你这么编写程序的话,程序中的很多地方就会到处充斥着类似的代码。

Benefits of Using ADTs

使用ADT的益处

问题并不在于拼凑法是种不好的编程习惯。而是说你可以采用一种更好的编程方法来替代这种方法,从而获得下面这些好处:

可以隐藏实现细节  把关于字体数据类型的信息隐藏起来,意味着如果数据类型发生改变,你只需在一处修改而不会影响到整个程序。例如,除非你把实现细节隐藏在一个ADT中,否则当你需要把字体类型从粗体的第一种表示变成第二种表示时,就不可避免地要更改程序中所有设置粗体字体的语句,而不能仅在一处进行修改。把信息隐藏起来能保护程序的其余部分不受影响。即使你想把在内存里存储的数据改为在外存里存储,或者你想把所有操作字体的子程序用另一种语言重写,也都不会影响程序的其余部分。
 

改动不会影响到整个程序  如果想让字体更丰富,而且能支持更多操作(例如变成小型大写字母、变成上标、添加删除线等)时,你只需在程序的一处进行修改即可。这一改动也不会影响到程序的其余部分。

让接口能提供更多信息  像currentFont.size = 16这样的语句是不够明确的,因为此处16的单位既可能是像素也可能是磅。语句所处的上下文环境并不能告诉你到底是哪一种单位。把所有相似的操作都集中到一个ADT里,就可以让你基于磅数或像素数来定义整个接口,或者把二者明确地区分开,从而有助于避免混淆。

更容易提高性能  如果你想提高操作字体时的性能,就可以重新编写出一些更好的子程序,而不用来回修改整个程序。

让程序的正确性更显而易见  验证像currentFont.attribute = current-
Font.attribute or 0x02这样的语句是否正确是很枯燥的,你可以替换成像currentFont.SetBoldOn()这样的语句,验证它是否正确就会更容易一些。对于前者,你可能会写错结构体或数据项的名字,或者用错运算符(用了and而不是or),也可能会写错数值(写成了0x20而不是0x02)。但对于后者,在调用current-
Font.SetBoldOn()时,唯一可能出错的地方就是写错方法(成员函数)名字,因此识别它是否正确就更容易一些。

程序更具自我说明性  你可以改进像currentFont.attribute or 0x02这样的语句——把0x02换成BOLD或“0x02所代表的具体含义”,但无论怎样修改,其可读性都不如currentFont.SetBoldOn()这条语句。

Woodfield、Dunsmore和Shen曾做过这样一项研究,他们让一些计算机科学专业的研究生和高年级本科生回答关于两个程序的问题:第一个程序按功能分解为8个子程序,而第二个程序分解为抽象数据类型中的8个子程序(1981)。结果,按那些使用抽象数据类型程序的学生的得分比使用按功能划分的程序的学生高出超过30%。

无须在程序内到处传递数据  在刚才那个例子里,你必须直接修改current-
Font的值,或把它传给每一个要操作字体的子程序。如果你使用了抽象数据类型,那么就不用再在程序里到处传递currentFont了,也无须把它变成全局数据。ADT中可以用一个结构体来保存currentFont的数据,而只有ADT里的子程序才能直接访问这些数据。ADT之外的子程序则不必再关心这些数据。

你可以像在现实世界中那样操作实体,而不用在底层实现上操作它  你可以定义一些针对字体的操作,这样,程序的绝大部分就能完全以“真实世界中的字体”这个概念来操作,而不再用数组访问、结构体定义、True与False等这些底层的实现概念了。

这样一来,为了定义一个抽象数据类型,你只需定义一些用来控制字体的子程序——多半就像这样:

currentFont.SetSizeInPoints(sizeInPoints)

currentFont.SetSizeInPixels(sizeInPixels)

currentFont.SetGBoldOn()

currentFont.SetBoldOff()

currentFont.SetItalicOn()

currentFont.SetItalicOff()

currentFont.SetTypeFace(faceName)

这些子程序里的代码可能很短——很可能就像你此前看到的那个用拼凑法控制字体时所写的代码。这里的区别在于,你已经把对字体的操作都隔离到一组子程序里了。这样就为需要操作字体的其他部分程序提供了更好的抽象层,同时它也可以在针对字体的操作发生变化时提供一层保护。

****************************************************************

假设你开发了一套软件来控制一个核反应堆的冷却系统。你可以为这个冷却系统规定如下一些操作,从而将其视作一个抽象数据类型:

coolingSystem.GetTemperature()

coolingSystem.SetCirculationRate(rate)

coolingSystem.OpenValve(valveNumber)

coolingSystem.CloseValve(valveNumber)

实现上述各操作的代码由具体环境决定。程序的其余部分可以用这些函数来操纵冷却系统,无须为数据结构的实现、限制及变化等内部细节而操心。

下面再举一些抽象数据类型以及它们可能提供的操作:


巡航控制

设置速度

获取当前设置

恢复之前的速度

解散


搅拌机

开启

关闭

设置速度

启动“即时粉碎器”

停止“即时粉碎器”


油罐

填充油罐

排空油罐

获取油罐容积

获取油罐状态

 


列表

初始化列表

向列表中插入条目

从列表中删除条目

读取列表中的下一个条目


灯光

开启

关闭


堆栈

初始化堆栈

向堆栈中推入条目

从堆栈中弹出条目

读取栈顶条目


帮助屏幕

添加帮助项

删除帮助项

设置当前帮助项

显示帮助屏幕

关闭帮助显示

显示帮助索引

返回前一屏幕


菜单

开始新的菜单

删除菜单

添加菜单项

删除菜单项

激活菜单项

禁用菜单项

显示菜单

隐藏菜单

获取菜单选项


文件

打开文件

读取文件

写入文件

设置当前文件位置

关闭文件

 


电梯


指针

获取新分配内存的指针

用现有指针释放内存

更改已分配内存的大小

到上一层

到下一层

到指定层

报告当前楼层

回到底层

通过研究这些例子,你可以得出一些指导建议,下面就来说明这些指导建议:

把常见的底层数据类型创建为ADT并使用这些ADT,而不再使用底层数据类型  大多数关于ADT的论述中都会关注于把常见的底层数据类型表示为ADT。从前面的例子中可以看到,堆栈、列表、队列以及几乎所有常见的底层数据类型都可以用ADT来表示。

你可能会问:“这个堆栈、列表或队列又是代表什么呢?”如果堆栈代表的是一组员工,就该把它看做是一些员工而不是堆栈;如果列表代表的是一个出场演员名单,就该把它看做是出场演员名单而不是列表;如果队列代表的是电子表格中的一组单元格,就该把它看做是一组单元格而不是一个一般的队列。也就是说,要尽可能选择最高的抽象层次。

把像文件这样的常用对象当成ADT  大部分编程语言中都包含有一些抽象数据类型,你可能对它们已经比较熟悉了,而只是可能并未将其视作ADT。文件操作是个很好的例子。在向磁盘写入内容时,操作系统负责把读/写磁头定位到磁盘上的特定物理位置,如果扇区的空间用完了,还要重新分配新扇区,并负责解释那些神秘的错误代码。操作系统提供了第一层次的抽象以及在该层次上的ADT。高层语言则提供了第二层次的抽象以及在这一更高层次上的ADT。高级语言可以让你无须纠缠于调用操作系统API以及管理数据缓冲区等繁琐细节,从而让你可以把一块磁盘空间视做一个“文件”。

你可以采用类似的做法对ADT进行分层。如果你想在某一层次用ADT来提供数据结构的操作(比如说在堆栈中压入和弹出数据),没问题。而你也可以在这一抽象层次之上再创建一个针对现实世界中的问题的抽象层次。

简单的事物也可当做ADT  为了证明抽象数据类型的实用价值,你不一定非要使用庞杂的数据类型。在前面的一组例子中,有一盏只支持两种操作(开启、关闭)的灯。你可能会觉得把简单的“开”、“关”操作放到单独的子程序中有些浪费功夫,不过即使这样简单的操作也可以通过使用ADT而获益。把灯和与之相关的操作放到一个ADT里,可以提高代码的自我说明能力,让代码更易于修改,还能把改动可能引起的后果封闭在TurnLightOn()和TurnLightOff()两个子程序内,并减少了需要到处传递的数据的项数。

不要让ADT依赖于其存储介质  假设你有一张保险费率表,它太大了,因此只能保存到磁盘上。你可能想把它称做一个“费率文件”然后编出类似RateFile.Read()这样的访问器子程序(access routine)。然而当你把它称做一个“文件”时,已经暴露了过多的数据信息。一旦对程序进行修改,把这张表存到内存中而不是磁盘上,把它当做文件的那些代码将变成不正确的,而且产生误导并使人迷惑。因此,请尽量让类和访问器子程序的名字与存储数据的方式无关,并只提及抽象数据类型本身,比如说“保险费率表”。这样一来,前面这个类和访问器子程序的名字就可能是rateTable.Read(),或更简单的rates.Read()。

Handling Multiple Instances of Data with ADTs in Non-Object-

Oriented Environments

在非面向对象环境中用ADT处理多份数据实例

面向对象的编程语言能自动支持对同一ADT的多份实例的处理。如果你只是在面向对象的环境中工作,那你根本就不用自己操心处理多个实例的实现细节了,恭喜你!(你可以直接去读下一节“ADT和类”。)

如果你是在像C语言这样的非面向对象的环境中工作,你就必须自己手工实现支持处理多个实例的技术。一般来说,这就意味着你要为ADT添加一些用来创建和删除实例的服务操作,同时需要重新设计ADT的其他服务操作,使其能够支持多个实例。

前面字体那个ADT原来只是提供这些操作:

currentFont.SetSize(sizeInPoints)

currentFont.SetBoldOn()

currentFont.SetBoldOff()

currentFont.SetItalicOn()

currentFont.SetItalicOff()

currentFont.SetTypeFace(faceName)

在非面向对象的环境里,这些操作不能附着在某个类上,此很可能要写成:

SetCurrentFontSize( sizeInPoints )

SetCurrentFontBoldOn()

SetCurrentFontBoldOff()

SetCurrentFontItalicOn()

SetCurrentFontItalicOff()

SetCurrentFontTypeFace( faceName )

如果你想一次使用更多的字体,那么就需要增加一些服务操作来创建和删除字体的实例了,比如说这样:

CreateFont(fontId)

DeleteFont(fontId)

SetCurrentFont(fontId)

这里引入了一个fontId变量,这是用来在创建和使用多个字体实例时分别控制每个实例的一种处理方法。对于其他操作,你可以采用下列三种方法之一对ADT的接口进行处理:

做法1:每次使用ADT服务子程序时都明确地指明实例。在这种情况下没有“当前字体”的概念。你把fontId传给每个用来操作字体的子程序。Font ADT的服务子程序负责跟踪所有底层的数据,而调用方代码只需使用不同的fontId即可区分多份实例。这种方法需要为每个Font子程序都加上一个fontId参数。

做法2:明确地向ADT服务子程序提供所要用到的数据。采用这种方法时,你要在调用ADT服务的子程序里声明一个该ADT所要用到的数据。换句话说,你要声明一个Font数据类型,并把它传给ADT中的每一个服务子程序。你在设计时必须要让ADT的每个服务子程序在被调用时都使用这个传入的Font数据类型。用这种方法时,调用方代码无须使用fontId,因为它总是自己跟踪字体数据。(虽然从Font数据类型即可直接取得所有数据,但你仍然应该仅通过ADT的服务子程序来访问它。这称为保持结构体“封闭”。)

这种方法的优点是,ADT中的服务子程序不需要根据fontId来查询字体的信息。而它的缺点则是向程序的其余部分暴露了字体内部的数据,从而增加了调用方代码可能利用ADT内部实现细节的可能性,而这些细节本应该隐藏在ADT的内部。

做法3:使用隐含实例(需要倍加小心)。设计一个新的服务子程序,通过调用它来让某一个特定的字体实例成为当前实例——比方说SetCurrentFont(fontId)。一旦设置了当前字体,其他所有服务子程序在被调用时都会使用这个当前字体。用这种方法也无须为其他服务子程序添加fontId参数。对于简单的应用程序而言,这么做可以让使用多个实例更为顺畅。然而对于复杂的应用程序来说,这种在系统范围内对状态的依赖性就意味着,你必须在用到字体操作的所有代码中跟踪当前的字体实例。这样一来,复杂度有可能会急剧增长,对于任何规模的应用程序来说,还有一些更好的替代方案。

在抽象数据类型的内部,你还可以选择更多处理多个实例的方法;但在抽象数据类型的外部,如果你使用非面向对象的编程语言的话,能选择的方法也就是这些了。

ADTs and Classes

ADT和类

抽象数据类型构成了“类/class”这一概念的基础。在支持类的编程语言里,你可以把每个抽象数据类型用它自己的类实现。类还涉及到继承和多态这两个额外的概念。因此,考虑类的一种方式,就是把它看做是抽象数据类型再加上继承和多态两个概念。

Trackback from your site.

请在这里留言: