第六章.
继承和面向对象设计
面向对象的程序设计(
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”
的关系。对于基类成立的一切都应该适用于派生类,因为派生类的对象就是一个基类对象。