目录
在计算时代的早期,程序员基于语句思考编程问题。到了20世纪七八十年代,程序员开始基干子程序去思考编程。进入21世纪,程序员以类为基础思考编程问题。
类是由一组数据和子程序构成的集合。
本章将就如何创建高质量的类提供一些精辟的建议。
抽象数据类型 (ADT,abstract data type) 是指一些数据以及对这些数据所进行的操作的集合。一个 ADT 可能是一个图形窗体以及所有能影响该窗体的操作;也可以是一个文件以及对这个文件进行的操作;或者是一张保险费率表以及相关操作等。
要想理解面向对象编程,首先要理解 ADT。不懂 ADT 的程序员开发出来的类只是名义上的“类”而己——实际上这种“类”只不过就是把一些稍有点儿关系的数据和子程序堆在一起。然而在理解 ADT 之后,程序员就能写出在一开始很容易实现、日后也易于修改的类来。
下面再举一些抽象数据类型以及它们可能提供的操作:
通过研究这些例子,你可以得出一些指导建议,下面就来说明这些指导建议:
1、把常见的底层数据类型创建为 ADT 并使用这些 ADT,而不再使用底层数据类型。 从前面的例子中可以看到,堆栈、列表、队列以及几乎所有常见的底层数据类型都可以用 ADT 来表示。你可能会问:“这个堆栈、列表或队列又是代表什么呢?”
2、把像文件这样的常用对象当成 ADT。 大部分编程语言中都包含有一些抽象数据类型,你可能对它们己经比较熟悉了,而只是可能并未将其视作 ADT。
3、简单的事物也可当做 ADT。 为了证明抽象数据类型的实用价值,你不一定非要使用庞杂的数据类型。
4、不要让 ADT 依赖于其存储介质。 假设你有一张保险费率表,它太大了,因此只能保存到磁盘上。你可能想把它称做一个“费率文件〞然后编出类似RateFile. Read() 这样的访问器子程序(access routine)。然而当你把它称做一个“文件”时,已经暴露了过多的数据信息。一旦对程序进行修改,把这张表存到内存中而不是磁盘上,把它当做文件的那些代码将变成不正确,而且产生误导并使人迷惑。因此,请尽量让类和访问器子程序的名字与存储数据的方式无关,并只提及抽象数据类型本身,比如说“保险费率表”。这样一来,前面这个类和访问器子程序的名字就可能是 raterable. Read(),或更简单的 rates. Read()。
创建高质量的类,第一步,可能也是最重要的一步,就是创建一个好的接口。这也包括了创建一个可以通过接口来展现的合理的抽象,并确保细节仍被隐藏在抽象背后。
正如第 5.3 节“形成一致的抽象”中所述,抽象是一种以简化的形式来看待复杂操作的能力。类的接口为隐藏在其后的具体实现提供了一种抽象。类的接口应能提供一组明显相关的子程序。
为了追求设计优秀,这里给出一些创建类的抽象接口的指导建议:
1、类的接口应该展现一致的抽象层次 。在考虑类的时候有一种很好的方法,就是把类看做一种用来实现抽象数据类型(ADT,见第 6.1 节)的机制。每一个类应该实现一个 ADT,并且仅实现这个 ADT。如果你发现某个类实现了不止一个ADT,或者你不能确定究竟它实现了何种 ADT,你就应该把这个类重新组织为一个或多个定义更加明确的 ADT。
在下面这个例子中,类的接口不够协调,因为它的抽象层次不一致:
这个类展现了两个 ADT: Employee 和 ListContainer。出现这种混合的抽象,通常是源于程序员使用容器类或其他类库来实现内部逻辑,但却没有把“使用类库”这一事实隐藏起来。请自问一下,是否应该把使用容器类这一事实也归入到抽象之中?这通常都是属于应该对程序其余部分隐藏起来的实现细节,就像下面这样:
有的程序员可能会认为从 ListContainer 继承更方便,因为它支持多态,可以传递给以 ListContainer 对象为参数的外部查询函数或排序函数来使用。然而这一观点却经不起对“继承”合理性的主要测试:“继承体现了‘是一个⋯(is a)’关系吗?” 如果从 ListContainer 中继承,就意味着 Bmoloveecensus “是一个”ListContainer,这显然不对。如果 Employeecensus 对象的抽象是它能够被搜索或排序,这些功能就应该被明确而一致地包含在类的接口之中。
在修改程序时,混杂的抽象层次会让程序越来越难以理解,整个程序也会逐步堕落直到变得无法维护。
2、一定要理解类所实现的抽象是什么。 一些类非常相像,你必须非常仔细地理解类的接口应该捕捉的抽象到底是哪一个。
3、提供成对的服务。 大多数操作都有和其相应的、相等的以及相反的操作。
4、把不相关的信息转移到其他类中 。有时你会发现,某个类中一半子程序使用着该类的一半数据,而另一半子程序则使用另一半数据。这时你其实已经把两个类混在一起使用了,把它们拆开吧!
5、尽可能让接口可编程,而不是表达语义。 每个接口都由一个可编程(program-matic)的部分和一个语义(semantic)部分组成。可编程的部分由接口中的数据类型和其他属性构成,编译器能强制性地要求它们(在编译时检查错误)。而语义部分则由“本接口将会被怎样使用”的假定组成,而这些是无法通过编译器来强制实施的。语义接口中包含的考虑比如“Routine入 必须在 RoutineB 之前被调用”或“如果 dataMember 未经初始化就传给 RoutineA 的话,将会导致 RoutineA 崩溃〞。语义接口应通过注释说明,但要尽可能让接口不依赖于这些说明。一个接口中任何无法通过编译器强制实施的部分,就是一个可能被误用的部分。要想办法把语义接口的元素转换为编程接口的元素,比如说用 Asserts(断言)或其他的技术。
6、谨防在修改时破坏接口的抽象。 在对类进行修改和扩展的过程中,你常常会发现额外所需的一些功能。这些功能并不十分适应于原有的类接口,可看上去却也很难用另一种方法来实现。举例来说,你可能会发现 Employee 类演变成了下面这个样子:
在雇工和检查邮政编码、电话号码或职位的子程序之间并不存在什么逻辑上的关联,那些暴露 SQL 语句查询细节的子程序所处的抽象层次比 Employee 类也要低得多,它们都破坏了 Employee 类的抽象。
7、不要添加与接口抽象不一致的公用成员。 每次你向类的接口中添加子程序时,问问“这个子程序与现有接口所提供的抽象一致吗?”如果发现不一致,就要换另一种方法来进行修改,以便能够保持抽象的完整性。
8、同时考虑抽象性和内聚性。 抽象性和内聚性这两个概念之间的关系非常紧密——一个呈现出很好的抽象的类接口通常也有很高的内聚性。而具有很强内聚性的类往往也会呈现为很好的抽象,尽管这种关系并不如前者那么强。
我发现,关注类的接口所表现出来的抽象,比关注类的内聚性更有助于深入地理解类的设计。如果你发现某个类的内聚性很弱,也不知道该怎么改,那就换一种方法,问问你自己这个类是否表现为一致的抽象。
设计精良的模块和设计糟糕的模块的唯一最大区别,就是对其他模块隐藏本模块内部数据和其他实现细节的程度。--Joshua Bloch
封装填补了抽象留下的空白。抽象是说:“可以让你从高层的细节来看待一个对象。”而封装则说:“除此之外,你不能看到对象的任何其他细节层次。” 要么就是封装与抽象两者皆有,要么就是两者皆失。除此之外没有其他可能。
关于房屋材质的比喻:封装是说,你可以从房屋的外面看,但不能靠得太近去把门的细节都看清楚。可以让你知道哪里有门,让你知道门是开着的还是关着的,但不能让你知道门是木质的、纤维玻璃的、钢质的还是其他什么材质的,当然就更不能让你看到每一根木纤维。
如图 5-8 所示,封裝帮助你管理复杂度的方法是不让你看到那些复杂度:
1、尽可能地限制类和成员的可访问性。 让可访问性 (accessibilty)尽可能低是促成封装的原则之一。当你在犹豫某个子程序的可访问性应该设为公用(public)、私用(private)抑或受保护(protected)时,经验之举是应该采用最严格且可行的访问级别(Meyers 1998, Bloch 2001)。
2、不要公开暴露成员数据。暴露成员数据会破坏封裝性,从而限制你对这个抽象的控制能力。
3、避免把私用的实现细节放入类的接口中。 做到真正的封装以后,程序员们是根本看不到任何实现细节的。无论是在字面上还是在喻意上,它们都被隐藏了起来。
4、不要对类的使用者做出任何假设。 类的设计和实现应该符合在类的接口中所隐含的契约。它不应该对接口会被如何使用或不会被如何使用做出任何假设一除非在接口中有过明确说明。像下面这样一段注释就显示出这个类过多地假定了它的使用者:请把x,y和2初始化为1.0,因为如果把它们初始化为0.0 的话,Derivedclass 就会崩溃.
5、避免使用友元类 (friend class)。有些场合下,比如说 State 模式中,按照正确的方式使用友元类会有助于管理复杂度(Gamma et al. 1995)。但在一般情况下友元类会破坏封装,因为它让你在同一时刻需要考虑更多的代码量,从而增加了复杂度。
6、不要因为一个子程序里仅使用公用子程序,就把它归入公开接口。 一个子程序仅仅使用公用的子程序这一事实并不是十分重要的考虑要素。相反,应该问的问题是,把这个子程序暴露给外界后,接口所展示的抽象是否还是一致的。
7、让阅读代码比编写代码更方便。 阅读代码的次数要比编写代码多得多,即使在开发的初期也是如此。因此,为了让编写代码更方便而降低代码的可读性是非常不经济的。
8、要格外警惕从语义上破坏封装性 。
比较起来,语义上的封裝性和语法上的封装性二者的难度相差无几。从语法的角度说,要想避免窥探另一个类的内部实现细节,只要把它内部的子程序和数据都声明为 private 就可以了,这是相对容易办到的。然而,要想达到语义上的封装性就完全是另一码事儿了。下面是一些类的调用方代码从语义上破坏其封裝性的例子。
上面这些例子的问题都在于,它们让调用方代码不是依赖于类的公开接口,而是依赖于类的私用实现。每当你发现自己是通过查看类的内部实现来得知该如何使用这个类的时候,你就不是在针对接口编程了,而是在透过接口针对内部实现编程了。如果你透过接口来编程的话,封装性就被破坏了,而一旦封装性开始遭到破坏,抽象能力也就快遭殃了。
如果仅仅根据类的接口文档还是无法得知如何使用一个类的话,下确的做法不是拉出这个类的源代码,从中查看其内部实现。这是个好的初衷,但却是个错误的决断。正确的做法应该是去联系类的作者,告诉他“我不知道该怎么用这个类。”而对于类的作者来说,正确的做法不是面对面地告诉你答案,而是从代码库中 check out(签出)类的接口文件,修改类的接口文档,再把文件 check in(签入)回去,然后告诉你“看看现在你知不知道该怎么用它了。”你希望让这一次对话出现在接口代码里,这样就能留下来让以后的程序员也能看到。你不希望让这一次对话只存在于自己的脑海里,这样会给使用该类的调用方代码烙下语义上的徽妙依赖性。你也不想让这一次对话只在个人之问进行,这样只能让你的代码获.益,而对其他人没有好处。
--我认为这一点非常重要,当你去读源代码(java提供的,别人提供的),你最先应该关注的是api继而推测如何使用这个类,而不是先去关注内部实现。当你想要了解其内部实现的时候,再去了解。
给类定义合理的接口,对于创建高质量程序起到了关键作用。然而,类内部的设计和实现也同样重要。这一节就来论述关于包含、继承、成员函数和数据成员、类之间的耦合性、构造函数、值对象与引用对象等的问题。
包含是一个非常简单的概念,它表示一个类含有一个基本数据元素或对象。与包含相比,关于继承的论述要多得多,这是因为继承需要更多的技巧,而且更容易出错,而不是因为继承要比包含更好。包含才是面向对象编程中的主力技术。
1、通过包含来实现“有一个/has a”的关系。 可以把包含想成是“有一个”关系。比如说,一名雇员“有一个”姓名、“有一个”电话号码、“有一个”税收DD等。通常,你可以让姓名、电话号码和税收 ID 成为 Bmployee 类的数据成员,从而建立这种关系。
2、在万不得已时通过 private 继承来实现“有一个”的关系。 这么做的主要原因是要让外层的包含类能够访问内层被包含类的 protected成员函数与数据成员。然而在实践中,这种做法会在派生类与基类之间形成一种过于紧密的关系,从而破坏了封装性。而且,这种做法也往往会带来一些设计上的错误,而这些错误是可以用“private 继承”之外的其他方法解决的。
3、警惕有超过约7个数据成员的类。 研究表明,人们在做其他事情时能记住的离散项目的个数是 7土2(Miller 1956)。如果一个类包含有超过约7个数据成员,请考虑要不要把它分解为几个更小的类(Riel 1996)。如果数据成员都是整型或字符串这种简单数据类型,你可以按7士2 的上限来考虑;反之,如果数据成员都是复杂对象的话,就应按7士2的下限来考虑了。
用C++进行面向对象编程时的一个最重要的法则就是:public继承代表的是“是一个”的关系。请把这一法则印在脑中。— Scott Meyers
当决定使用继承时,你必须要做如下几项决策:
下面就来详细解释如何考虑这些事项。
用 public 继承来实现“是一个•••••”的关系。 当程序员决定通过继承一个现有类的方式创建一个新类时,他是在表明这个新的类是现有类的一个更为特殊的版本。基类既对派生类将会做什么设定了预期,也对派生类能怎么运作提出了限制(Meyers 1998)。
如果派生类不准备完全遵守由基类定义的同一个接口契约,继承就不是正确的实现技术了。请考虑换用包含的方式,或者对继承体系的上层做修改。
要么使用继承并进行详细说明,要么就不要用它 。继承给程序增加了复杂度,如果某个类并末设计为可被继承,就应该把它的成员定义成 non-virtual (C++)、 final (Java)或 non-overridable (Microsoft VisualBasic),这样你就无法继承它了。
遵循 Liskov 替换原则 (Liskov Substitution Principle, LSP)。除非派生类真的“是一个”更特殊的基类,否则不应该从基类继承 。Andy Hunt 和 Dave Thomas 把 LSP总结为:“派生类必须能通过基类的接口而被使用,且使用者无需了解两者之间的差异。”(Hunt and Thomas 2000)。换句话说,对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的。
比如:如果你有一个 Account 基类以及 CheckingAccount、 SavingsAccount、AutoLoanaccount 三个派生类,那么程序员应该能调用这三个 Account 派生类中从 Account 继承而来的任何一个子程序,而无须关心到底用的是 Account 的哪一个派生类的对象。
不要“覆盖”一个不可覆盖的成员函数。 C++和 Java 两种语言都允许程序员“覆盖”那些不可覆盖的成员函数。如果一个成员函数在基类中是私用 (private)的话,其派生类可以创建一个同名的成员函数。对于阅读派生类代码的程序员来说,这个函数是令人困惑的,因为它看上去似乎应该是多态的,但事实上却非如此,只是同名而已。换种方法来说,本指导建议就是“派生类中的成员函数不要与基类中不可覆盖的成员函数的重名。”
把共用的接口、数据及操作放到继承树中尽可能高的位置。 接口、数据和操作在继承体系中的位置越高,派生类使用它们的时候就越容易。多高就算太高了呢?根据抽象性来决定吧。如果你发现把一个子程序移到更高的层次后会破坏该层对象的抽象性,就该停手了。
只有一个实例的类是值得怀疑的。 只需要一个实例,这可能表明设计中把对象和类混为一谈了。考虑一下能否只创建一个新的对象而不是一个新的类。派生类中的差异能否用数据而不是新的类来表达呢?单例(Singleton 〉模式则是本条指导方针的一个特例。
只有一个派生类的基类也值得怀疑。 每当我看到只有一个派生类的基类时,我就怀疑某个程序员又在进行 “提前设计”了——也就是试图去预测未来的需要,而又常常没有真正了解未来到底需要什么。为未来要做的工作着手进行准备的最好方法,并不是去创建几层额外的、“没准以后哪天就能用得上的”基类,而是让眼下的工作成果尽可能地清晰、简单、直截了当。也就是说,不要创建任何并非绝对必要的继承结构。
避免让继承体系太深。人们己经发现,过深的继承层次会显著导致错误率的增长(Basili, Briand andMelo 1996)。每个曾经调试过复杂继承关系的人都应该知道个中原因。过深的继承层次增加了复杂度,而这恰恰与继承所应解决的问题相反。请牢牢记住首要的技术使命。请确保你在用继承来避免代码重复并使复杂度最小。
让所有数据都是 private(而非 protected)。 正如 Joshua Bloch 所言,“继承会破坏封装”(Bloch 2001)。当你从一个对象继承时,你就拥有了能够访问该对象中的 protected 子程序和 protected数据的特权。如果派生类真的需要访问基类的属性,就应提供 protected 访问器函数 (accessor function)
在C++的多重继承中有一个毋庸置疑的事实就是,它打开了一个潘多拉的盒子,里面是单继承所没有的复杂度。-Scott Meyers
虽然有些专家建议广泛使用多重继承(Meyer1997),但以我的经验而言,多重继承的用途主要是定义“混合体(mixins)”,也就是一些能给对象增加一组属性的简单类。
程序员在决定使用多重继承之前,应该仔细地考虑其他替代方案,并谨慎地评估它可能对系统的复杂度和可理解性产生的影晌。
这一节给出了许多规则,它们能帮你远离与继承相关的麻烦。所有这些规则背后的潜台词都是在说,继承往往会让你和程序员的首要技术使命(即管理复杂度)背道而驰。从控制复杂度的角度说,你应该对继承持有非常歧视的态度。
下面来总结一下何时可以使用继承,何时又该使用包含:
如果可能,应该在所有的构造函数中初始化所有的数据成员 。 在所有的构造函数中初始化所有的数据成员是一个不难做到的防御式编程实践。
用私用(private)构造函数来强制实现单件属性(singleton property)。如果你想定义一个类,并需要强制规定它只能有唯一一个对象实例的话,可以把该类所有的构造函数都隐藏起来,然后对外提供一个 static 的 CetInstance() 子程序来访问该类的唯一实例。它的工作方式如下例所示:
优先采用深层复本(deep copies),除非论证可行,才采用浅层复本 (shallowcopies)。在设计复杂对象的时候,你需要做出一项主要决策,即应为对象实现深拷贝(得到深层复本)还是浅拷贝(得到浅层复本)。对象的深层复本是对象成员数据逐项复制 (member-wise copy)的结果:而其浅层复本则往往只是指向或引用同一个实际对象,当然,“深”和“浅”的具体含义可以有些出入。
尽管通常情况下类是有用的,但你也可能会遇到一些麻烦。下面就是一些应该避免创建的类:
1、避免创建万能类(god class)。要避免创建什么都知道、什么都能干的万能类。如果一个类把工夫都花在用 Get()方法和 Set(方法向其他类索要数据(也就是说,深入到其他类的工作中并告诉它们该如何去做)的话,请考虑是否应该把这些功能组织到其他那些类中去,而不要放到万能类里(Riel 1996)。
2、消除无关紧要的类。 如果一个类只包含数据但不包含行为的话,应该问问自己,它真的是一个类吗?同时应该考虑把这个类降级,让它的数据成员成为一个或多个其他类的属性。
3、避免用动词命名的类。 只有行为而没有数据的类往往不是一个真正的类。请考虑把类似 DatabaseInitialization(数据库初始化)或 Strin串构造器)这样的类变成其他类的一个子程序。
不同编程语言在实现类的方法上有着很有意思的差别。请考虑一下如何在一个派生类中通过覆盖成员函数来实现多态。
略