第六章. 继承和面向对象设计
面向对象的程序设计( OOP )风靡计算机软件界已经有 20 个年头了,因此,你或多或少会与继承、派生以及虚函数这些事物有所接触。即使你仅仅使用 C 语言编程,那你也难以彻底逃离 OOP 的大气候。
然而, C++ 中的 OOP 与你过去常见的可能有所不同。继承可以是单一的也可以是多重的,同时每个继承链接可以是公 有的 (public) ,可以是受保护的 (protected) ,也可以是私有的 (private) 。每个链接也可以分为虚拟 (virtual) 和非虚拟两种。另外,成员函数也有选择的范围:虚拟的?非虚拟的?纯虚拟的?此外,与其他语言特征进行交互也有需要考虑的问题:默认参数值是如何与虚函数相交互的?继承是如何影响 C++ 的名字查找规则的?还有设计的问题:如果需要将一个类设计成可修改的,那么虚函数是实现这一特性的最优途径吗?
本章就会针对这些问题为大家一一道来。而后,我还将向大家解释 C++ 种特殊功能的真正含义。——也就是当你使用一种特定的构造方式时,你真正表达出的内容。比如说,公共继承意味着“ A 是一个 B ”,倘若你让其表示其它的含义,那么你就会惹上麻烦。类似地,一个虚函数意味着“接口必须被继承”,然而一个非虚函数则意味着“接口和实现必须都被继承。”一个 C++ 程序员如果不能恰当的区分这些内容的含义,那么他的编程生涯就会显得步履维艰。
如果你能够深入了解 C++ 中浩瀚特征的方方面面,你将发现你对 OOP 的见解将有质的飞跃。你的见解将决定你对软件系统的整体认识。一旦你对 C++ 的认识变得全面而成熟了,此时你的 C++ 之路将变得一马平川。
第32条: 确保公共继承以“ A 是一个 B ”形式进行
威廉迪蒙在他的书《一些人睡去,而另一些人必须》( W. H. Freeman and Company , 1974 )中讲述了这样一个故事:他在课堂上尝试让他的学生记住他课程中最重要的那一部分。他和他的学生讲,据说普通的英国学生仅仅能记得黑斯廷斯战役发生于 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 类型参数的函数同样能够接受 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++ 才会按刚才米阿术的情景运行。私有继承则意味着派生出的某些内容是全新的。受保护的继承今天暂且不谈。
公共继承和“ 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” 的关系。对于基类成立的一切都应该适用于派生类,因为派生类的对象就是一个基类对象。