08有关类设计和实现的问题(类的结构关系)
一. 类内部的设计和实现
给类定义合理的接口,对于创建高质量程序起到了关键作用。然而,类内部的设计和实现也同样重要。这里主要论述关于包含、继承、成员函数和数据成员、类之间的耦合性、构造函数、值对象与引用对象等。
1. 包含(“有一个...”关系)——“has a”
包含是一个非常简单的概念,它表示一个类含有一个基本数据元素或对象。包含是面向对象编程中的主力技术。
1.1 通过包含来实现“有一个 / has a”的关系
1.2 警惕有超过约 7 个数据成员的类
2. 继承(“是一个...”关系)—— “is a”
继承的概念是说一个类是另一个类的一种特化。继承的目的在于,通过“定义能为两个或多个派生类提供共有元素的基类”的方式写出更精简的代码。其中的共有元素可以使子程序接口、内部实现、数据成员或数据类型等。继承能把这些共有的元素集中在一个基类中,从而有助于避免在多处出现重复的代码和数据。
当决定使用继承时,你必须要做如下几项决策。
- 对于每一个成员函数而言,它应该对派生类可见吗?它应该由默认的实现吗?这一默认的实现能被覆盖吗?
- 对于每一个数据成员而言(包括变量、具名常量、枚举等),它应该对派生类可见吗?
2.1 用 public 继承来实现“是一个...”的关系
当程序员决定通过继承一个现有类的方式创建一个新类时,他是在表明这个新的类是现有类的一个更为特殊的版本。基类既对派生类将会做什么设定类预期,也对派生类能怎么运作提出了限制。
如果派生类不 准备完全遵守由基类定义的同一个接口契约,继承就不是正确的实现技术了。请考虑换用包含的方式,或者对继承体系的上层做修改。
2.2 要么使用继承并进行详细说明,要么就不要用它
继承给程序增加了复杂度,因此它是一种危险的技术。“要么使用继承并进行详细说明,要么就不要用它”。
2.3 遵循 Liskov 替换原则
Barbara Liskov 在一篇面向对象编程的开创性论文中提出,除非派生类真的“是一个”更特殊的基类,否则不应该从基类继承。即“派生类必须能够通过基类的接口而被使用”,且使用者无须了解两个之间的差异。换句话说,对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的。
如果程序遵循 Liskov 替换原则,继承就能成为降低复杂度的一个强大工具,因为它能让程序员关注与对象的一般特性而不必担心细节。若谷程序员必须要不断地思考不同派生类的实现在语义上的差异,那继承就只会增加复杂度了。
2.4 确保只继承需要继承的部分
派生类可以继承成员函数的接口和/或实现。
- 抽象且可覆盖的子程序(如纯虚函数)是指派生类只继承了该子程序的接口,但不继承其实现。
- 可覆盖的子程序(如非纯虚函数)是指派生类继承了该子程序的接口及默认实现,并且可以覆盖该默认实现。
- 不可覆盖的子程序(如 override final 标识的虚函数)是指派生类继承了该子程序的接口及其默认实现,但不能覆盖该默认实现。
当你选择通过继承的方式来实现一个新的类时,请针对每一个子程序仔细考虑你所希望的继承方式.仅仅是因为要继承接口所以才继承实现,或仅仅是因为要继承实现所以才继承接口,这两种情况都值得注意.如果你只是想使用一个类的实现而不是接口,那么就应该采用包含方式,而不是继承。
2.5 不要“覆盖”一个不可覆盖的成员函数
C++ 和 Java 两种语言都允许程序员“覆盖”那些不可覆盖的成员函数。如果一个成员函数在基类中时私有的,其派生类可以创建一个同名的成员函数。对于阅读 派生类代码的程序员来说,这个函数是令人困惑的,因为它看上去似乎应该是多态的,但事实上缺非如此,只是同名而已。
2.6 把共用的接口、数据及操作放到继承树中尽可能高的位置
接口、数据和操作在继承体系中的位置越高,派生类使用它们的时候就越容易。多高就算太高了呢?根据抽象性来决定以吧。如果你发现一个子程序移到更高的层次后会破坏该层对象的抽象性,就该停手了。
2.7 只有一个实例的类是值得怀疑的
只需要一个实例,这可能表名设计中把对象和类混为一谈了。考虑一下能否只创建一个新的对象而不是一个新的类。派生类中的差异能否用数据而不是新的类来表达呢?单例模式(Singleton)则是本条指导方针的一个特例。
2.8 只有一个派生类的基类也值得怀疑
每当我看到只有一个派生类的基类时,我就怀疑某个程序员又在进行“提前设计”了 —— 也就是试图去预测未来的需要,而又常常没有真正了解未来到底需要什么。为未来要做的工作着手进行准备的最好方法,并不是去创造几层额外的、“没准以后那天就能用的上的”基类,而是让眼下的工作成果尽可能地清晰、简单、直截了当。也就是说,不要创建任何并非绝对必要的继承结构。
2.9 派生后覆盖了某个子程序,但在其中没做任何操作,这种情况也值得怀疑
这通常表明基类的设计中有错误。举例来说,假设你有一个 Cat 类,它有一个 Scratch() 成员函数,可是最终你发现有些猫的爪尖儿没了,不能抓了。你可能想从 Cat 类派生一个叫 ScratchlessCat 的类,然后覆盖 Scratch() 方法让它什么都不做。但这种做法有一下你个问题:
-
它修改了 Cat 类接口所表达的语义,因此破坏了 Cat 类所代表的抽象(即接口契约)。
-
当你从它进一步派生出其他派生类时,采用这一做法会迅速失控。如果你又发现有只猫没有尾巴了怎么办?
-
采用这种做法一段时间后,代码会逐渐变得混乱而难以维护,因为基类的接口和行为几乎无法让人理解其派生类的行为。
修正这一问题的位置不是在派生类,而是在最初的 Cat 类中。应该创建一个 Claw 类并让 Cat 类包含它。问题的根源在于做了所有猫都能抓的假设,因此应该从源头上理解这个问题,而不是到发现问题的地方修补。
2.10 避免让继承体系过深
面向对象的编程方法提供了大量可以用来管理复杂度的技术。然而每种强大的工具都有其危险之处,甚至有些面向对象技术还有增加 —— 而不是降低 —— 复杂度的趋势。
Arthur Riel 建议把继承层次限制在最多 6 层之内。 Arthur 是基于 “神奇数字 7 +- 2” 这一理论得出这一建议的,但我觉得这样过于乐观了。依我的经验,大多数人在脑中同时应付超过 2 到 3 层继承时就有麻烦了。
人们已经发现,过深的集成层次会显著导致错误率的增长。每个曾经调试过复杂继承关系的人都应该知道个中原因。过深的继承层次增加了复杂度,而这恰恰与继承所应解决的问题相反。请牢牢记住首要的技术使命。请确保你在用继承来避免代码重复并使复杂度最小。
2.11 尽量使用多态,避免大量的类型检查
频繁重复出现的 case 语句有时是在暗示,采用继承可能是中更好的设计选择 —— 尽管并不总是如此。下面就是一段迫切需要采用更为面向对象的方法的典型代码示例:
//多半应该用多态替代的 case 语句
switch (shape.type){
case Shape_Circle:
shape.DrawCircle();
break;
case Shape_Circle:
shape.DrawSquare();
break;
...
}
在这个例子中,对 shape.DrawCircle() 和 shape.DrawSquare() 的调用应该用一个叫 shape.Draw() 的方法来替代。因为无论形状是圆还是放都可以调用这个方法来绘制
另外,case 语句有时也用来把种类确实不同的对象或行为分开。下面就是一个在面向对象编程中合理采用 case 语句的例子:
//也许不该用多态来替代的 case 语句
switch (ui.command()){
case Command_OpenFile:
OpenFile();
break;
case Command_Print:
Print();
break;
case Command_Exit:
ShutDown();
break;
...
}
此时也可以创建一个基类并派生一些派生类,再用多态的 DoCommand() 方法来实现每一种命令(就像 Command 模式的做法一样)。但在项这个例子一样简单的场合中,DoCommand() 意义实在不大,因此采用 case 语句才是更容易理解的方案。
2.12 让所有数据都是 private ( 而非 protected)
正如 Joshua Bloch 所言,“继承会破坏封装”。当你从一个对象继承时,你就拥有可能够访问该对象中的 protected 数据的特权。如果派生类真的需要访问基类的属性,就应该提供 protected 访问器函数。
2.13 多重继承
继承是一种强大的工具。就像用电锯取代手锯伐木一样,当小心使用时,它非常有用,但在还没能了解应该注意的事项的人手中,他也会变得非常危险。
如果把继承比作是电锯,那么多重继承就是 20 世纪 50 年代 的那种既没有防护罩,也不能自动停机的危险电锯。有时这种工具的确有用,但在大多数情况下,你最好还是把它放在仓库里为妙 —— 至少在这儿它不会造成任何破坏。
虽然有些专家建议广泛使用多重继承,但以我个人经验而言,多重继承的用途主要是定义“混合体”,也就是一些能给对象增加一组属性的简单类。之所以称其为混合体,是因为他们可以把一些属性“混合”到派生类里面。“混合体”可以是行如 Displayable (可显示), Persistant (持久化),serializable (可序列化) 或 Sortable (可排序)这样的类。它们几乎总是抽象的,也不打算独立于其他对象而被单独实例化。
混合体需要使用多重继承,但只要所有的混合体之间保持完全独立,他们也不会导致典型的菱形继承问题。通过把一类属性夹在一起,还能使设计方案更容易理解。程序员会更容易理解一个用了 Displayable 和 Peristent 混合体的对象 —— 因为这样只需要实现两个属性即可 —— 而较难理解一个需要 11 个更具体的子程序的对象。
Java 和 VB 语言也都认可混合体的价值,因为它们允许多重继承,但只能继承一个类的实现。而 C++ 则同时支持接口和实现的多重继承。程序员在决定使用多重继承之前,应该仔细地考虑其他方案,并谨慎地评估它可能对系统的复杂度和可理解性产生的影响。
2.14 为什么有这么多关于继承的规则
这一节给出了许多规则,它们能帮你远离与继承相关的麻烦。所有这些规则背后的前台词都是在说,继承往往会让拟合程序员的首要技术使命(即管理复杂度)背道而驰。从控制复杂度的角度来说,你应该对继承持有非常歧视的态度。下面来总结一下何时可以使用继承,何时又该使用包含:
- 如果多个类共享数据而非行为,应该创建这些类可以包含的共用对象。
- 如果多个类共享行为而非数据,应该让它们从共同的基类继承而来,并在基类里定义共用的子程序。
- 如果多个类既共享数据也共享行为,应该让它们从一个共同的基类继承而来,并在基类里定义共用的数据和子程序。
- 当你想由基类控制接口时,使用继承;当你想自己控制接口时,使用包含。
二. 成员函数和数据成员
###### 1. 让类中子程序的数量尽可能少
一份针对 C++ 程序的研究发现,类里面的子程序的数量越多,则出错率也就越高。然而,也发现其他一些竞争因素产生的影响更显著,包括过深的集成体系、在一个类中调用了大量的子程序,以及类之间的强耦合等。请在保持子程序数量最少和其他这些因素之间评估利弊。
2. 禁止隐式地产生你不需要的成员函数和运算符
有时你会发现应该禁止某些成员函数 —— 比如说你想禁止赋值,或不想让某个对象被构造。你可能会觉得,既然编译器是自动生成这些运算符的,你也就只能对它们放行。当时在这种情况下,你完全可以通过把构造函数、赋值运算符或其他成员函数或运算符定义为 private,从而禁止调用方代码访问它们(把构造函数定义为 private 也是定义单件类时所有的标准技术)。
3. 减少类所调用的不同子程序的数量
一份研究发现,类里面的错误数量与类所调用的子程序的总数是统计相关的。统一研究还发现,类所用到的其他类的数量越高,其出错率也会越高。
4. 对其他类的子程序的间接调用要尽可能少
直接的关联已经够危险了。而间接的关联 —— 如 account.ContactPerson().DaytimeContactInfo().PhoneNumber() —— 往往更加危险。研究人员就此总结出了一条 “Demeter 法则”,基本上就是说 A 对象 可以任意调用它自己的所有子程序。如果 A 对象创建了一个 B 对象,它也可以调用 B 对象的任何 (公用)子程序,但是它应该避免再调用由 B 对象所提供的对象中的子程序。在前面 account 这个例子中,就是说 account.ContactPerson() 这一调用是合适的,但 account.ContactPerson().DaytimeContactInfo() 这一调用则不合适。
一般来说,应尽量减小类和类之间相互合作的范围 —— 即尽量让下面这几个数字最小。
- 所实例化的对象的种类
- 在被实例化对象上直接调用的不同子程序的数量
- 调用由其他对象返回的对象的子程序的数量
三. 构造函数
1. 如果可能,应该在所有的构造函数中初始化所有的数据成员
在所有的构造函数中初始化所有的数据成员是一个不难做到的防御式编程时实践。
2. 用私有(private)构造函数来强制实现单件模式
如果你想定义一个类,并需要强制规定它只能有唯一一个对象实例的话,可以把该类所有的构造函数都隐藏起来,然后对外界提供一个 static 的 GetInstance() 子程序来访问该类的唯一实例。
3. 优先采用深拷贝(deep copues), 除非论证可行,才采用浅拷贝(shallow copies)
在设计复杂对象时,你需要做成一项主要决策,即应为对象实现深拷贝(得到深层复本)还是浅拷贝(得到浅层复本)。对象的深层复本是对象成员数据逐项复制的结果;而其浅层复本则往往只是指向或引用同一个实例对象,当然 “深” 和 “浅” 的具体含义可以有些出入。
实现浅层复本的动机一般是为了改善性能。尽管把大型的对象复制出多份复本从美学上看十分令人不快,但这样做很少会导致显著的性能损失。某几个对象可能会引起性能问题,但众所周知,程序员们很不擅长推测真正招致问题的代码。
为了不确定的性能提高而增加复杂度是不妥的,因此,在面临选择实现深拷贝还是浅拷贝时,一种合理的方式便是优先实现深拷贝 —— 除非能够论证浅拷贝更好。
深层复本在开发和维护方面都要比浅层复本简单。实现浅拷贝除了要用到两种方法都需要的代码之外,还要增加很多代码用于引用计数、确保安全的复制对象、安全地比较对象以及安全地删除对象等。而这些代码时很容易出错的,除非你有充分地理由,否则就应该避免他们。