有一种普遍的说法是把封装、继承和多态并称为面向对象的三大特征。如果你很熟悉C++并且对面向对象思想有过一些思考,那么很可能对这个说法有过怀疑,面向对象思想在本质上认为世界是由对象构成的,和面向过程是世界观的不同,而所谓的三大特征实际和面向对象的思想本质没有半毛钱的关系,准确的表述应该是封装、继承和多态是C++相对于C的三大特征。如果你碰巧了解一点C++编译器可能会发现封装也好,继承、多态也好都只是语法糖,技巧层面的东西而已,和思想无关。
以上为废话。
本文主要就C++的继承机制进行一些讨论。很多C++教材在讲到继承时喜欢利用几何上的一些概念,比如对如下的集合关系进行建模:
在一次内部技术培训的时候我提出这个问题,结果大家都沉默不语,于是我特意找了个新手程序员回答,他给出了我预料之中的答案:以四边形作为基类,矩形和正方形依次继承下来。没人表示同意也没人表示反对,可能所有的人第一反应得出的都是这个方案,但老鸟程序员会马上察觉出其中的不妥,即使他给不出更好的方案。
实际上在没有给定需求场景的情况下你永远无法设计出一个类,更不要说设计一组类和这些类的层次关系。很多教材都有这个毛病,上来就设计Student、Teacher而没说要完成的功能是什么,即使是做了许多年C++之后再回过头来看那些例子还是晕忽忽的,何况初学者,——当然这也许仅仅是因为我自己太笨。
我们设定两个简单的需求取边长和计算面积,暂时不考虑继承关系而分别实现三个类,那么它们是下面这个样子的:
1 class Quadrangle
2 {
3 public:
4 int GetSideLength(int index);
5 int GetArea(void);
6
7 private:
8 int m_arrSlide[4];
9 };
10
11 class Rectangle
12 {
13 public:
14 int GetWidth(void);
15 int GetHeight(void);
16 int GetArea(void);
17
18 private:
19 int m_nWidth;
20 int m_nHeight;
21 };
22
23 class Square
24 {
25 public:
26 int GetWidth(void);
27 int GetArea(void);
28
29 private:
30 int m_nWidth;
31 };
显然,正方形四边形Square 的实现最简单,四边形Quadrangle的实现最复杂(知道四条边长能确定唯一的四边形吗?)。继承机制有一个特点:派生类总是比基类更复杂,因为派生类是在完整的继承了基类实现的基础上增加新的成员、方法。由四边形到矩形再到正方形却是越来越简单,这就形成了一个悖论,导致我们无法按照继承的层次描述三者的关系。
一个老到的程序员会告诉你最好分别实现三个类,不考虑三个者之间的关系,这在大部分场景中是可行的。如果确实需要描述三者之间的层次关系,我能想到的最好的方式是使用接口:
接口用来描述层次关系,各个类独立实现。由此也可以看出,虽然C++中的接口是用纯虚类继承实现的,但实际上接口机制和继承机制是两种完全不同的东西。
到现在为止,我们可以得出结论:继承机制实际上很难描述现实概念的层次关系,这是它的局限性。对继承的应用很多情况下并不是为了描述真实概念的层次关系,而只是组织代码的一种形式。比如可以尝试下用继承关系描述一棵进化树,你会发现这个基本上很难。
在C++中引入继承机制的目的是什么,大部分资料对此都语焉不详,但是无论如何至少有一半的目的是为了组织和复用代码,继承扩展是很常用的手法,在MFC、WTL等框架中到处可以看到这样的代码。但是在实际的应用中一定要避免单纯为了复用代码而使用继承机制,典型的案例比如窗口和控件。
窗口和控件是两种完全不同的东西,微软为了复用消息机制把两个概念硬是揉在了一起,所有的控件都从窗口继承下来,这直接导致了GUI框架的高复杂度和难以扩展。
代码复用只是良好设计的副产品而不应该是设计本身的目的。
最后总结一下本文的核心观点:
1. 继承机制有很大的局限性,难于描述现实概念的层次关系;
2. 使用继承时避免生搬硬套现实概念的层次关系;
3. 避免单纯以代码复用为目的使用继承。
修改记录:
2011-11-09:
园友Todd Wei在留言中指出和本文最相关的理论是里氏代换原则,于是查了查相关资料,从百度百科摘录如下:
里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
…… 我们让正方形从长方形继承,然后在它的内部设置width等于height,这样,只要width或者height被赋值,那么width和height会被同时赋值,这样就保证了正方形类中,width和height总是相等的.现在我们假设有个客户类,其中有个方法,规则是这样的,测试传入的长方形的宽度是否大于高度,如果满足就停止下来,否则就增加宽度的值。现在我们来看,如果传入的是基类长方形,这个运行的很好。根据LSP,我们把基类替换成它的子类,结果应该也是一样的,但是因为正方形类的width和height会同时赋值,这个方法没有结束的时候,条件总是不满足,也就是说,替换成子类后,程序的行为发生了变化,它不满足LSP。
可见使用里氏代换原则(LSP)进行判断更加简单易行。不过仔细考虑一下LSP似乎也有其适用局限,比如本文给出的示例代码中实际上是可以用派生类替换掉基类的(都是只读方法)所以这个判断失效;另外很多时候我们设计继承层次只是出于管理方便(比如只是为了把他们放入同一个一个数组)而不是为了“使用”,这个时候基类有很强的抽象性(但不一定是抽象类),也会导致基于LSP的判断失效。
综合各位园友的评论,关于继承的适用性准确描述是:继承适合描述“抽象到具体”的逻辑关系,不适合描述从“父集到子集”的集合逻辑关系。另外值得一提的是,接口继承和实现继承在C++中看起来差不多,但实际上完全不是一回事儿。
这里特别感谢一下Bright Zhang、Todd Wei两位园友,也感谢其他几位参与讨论的园友。
参考资料:
CSDN上关于C++继承的深入讨论:继承是什么和应该是什么
百度百科:里氏代换原则
本文地址: http://www.cnblogs.com/xrunning/archive/2011/10/17/2214487.html