Design and Implementation Issues
有关设计和实现的问题
给类定义合理的接口,对于创建高质量程序起到了关键作用。然而,类内部的设计和实现也同样重要。这一节就来论述关于包含、继承、成员函数和数据成员、类之间的耦合性、构造函数、值对象与引用对象等的问题。
Containment (“has a” Relationships)
包含(“有一个……”的关系)
包含是一个非常简单的概念,它表示一个类含有一个基本数据元素或对象。与包含相比,关于继承的论述要多得多,这是因为继承需要更多的技巧,而且更容易出错,而不是因为继承要比包含更好。包含才是面向对象编程中的主力技术。
通过包含来实现“有一个/has a”的关系 可以把包含想成是“有一个”关系。比如说,一名雇员“有一个”姓名、“有一个”电话号码、“有一个”税收ID等。通常,你可以让姓名、电话号码和税收ID成为Employee类的数据成员,从而建立这种关系。
在万不得已时通过private继承来实现“有一个”的关系 在某些情况下,你会发现根本无法用把一个对象当做另一对象的成员的办法来实现包含关系。一些专家建议此时可采用private继承自所要包含的对象的办法(Meyers 1998、Sutter 2000)。这么做的主要原因是要让外层的包含类能够访问内层被包含类的protected成员函数与数据成员。然而在实践中,这种做法会在派生类与基类之间形成一种过于紧密的关系,从而破坏了封装性。而且,这种做法也往往会带来一些设计上的错误,而这些错误是可以用“private继承”之外的其他方法解决的。
警惕有超过约7个数据成员的类 研究表明,人们在做其他事情时能记住的离散项目的个数是7±2(Miller 1956)。如果一个类包含有超过约7个数据成员,请考虑要不要把它分解为几个更小的类(Riel 1996)。如果数据成员都是整型或字符串这种简单数据类型,你可以按7±2的上限来考虑;反之,如果数据成员都是复杂对象的话,就应按7±2的下限来考虑了。
Inheritance (“is a” Relationships)
继承(“是一个……”关系)
继承的概念是说一个类是另一个类的一种特化(specialization)。继承的目的在于,通过“定义能为两个或更多个派生类提供共有元素的基类”的方式写出更精简的代码。其中的共有元素可以是子程序接口、内部实现、数据成员或数据类型等。继承能把这些共有的元素集中在一个基类中,从而有助于避免在多处出现重复的代码和数据。
当决定使用继承时,你必须要做如下几项决策:
■ 对于每一个成员函数而言,它应该对派生类可见吗?它应该有默认的实现吗?这一默认的实现能被覆盖(override)吗?
■ 对于每一个数据成员而言(包括变量、具名常量、枚举等),它应该对派生类可见吗?
下面就来详细解释如何考虑这些事项:
用public继承来实现“是一个……”的关系 当程序员决定通过继承一个现有类的方式创建一个新类时,他是在表明这个新的类是现有类的一个更为特殊的版本。基类既对派生类将会做什么设定了预期,也对派生类能怎么运作提出了限制(Meyers 1998)。
如果派生类不准备完全遵守由基类定义的同一个接口契约,继承就不是正确的实现技术了。请考虑换用包含的方式,或者对继承体系的上层做修改。
要么使用继承并进行详细说明,要么就不要用它 继承给程序增加了复杂度,因此它是一种危险的技术。正如Java专家Joshua Bloch所说,“要么使用继承并进行详细说明,要么就不要用它。”如果某个类并未设计为可被继承,就应该把它的成员定义成non-virtual(C++)、final(Java)或non-overridable(Microsoft Visual Basic),这样你就无法继承它了。
遵循Liskov替换原则(Liskov Substitution Principle,LSP) Barbara Liskov在一篇面向对象编程的开创性论文中提出,除非派生类真的“是一个”更特殊的基类,否则不应该从基类继承(Liskov 1988)。Andy Hunt和Dave Thomas把LSP总结为:“派生类必须能通过基类的接口而被使用,且使用者无须了解两者之间的差异。”(Hunt and Thomas 2000)。
换句话说,对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的。
如果你有一个Account基类以及CheckingAccount、SavingsAccount、AutoLoan-
Account三个派生类,那么程序员应该能调用这三个Account派生类中从Account继承而来的任何一个子程序,而无须关心到底用的是Account的哪一个派生类的对象。
如果程序遵循Liskov替换原则,继承就能成为降低复杂度的一个强大工具,因为它能让程序员关注于对象的一般特性而不必担心细节。如果程序员必须要不断地思考不同派生类的实现在语义上的差异,继承就只会增加复杂度了。假如说程序员必须要记得:“如果我调用的是CheckingAccount或SavingsAccount中的InterestRate()方法的话,它返回的是银行应付给消费者的利息;但如果我调用的是AutoLoanAccount中的InterestRate()方法就必须记得变号,因为它返回的是消费者要向银行支付的利息。”根据LSP,在这个例子中AutoLoanAccount就不应该从Account继承而来,因为它的InterestRate()方法的语义同基类中InterestRate()方法的语义是不同的。
确保只继承需要继承的部分 派生类可以继承成员函数的接口和/或实现。表6-1显示了子程序可以被实现和覆盖(override)的几种形式:
表6-1 继承而来的子程序的几种形式
|
|
可覆盖的 |
不可覆盖的 |
|
提供默认实现 |
可覆盖的子程序 |
不可覆盖的子程序 |
|
未提供默认实现 |
抽象且可覆盖的子程序 |
不会用到(一个未经定义但又不让覆盖的子程序是没有意义的) |
正如此表所示,继承而来的子程序有三种基本情况:
■ 抽象且可覆盖的子程序是指派生类只继承了该子程序的接口,但不继承其实现。
■ 可覆盖的子程序是指派生类继承了该子程序的接口及其默认实现,并且可以覆盖该默认实现;
■ 不可覆盖的子程序是指派生类继承了该子程序的接口及其默认实现,但不能覆盖该默认实现。
当你选择通过继承的方式来实现一个新的类时,请针对每一个子程序仔细考虑你所希望的继承方式。仅仅是因为要继承接口所以才继承实现,或仅仅是因为要继承实现所以才继承接口,这两类情况都值得注意。如果你只是想使用一个类的实现而不是接口,那么就应该采用包含方式,而不该用继承。
不要“覆盖”一个不可覆盖的成员函数 C++和Java两种语言都允许程序员“覆盖”那些不可覆盖的成员函数。如果一个成员函数在基类中是私用(private)的话,其派生类可以创建一个同名的成员函数。对于阅读派生类代码的程序员来说,这个函数是令人困惑的,因为它看上去似乎应该是多态的,但事实上却非如此,只是同名而已。换种方法来说,本指导建议就是“派生类中的成员函数不要与基类中不可覆盖的成员函数的重名。”
把共用的接口、数据及操作放到继承树中尽可能高的位置 接口、数据和操作在继承体系中的位置越高,派生类使用它们的时候就越容易。多高就算太高了呢?根据抽象性来决定吧。如果你发现把一个子程序移到更高的层次后会破坏该层对象的抽象性,就该停手了。
只有一个实例的类是值得怀疑的 只需要一个实例,这可能表明设计中把对象和类混为一谈了。考虑一下能否只创建一个新的对象而不是一个新的类。派生类中的差异能否用数据而不是新的类来表达呢?单件(Singleton)模式则是本条指导方针的一个特例。
只有一个派生类的基类也值得怀疑 每当我看到只有一个派生类的基类时,我就怀疑某个程序员又在进行“提前设计”了——也就是试图去预测未来的需要,而又常常没有真正了解未来到底需要什么。为未来要做的工作着手进行准备的最好方法,并不是去创建几层额外的、“没准以后哪天就能用得上的”基类,而是让眼下的工作成果尽可能地清晰、简单、直截了当。也就是说,不要创建任何并非绝对必要的继承结构。
派生后覆盖了某个子程序,但在其中没做任何操作,这种情况也值得怀疑 这通常表明基类的设计中有错误。举例来说,假设你有一个Cat(猫)类,它有一个Scratch()(抓)成员函数,可是最终你发现有些猫的爪尖儿没了,不能抓了。你可能想从Cat类派生一个叫ScratchlessCat(不能抓的猫)的类,然后覆盖Scratch()方法让它什么都不做。但这种做法有这么几个问题:
■ 它修改了Cat类的接口所表达的语义,因此破坏了Cat类所代表的抽象(即接口契约)。
■ 当你从它进一步派生出其他派生类时,采用这一做法会迅速失控。如果你又发现有只猫没有尾巴该怎么办?或者有只猫不捉老鼠呢?再或者有只猫不喝牛奶?最终你会派生出一堆类似ScratchlessTaillessMicelessMilklessCat(不能抓、没尾巴、不捉老鼠、不喝牛奶的猫)这样的派生类来。
■ 采用这种做法一段时间后,代码会逐渐变得混乱而难以维护,因为基类的接口和行为几乎无法让人理解其派生类的行为。
修正这一问题的位置不是在派生类 ,而是在最初的Cat类中。应该创建一个Claw(爪子)类并让Cat类包含它。问题的根源在于做了所有猫都能抓的假设,因此应该从源头上解决问题,而不是到发现问题的地方修补。
避免让继承体系过深 面向对象的编程方法提供了大量可以用来管理复杂度的技术。然而每种强大的工具都有其危险之处,甚至有些面向对象技术还有增加——而不是降低——复杂度的趋势。
在《Object-Oriented Design Heuristics》(面向对象设计的启发式方法)(1996)这本优秀著作中,Arthur Riel建议把继承层次限制在最多6层之内。Arthur是基于“神奇数字7±2”这一理论得出这一建议的,但我仍觉得这样过于乐观了。依我的经验,大多数人在脑中同时应付超过2到3层继承时就有麻烦了。用那个“神奇数字7±2”用来限制一个基类的派生类总数——而不是继承层次的层数——可能更为合适。
人们已经发现,过深的继承层次会显著导致错误率的增长(Basili,Briand and Melo 1996)。每个曾经调试过复杂继承关系的人都应该知道个中原因。过深的继承层次增加了复杂度,而这恰恰与继承所应解决的问题相反。请牢牢记住首要的技术使命。请确保你在用继承来避免代码重复并使复杂度最小。
尽量使用多态,避免大量的类型检查 频繁重复出现的case语句有时是在暗示,采用继承可能是种更好的设计选择——尽管并不总是如此。下面就是一段迫切需要采用更为面向对象的方法的典型代码示例:
C++示例:多半应该用多态来替代的case语句
switch ( shape.type ) {
case Shape_Circle:
shape.DrawCircle();
break;
case Shape_Square:
shape.DrawSquare();
break;
...
}
在这个例子中,对shape.DrawCircle()和shape.DrawSquare()的调用应该用一个叫shape.Draw()的方法来替代,因为无论形状是圆还是方都可以调用这个方法来绘制。
另外,case语句有时也用来把种类确实不同的对象或行为分开。下面就是一个在面向对象编程中合理采用case语句的例子:
C++示例:也许不该用多态来替代的case语句
switch ( ui.Command() ) {
case Command_OpenFile:
OpenFile();
break;
case Command_Print:
Print();
break;
case Command_Save:
Save();
break;
case Command_Exit:
ShutDown();
break;
...
}
此时也可以创建一个基类并派生一些派生类,再用多态的DoCommand()方法来实现每一种命令(就像Command模式的做法一样)。但在像这个例子一样简单的场合中,DoCommand()意义实在不大,因此采用case语句才是更容易理解的方案。
让所有数据都是private(而非protected) 正如Joshua Bloch所言,“继承会破坏封装”(Bloch 2001)。当你从一个对象继承时,你就拥有了能够访问该对象中的protected子程序和protected数据的特权。如果派生类真的需要访问基类的属性,就应提供protected访问器函数(accessor function)。
Multiple Inheritance
多重继承
继承是一种强大的工具。就像用电锯取代手锯来伐木一样,当小心使用时,它非常有用,但在还没能了解应该注意的事项的人手中,它也会变得非常危险。
如果把继承比做是电锯,那么多重继承就是20世纪50年代的那种既没有防护罩、也不能自动停机的危险电锯。有时这种工具的确有用,但在大多数情况下,你最好还是把它放在仓库里为妙——至少在这儿它不会造成任何破坏。
虽然有些专家建议广泛使用多重继承(Meyer 1997),但以我的经验而言,多重继承的用途主要是定义“混合体(mixins)”,也就是一些能给对象增加一组属性的简单类。之所以称其为混合体,是因为它们可以把一些属性“混合”到派生类里面。“混合体”可以是形如Displayable(可显示),Persistant(持久化),Serializable(可序列化)或Sortable(可排序)这样的类。它们几乎总是抽象的,也不打算独立于其他对象而被单独实例化。
混合体需要使用多重继承,但只要所有的混合体之间保持完全独立,它们也不会导致典型的菱形继承(diamond-inheritance)问题。通过把一些属性夹(chunking)在一起,还能使设计方案更容易理解。程序员会更容易理解一个用了Displayable和Persistent混合体的对象——因为这样只需要实现两个属性即可——而较难理解一个需要实现11个更具体的子程序的对象。
Java和Visual Basic语言也都认可混合体的价值,因为它们允许多重接口继承,但只能继承一个类的实现。而C++则同时支持接口和实现的多重继承。程序员在决定使用多重继承之前,应该仔细地考虑其他替代方案,并谨慎地评估它可能对系统的复杂度和可理解性产生的影响。







