威廉 德门特在他的著作《几人留守几人眠》中讲述了这样一个故事:他在课堂上尝试让他的学生记住课程中最重要的那一部分。他和他的学生讲,据说普通的英国学生仅仅能记得黑斯廷斯战役发生于1066年。德门特强调,如果有一个学生只记住了一点点,他(她)记住的便是1066这个年号。德门特继续讲,对于他的课堂上的学生,只有几条核心的信息,其中还包括一条有趣的结论——“安眠药最终会导致失眠”。他请求他的学生们一定要记住这些核心信息,即使把课堂中讨论的所有其他的内容都忘光也可以,整个学期他都为学生反复重复这些核心信息。
在学期末,期末考试的最后一道题是“请写下这一学期中让你铭记一生的一件东西。”当德门特阅卷的时候,差点儿没昏过去。几乎所有的学生不约而同地写下了“1066”。
因此,我“诚惶诚恐”地向各位讲述C++面向对象编程中最为重要(没有之一)的一条原则:公共继承意味着“A是一个B”关系。这条原则一定要深深的印在脑子里。
如果你编写了B类(Base,基类),并编写了由其派生出的D类(Derived,派生类),那么你就告诉了C++编译器(以及代码的读者),每一个D类型的对象同时也是B类型的,但是反过来不成立。你要表达的是:B表示比D更加一般化的内容,而D则表示比B更加具体化的内容。另外我们还强调如果一个B类型的对象可以在某处使用时,那么D类型的对象一定可以在此使用。这是因为D类型的每个对象一定是B类型的。反之,如果某一刻你需要一个D类型的对象,那么一个B类型的对象则不一定能满足要求:每个D都是一个B,但反之不然。
C++严格按上述方式解释公共继承。请参见下面的示例:
class Person {...};
class Student: public Person {...};
我们从生活的经验中可以得知:每个学生都是一个人,但是并不是每个人都是学生。上面的代码精确的体现了这一层次结构。我们期望“人”的每一条属性对“学生”都适用(比如人有出生日期,学生也有)。但是对学生能成立的属性对于一般的人来说并不一定成立(比如一个学生被某所大学录取了,但不是每个人都会去上大学)。人的概念比学生的概念更加宽泛,而学生是一类特殊的人。
在C++领域中,一切需要使用Person类型参数(或指向Person的指针或引用)的函数同样能够接受Student对象(或指向Student的指针或引用):
void eat(const Person& p); // 人人都会吃饭
void study(const Student& s); // 只有学生会学习
Person p; // p是一个人
Student s; // s是一个学生
eat(p); // 正确,p是一个人
eat(s); // 正确, s是一个学生,
// 同时一个学生是一个人
study(s); // 正确
study(p); // 错误!p不一定是学生
这一点仅仅在公共继承的情况下成立。只有在Student是由Person公共派生而来时,C++才会按刚才描述的情景运行。私有继承将引入一个全新的议题(我们在条目39中讨论)。受保护的继承我至今也没有弄明白,暂且不谈。
公共继承和“A是一个B”的等价性听上去很简单,但是有时你的直觉会误导你。比如说,“企鹅是鸟”这是千真万确的,同时,“鸟会飞”也是不争的事实。如果我们在C++中如此幼稚地表述这一情景,那么我们将得到:
class Bird {
public:
virtual void fly(); // 鸟会飞
...
};
class Penguin:public Bird { // 企鹅是鸟
...
};
瞬间我们陷入泥潭,因为这一层次结构中,企鹅竟然会飞!这显然是荒谬的。那么问题出在哪里呢?
这种情况下,我们成为了一种不精确的语言——英语的受害者。当我们说“鸟类能够飞行”时,我们的意思并不是说所有的鸟类都会飞。在一般情况下,只有拥有飞行能力的鸟类才能够飞。假如我们的语言更加精确些,我们就能认识到世界上还存在着一些不会飞的鸟类,我们也就能构建出下面的层次结构,这样的机构才更加贴近真实世界:
class Bird {
... // 不声明任何飞行函数
};
class FlyingBird: public Bird {
public:
virtual void fly();
...
};
class Penguin: public Bird {
... // 不声明任何飞行函数
};
这一层次结构比原先设计的更加忠实于我们所了解的世界。
到目前为止,上文的飞禽问题尚未彻底明了,因为在一些软件系统中,区分鸟类是否可以飞行这项工作是没有意义的,如果你的程序主要是关于鸟类的喙和翅膀,而与飞行没有什么关系,那么原先的2个类的层次结构就可以满足要求了。这里也很清晰的反映出了这一哲理:凡事并不存在一劳永逸的解决方案。对软件系统而言,最好的设计一定会考虑到这个系统是用来做什么的,无论是现在还是未来,如果你的程序对飞行的问题一无所知,并且也不准备去了解,那么忽略飞行特性的设计方案很可能就是完美的。事实上,这样做要比将两者区分开的设计方案更好些,因为你正在模拟的世界中很可能不会存在这一机制。
对于解决上文中的“白马非马”问题,还存在另外一个思考方法。那就是为企鹅重新定义fly函数,从而让其产生一个运行时错误:
void error(const std::string& msg); // 定义的内容在其他地方
class Penguin: public Bird {
public:
virtual void fly() { error("尝试让一只企鹅飞行!”);}
...
};
一定要认识到:这样做不一定能达到预期效果。因为这并不是说“企鹅不会飞”,而是说“企鹅会飞,但是在它尝试飞行时出错了”。这一点很重要。
如何找出两者的区别呢?我们从捕获错误的时机入手:“企鹅不会飞”的指令可以由编译器做出保证,但是对于“企鹅在尝试飞行时出错”这一规则的违背只能够在运行时捕获。
为了表达这一约束,“企鹅不能飞行——句号”,你要确认企鹅对象一定没有飞行函数定义:
class Bird
... // 不声明任何飞行函数
};
class Penguin: public Bird {
... // 不声明任何飞行函数
};
现在,如果你尝试让一个企鹅飞行,那么编译器将对你的侵犯行为做出抗议:
Penguin p;
p.fly(); // 错误!
如果你适应了“产生运行时错误”的方法,上文代码的行为则与你所了解的大相径庭。使用上文中的方法,编译器不会对p.fly的调用做出任何反应。条目18中解释了好的接口设计能够防止非法代码得到编译。因此你最好使用在编译室拒绝企鹅尝试飞行的设计方案,而不是仅仅在运行时捕获错误。
可能你承认你的鸟类学知识并不丰富,但对于初级几何你还是有信心的吧,让我们拿长方形和正方形再举一个例子,这没有什么复杂的吧?
好,请回答一个简单的问题:Square(正方形)类是否应该公共继承自Rectangle(长方形)类?
你会说:“当然可以了!正方形就是一个长方形,这地球人都知道。但反过来就不成立了。”这一点至少在学校里是正确的,但我们都已经不是小学生了,请考虑下面的代码:
class Rectangle {
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int height() const; // 返回当前值
virtual int width() const;
...
};
void makeBigger(Rectangle& r) // 增加r面积的函数
{
int oldHeight = r.height();
r.setWidth(r.width() + 10); // 为r的宽增加10
assert(r.height() == oldHeight); // 断言r的高不变
}
显然地,这里的判断永远不会失败,makeBigger仅仅改变了r的宽,它的高始终没有改变。
现在请观察下面的代码,其中使用了公共继承,从而使得正方形得到与长方形一致的待遇:
class Square: public Rectangle {...};
Square s;
...
assert(s.width() == s.height()); // 这对所有的正方形都成立
makeBigger(s); // 根据继承关系,s是一个长方形
// 因此我们可以增加它的面积
assert(s.width() == s.height()); // 对所有的正方形也应成立
第二次判断同样不应该出错,这也是十分明显的。因为正方形的定义要求长宽值永远相等。
但是现在我们又遇到了一个问题,我们如何协调下面的断言呢?
l 在调用makeBigger之前,s的高与宽相等;
l 在makeBigger内部,s的宽值该变了,但是高没有;
l 从makerBigger返回后,s的高与宽又相等了。(请注意:s是通过引用传入makeBigger中的,因此makeBigger改变的是s本身,而不是s的副本。)
欢迎来到公共继承的美妙世界。在这里,你在其他领域(包括数学)所积累的经验也许不会按部就班地奏效。这种情况下最基本的问题就是:一些对长方形可用的属性(它的长、宽可以分别修改),对于正方形而言并不适用(它的长、宽必须保持一致)。但是,公共继承要求对基类成立的一切对于派生类同样应该能够成立——一切的一切!这种情况下,长方形和正方形(以及条目38中集合、线性表)的实例都会遇到问题。因此,使用公共继承来构建它们之间的关系显然是错误的。编译器会允许你这样做,但是就像我们刚刚所看到的一样,我们无法确保代码是否能够按要求运行。这件事每一位程序员一定深有体会(往往比其他行业的人要深得多),因为许多情况下代码能够通过编译并不意味着它能够正常运行。
在我们投入面向对象程序设计的怀抱时,多年积累的编程经验难道成为了我们的绊脚石吗?这一点你无需顾虑。旧有的知识依然是宝贵的,只是既然你已经把继承的概念添加进你大脑中的设计方案库中,你就应该以全新的眼光来开拓自己的感官世界,从而使你在面对包含继承的程序时不会迷失方向。假如有人向你展示了一个几页长的程序,你也可以从企鹅继承自鸟类、正方形继承自长方形这些示例所包含的理念中,找出同样有趣的东西。这有可能是完成工作的正确途径,只是这个可能性并不大。
类间的关系并不仅限于“A是一个B”关系。另外还存在两个内部类关系,它们是:“A拥有一个B”、“A是以B的形式实现的”。这些关系将在条目38和条目39中讲解。由于人们往往会将上面两种关系其中之一错误地构造成“A是一个B”,因此随之带来的C++设计错误比比皆是,所以你应该确保对于这些关系之间的区别有着充分的理解,这样你才能在C++中分别对这些关系做出最优秀的构造。
时刻牢记
l 公共继承意味着“A是一个B”的关系。对于基类成立的一切都应该适用于派生类,因为派生类的对象就是一个基类对象。