代码大全《Code Complete》——可以工作的类(第6章)

293 阅读14分钟

第六章 可以工作的类

6.1 类的基础:抽象数据类型(ADTs)

抽象数据类型(ADT, abstract data type)是指一些数据以及对这些数据所进行的操作的集合。这些操作既向程序的其余部分描述了这些数据是怎么样的,也允许程序的其余部分改变这些数据。

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

不使用ADT的写法:

如果不使用ADT,要把字体大小改为12磅(也就是16像素),就要写类似这样的代码

currentFont.size = 16

或者

currentFont.size = PointsToPixels(12)

或者

currentFont.sizeonPixels = PointsToPixels (12)

这样是在直接修改currenFont的属性值(数据成员)

而使用ADT的写法可以是:

currentFont.setsizeInPoints(sizeInPoints)

currentFont.setsizeInPixels(sizeInPixels)

这样写的优势主要有

可以隐藏实现细节

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

改动不会影响到整个程序

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

让接口能提供更多信息

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

无须在程序内到处传递数据

避免了过多的数据必须要变成全局数据来实现修改。ADT里的数据由ADT中的子程序访问,ADT之外的子程序不必再关心这些数据。

不要让ADT依赖于其存储介质

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

6.2 良好的类接口

这里的接口指代类中的所有的public函数,它们应该是具有良好内聚性的,即应该展现一致的抽象层次。每一个类都实现且仅实现一个ADT。

image.png 同时这里的继承关系也不满足is-a原则

image.png

提供成对的服务

在设计类时,要考虑到许多在逻辑上对应的操作,比如打开和关闭、添加与删除等。但是添加的前提是确实需要。

把不相关的信息转移到其他类中

如果某个类中一半子程序使用着该类的一半数据,而另一半子程序则使用另一半数据。把它们拆开吧!

减少接口中的语义约束,尽可能让接口由代码约束

每个接口都由一个编程性的部分和一个语义性部分组成。编程性的部分由接口中的数据类型和其他属性构成,编译器能强制性地要求它们(在编译时检查错误)。而语义部分则由“本接口将会被怎样使用”的假定组成,而这些是无法通过编译器来强制实施的。语义接口中包含的考虑比如“RoutineA必须在RoutineB之前被调用”或“如果dataMember未经初始化就传给RoutineA的话,将会导致Routinea崩溃”。语义接口应通过注释说明,但要尽可能让接口不依赖于这些说明。一个接口中任何无法通过编译器强制实施的部分,就是一个可能被误用的部分。要想办法把语义接口的元素转换为编程接口的元素,比如说用Asserts或其他的技术。

谨防在修改时破坏接口的抽象

在对类进行修改和扩展的过程中,避免出现跨层抽象出现在同一个类里,比如控制层,服务层和数据层之间的混杂。

每次向类的接口中添加子程序时,问问“这个子程序与现有接口所提供的抽象一致吗?”避免破坏其完整性、内聚性。

尽可能限制类和成员的可访问性

如果暴露一个方法不会让抽象变得不一致的话,那么这样很可能就是可行的。如果你不确定,那么把它隐藏起来。除此之外,对于成员数据的原则就是不要暴露,而是提供给外部你规定的操作方法。

不要对类的使用者做出任何假设

类的设计和实现应该符合在类的接口中所隐含的契约。它不应该对接口会被如何使用或不会被如何使用做出任何假设——除非在接口中有过明确说明。即,减少语义信息。

下面是一些类的调用方代码从语义上破坏封装性的例子:

  • 不去调用A类的Initializeoperations()子程序,因为你知道A类的PerformFirstoperation ( )子程序会自动调用它。

  • 不在调用employee.Retrive(database)之前去调用database.Connect ()子程序,因为你知道在未建立数据库连接的时候employee.Retrieve( )会去连接数据库的。

  • 不去调用A类的Terminate()子程序,因为你知道A类的PerformFinal-operation ()子程序已经调过它了。

  • 即便在 object A 离开作用域之后,你仍去使用出 object A 创建的、指向 Object B 的指针或引用,因为你知道object A把 object B放置在静态存储空间中了,因此 object B肯定还可以用。

  • 使用classB.MAXIMUM_ELEMENTs而不用classA.MAXIMUM_ELEMENTS,因为你知道它们两个的值是相等的。

上面这些例子的问题都在于,它们让调用方代码不是依赖于类的公开接口,而是依赖于类的私用实现。每当你发现自己是通过查看类的内部实现来得知该如何使用这个类的时候,你就不是在针对接口编程了,而是在透过接口针对内部实现编程了。如果你透过接口来编程的话,封装性就被破坏了,而一旦封装性开始遭到破坏,抽象能力也就快遭殃了。

按照经验来说,类中包含的数据成员数量最好符合7±2原则。如果数据成员都是简单数据类型,可以按上限考虑,如果都是复杂对象,则应按照下限来考虑。

李氏原则(Liskov替换原则)

派生类必须能通过基类的接口使用,且使用者无需了解两者之间的差异。

只有一个实例的类是值得怀疑的

考虑一下能否用数据来替代用一个新的lei类来表达

只有一个派生类的基类也值得怀疑

这或许是一种“提前设计”一一也就是试图去预测未来的需要,而又常常没有真正了解未来到底需要什么。为未来要做的工作着手进行准备的最好方法,并不是去创建几层额外的、“没准以后哪天就能用得上的”基类,而是让眼下的工作成果尽可能地清晰、简单、直截了当。也就是说,不要创建任何并非绝对必要的继承结构。

派生后空白覆盖某个方法也值得怀疑

这通常表明基类的设计中有错误。举例来说,假设你有一个Ca类,它有一个scratch()成员函数,可是最终你发现有些猫的爪尖儿没了,不能抓了。你可能想从Cat类派生个叫scratchlessCat的类,然后覆盖scratch()方法让它什么都不做。但这种做法有这么几个问题。

它修改了cat类的接口所表达的语义,因此破坏了Cat类所代表的抽象(即接口契约)。

当你从它进一步派生出其他派生类时,采用这一做法会迅速失控。如果你又发现有只猫没有尾巴该怎么办?或者有只猫不捉老鼠呢﹖再或者有只猫不喝牛奶?最终你会派生出一堆类似scratchless、Tailless、Miceless、MilklessCat这样的派生类来。

采用这种做法一段时间后,代码会逐渐变得混乱而难以维护,因为基类的接口和行为几乎无法让人理解其派生类的行为。

修正这一问题的位置不是在派生类,而是在最初的Cat类中。应该创建一个Claw类并让 Cat类包含它。问题的根源在于做了所有猫都能抓的假设,因此应该从源头上解决问题,而不是到发现问题的地方修补。

避免继承过深

2-3层的继承已经足够复杂

何时使用继承

  • 如果多个类共享数据而非行为,应该创建这些类可以包含的共用对象。如果多个类共享行为而非数据,应该让它们从共同的基类继承而来,并在基类里定义共用的子程序。

  • 如果多个类既共享数据也共享行为,应该让它们从一个共同的基类继承而来,并在基类里定义共用的数据和子程序。

  • 当你想由基类控制接口时,使用继承;当你想自己控制接口时,使用包含。

关于构造函数的一些指导建议

  • 如果可能,应该在所有的构造函数中初始化所有的数据成员。在所有的构造函数中初始化所有的数据成员是一个不难做到的防御式编程实践。

  • 用私用构造函数来强制实现单例属性。如果你想定义一个类,并需要强制规定它只能有唯一一个对象实例的话,可以把该类所有的构造函数都隐藏起来,然后对外提供一个static 的GetInstance()子程序来访问该类的唯一实例。

  • 优先采用深拷贝,除非论证可行,才采用浅拷贝。为了不确定性能提高采用浅拷贝,就要面对如何确保安全地比较对象、如何安全地删除对象等问题。

6.4 创建类的原因

  • 对现实世界中的对象建模

  • 对抽象对象建模

  • 降低复杂度

  • 隔离复杂度

  • 隐藏实现细节

  • 限制变化所影响的范围

  • 隐藏全局数据

  • 让参数传递更顺畅

  • 创建中心控制点

  • 让代码更易于重用

  • 为程序族做计划

  • 把相关操作放到一起

  • 实现特定的重构

6.5 与具体编程语言相关的问题

例如:

在Java中,所有的方法默认都是可以覆盖的,方法必须被定义成final才能阻止派生类对它进行覆盖。

在C++中,默认是不可以覆盖方法的,基类中的方法必须被定义成virtual才能被覆盖。

一般不同语言之间存在显著差异的地方:

  • 在继承层次中被覆盖的构造函数和析构函数的行为

  • 在异常处理时构造函数和析构函数的行为

  • 默认构造函数的重要性

  • finalizer的调用时机

  • 和覆盖语言内置的运算符(包括赋值和等号)相关的知识

  • 当对象被创建和销毁时,或当其被声明时,或者它所在的作用域退出时,处理内存的方式

6.6 超越类:包

包的规则有利于对类与类之间进行区分,如公有私有,某个包属于某个部门,暗含的规则等。

CHECKLIST: Class Quality

抽象数据类型

你是否把程序中的类都看做是抽象数据类型了﹖是否从这个角度评估它们的接口了?

抽象

类是否有一个中心目的?

类的命名是否恰当?其名字是否表达了其中心目的?

类的接口是否展现了一致的抽象?

类的接口是否能让人清楚明白地知道该如何用它?

类的接口是否足够抽象,使你能不必顾虑它是如何实现其服务的?你能把类看做黑盒子吗?

类提供的服务是否足够完整,能让其他类无须动用其内部数据?是否已从类中除去无关信息?

是否考虑过把类进一步分解为组件类?是否已尽可能将其分解?

在修改类时是否维持了其接口的完整性?

封装

是否把类的成员的可访问性降到最小?

是否避免暴露类中的数据成员?

在编程语言所许可的范围内,类是否已尽可能地对其他的类隐藏了自己 的实现细节?

类是否避免对其使用者,包括其派生类会如何使用它做了假设?

类是否不依赖于其他类?它是松散耦合的吗?

继承

继承是否只用来建立“is-a”的关系?派生类是否遵循了LSP (Liskov替换原则)?

类的文档中是否记述了其继承策略?

派生类是否避免了“覆盖”不可覆盖的方法?

是否把公用的接口、数据和行为都放到尽可能高的继承层次中了?

继承层次是否很浅?

基类中所有的数据成员是否都被定义为private而非protected的了?

跟实现相关的其他问题

类中是否只有大约七个或更少的数据成员?

是否把类直接或间接调用其他类的子程序的数量减到最少了?

类是否只在绝对必要时才与其他的类相互协作?

是否在构造函数中初始化了所有的数据成员?

除非拥有经过测量的、创建浅层复本的理由,类是否都被设计为当作深层复本使用?

与语言相关的问题

你是否研究过所用编程语言里和类相关的各种特有问题?

Key Points

类的接口应提供一致的抽象。很多问题都是由于违背该原则而引起的。

类的接口应隐藏一些信息——如某个系统接口、某项设计决策、或一些实现细节。

包含往往比继承更为可取_除非你要对“is-a”的关系建模。

继承是一种有用的工具,但它却会增加复杂度,这有违于软件的首要技术使命——管理复杂度。

类是管理复杂度的首选工具。要在设计类时给予足够的关注,才能实现这一目标。