向左右向右走 —— 小时了了的技术博客

关注C++开发技术、架构设计、软件项目管理、软件产品管理等

有一种普遍的说法是把封装、继承和多态并称为面向对象的三大特征。如果你很熟悉C++并且对面向对象思想有过一些思考,那么很可能对这个说法有过怀疑,面向对象思想在本质上认为世界是由对象构成的,和面向过程是世界观的不同,而所谓的三大特征实际和面向对象的思想本质没有半毛钱的关系,准确的表述应该是封装、继承和多态是C++相对于C的三大特征。如果你碰巧了解一点C++编译器可能会发现封装也好,继承、多态也好都只是语法糖,技巧层面的东西而已,和思想无关。

以上为废话。

 

本文主要就C++的继承机制进行一些讨论。很多C++教材在讲到继承时喜欢利用几何上的一些概念,比如对如下的集合关系进行建模:

 

         在一次内部技术培训的时候我提出这个问题,结果大家都沉默不语,于是我特意找了个新手程序员回答,他给出了我预料之中的答案:以四边形作为基类,矩形和正方形依次继承下来。没人表示同意也没人表示反对,可能所有的人第一反应得出的都是这个方案,但老鸟程序员会马上察觉出其中的不妥,即使他给不出更好的方案。

         实际上在没有给定需求场景的情况下你永远无法设计出一个类,更不要说设计一组类和这些类的层次关系。很多教材都有这个毛病,上来就设计StudentTeacher而没说要完成的功能是什么,即使是做了许多年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++中引入继承机制的目的是什么,大部分资料对此都语焉不详,但是无论如何至少有一半的目的是为了组织和复用代码,继承扩展是很常用的手法,在MFCWTL等框架中到处可以看到这样的代码。但是在实际的应用中一定要避免单纯为了复用代码而使用继承机制,典型的案例比如窗口和控件。

窗口和控件是两种完全不同的东西,微软为了复用消息机制把两个概念硬是揉在了一起,所有的控件都从窗口继承下来,这直接导致了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 ZhangTodd Wei两位园友,也感谢其他几位参与讨论的园友。

 

参考资料:

  CSDN上关于C++继承的深入讨论:继承是什么和应该是什么

  百度百科:里氏代换原则

 

本文地址: http://www.cnblogs.com/xrunning/archive/2011/10/17/2214487.html

 

posted on 2011-10-17 09:08  小时了了  阅读(1837)  评论(17编辑  收藏  举报