面向对象编程(OOP)几乎已经风靡两个年代了,所以关于继承、派生、virtual函数等等,可能你已经有了一些经验。纵使你过去只以C编写程序,如今肯定也无法逃脱OOP的笼罩。
尽管如此,C++的OOP有可能和你原本习惯的OOP稍有不同:“继承”可以是单一继承或多重继承,每一个继承连接(link)可以是public,protected或private,也可以是virtual或non-virtual。然后是成员函数的各个选项:virtual?non-virtual?pure virtual?以及成员函数和其他语言特性的交互影响:缺省参数值与virtual函数有什么交互影响?继承如何影响 C++的名称查找规则?设计选项有哪些?如果class的行为需要修改,virtual函数是最佳选择吗?
本章对这些题目全面宣战。此外我也解释 C++各种不同特性的真正意义,也就是当你使用某个特定构件你真正想要表达的意思。例如“public继承”意味 "is-a",如果你尝试让它带着其他意义,你会惹祸上身。同样道理,virtual函数意味“接口必须被继承”,non-virtual 函数意味“接口和实现都必须被继承”。如果不能区分这些意义,会造成C++程序员大量的苦恼。
如果你了解C++各种特性的意义,你会发现,你对OOP的看法改变了。它不再是一项用来划分语言特性的仪典,而是可以让你通过它说出你对软件系统的想法。一旦你知道该通过它说些什么,移转至C++世界也就不再是可怕的高要求了。
条款32:确定你的public继承塑模出is-a关系 Make sure public inheritance models"is-a."
在《Some Must Watch While Some Must Sleep》(W.H.Freeman and Company,1974)这本书中,作者William Dement说了一个故事,谈到他曾经试图让学生记下课程中最重要的一些教导。书上说,他告诉他的班级,一般英国学生对于发生在1066年的黑斯廷斯(Hastings)战役所知不多。如果有学生记得多一些,Dement强调,无非也只是记得1066这个数字而已。然后Dement继续其课程,其中只有少数重要信息,包括“安眠药反而造成失眠症”这类有趣的事情。他一再要求学生,纵使忘了课程中的其他每一件事,也要记住这些数量不多的重要事情。Dement 在整个学期中不断耳提面命这样的话。
课程结束后,期末考的最后一道题目是:“写下你从本课程获得的一件永生不忘的事”。当Dement批改试卷,他目瞪口呆。几乎每一个人都写下 "1066"。
这就是为什么现在我要戒慎恐惧地对你声明,以 C++进行面向对象编程,最重要的一个规则是:public inheritance(公开继承)意味 "is-a"(是一种)的关系。把这个规则牢牢地烙印在你的心中吧!
如果你令class D("Derived")以public形式继承class B("Base"),你便是告诉C++编译器(以及你的代码读者)说,每一个类型为 D的对象同时也是一个类型为B的对象,反之不成立。你的意思是B比D表现出更一般化的概念,而D比B表现出更特殊化的概念。你主张“B对象可派上用场的任何地方,D对象一样可以派上用场”(译注:此即所谓Liskov Substitution Principle),因为每一个D对象都是一种(是一个)B对象。反之如果你需要一个 D对象,B对象无法效劳,因为虽然每个D对象都是一个B对象,反之并不成立。
C++对于“public继承”严格奉行上述见解。考虑以下例子:
class Person{...};
class Student: public Person {...};
根据生活经验我们知道,每个学生都是人,但并非每个人都是学生。这便是这个继承体系的主张。我们预期,对人可以成立的每一件事——例如每个人都有生日——对学生也都成立。但我们并不预期对学生可成立的每一件事——例如他或她注册于某所学校——对人也成立。人的概念比学生更一般化,学生是人的一种特殊形式。
于是,承上所述,在C++领域中,任何函数如果期望获得一个类型为Person (或pointer-to-Person或reference-to-Person)的实参,都也愿意接受一个Student对象(或pointer-to-Student或reference-to-Student):
void eat(const Person& p); //任何人都会号
void study(const Student& s); //只有学生才到校学习
Person p; //p是人
Student s; //s是学生
eat(p); //没有问题,p是人
eat(s); //没有问题,s是学生,而学生也是(is-a)人
study(s); //没有问题,s是个学生
study(p); //错误!p不是个学生
这个论点只对public继承才成立。只有当Student以public形式继承Person,C++的行为才会如我所描述。private继承的意义与此完全不同(见条款39),至于protected继承,那是一种其意义至今仍然困惑我的东西。
public继承和is-a之间的等价关系听起来颇为简单,但有时候你的直觉可能会误导你。举个例子,企鹅(penguin)是一种鸟,这是事实。鸟可以飞,这也是事实。如果我们天真地以C++描述这层关系,结果如下:
class Bird {
public:
virtual void fly(); //鸟可以飞
...
};
class Penguin: public Bird { //企鹅是一种鸟
...
};
突然间我们遇上了乱流,因为这个继承体系说企鹅可以飞,而我们知道那不是真的。怎么回事?
在这个例子中,我们成了不严谨语言(英语)下的牺牲品。当我们说鸟会飞的时候,我们真正的意思并不是说所有的鸟都会飞,我们要说的只是一般的鸟都有飞行能力。如果谨慎一点,我们应该承认一个事实:有数种鸟不会飞。我们来到以下继承关系,它塑模出较佳的真实性:
class Bird {
... //没有声明fly函数
};
class FlyingBird: public Bird {
public:
virtual void fly();
...
};
class Penguin: public Bird {
... //没有声明fly函数
};
这样的继承体系比原先的设计更能忠实反映我们真正的意思。
即便如此,此刻我们仍然未能完全处理好这些鸟事,因为对某些软件系统而言,可能不需要区分会飞的鸟和不会飞的鸟。如果你的程序忙着处理鸟喙和鸟翅,完全不在乎飞行,原先的“双classes继承体系”或许就相当令人满足了。这反映出一个事实,世界上并不存在一个“适用于所有软件”的完美设计。所谓最佳设计,取决于系统希望做什么事,包括现在与未来。如果你的程序对飞行一无所知,而且也不打算未来对飞行“有所知”,那么不去区分会飞的鸟和不会飞的鸟,不失为一个完美而有效的设计。实际上它可能比“对两者做出区隔”更受欢迎,因为这样的区隔在你企图塑模的世界中并不存在。
另有一种思想派别处理我所谓“所有的鸟都会飞,企鹅是鸟,但是企鹅不会飞,喔欧”的问题,就是为企鹅重新定义fly函数,令它产生一个运行期错误:
void error(const std::string& msg) //定义于另外某处
class Penguin: public Bird {
public:
virtual void fly(){error("Attempt to make a penguin fly!"); }
...
};
很重要的是,你必须认知这里所说的某些东西可能和你所想的不同。这里并不是说“企鹅不会飞”,而是说“企鹅会飞,但尝试那么做是一种错误”。
如何描述其间的差异?从错误被侦测出来的时间点观之,“企鹅不会飞”这一限制可由编译期强制实施,但若违反“企鹅尝试飞行,是一种错误”这一条规则,只有运行期才能检测出来。
为了表现“企鹅不会飞,就这样”的限制,你不可以为Penguin定义fly函数:
class Bird {
... //没有声明fly函数
};
class Penguin: public Bird {
... //没有声明fly函数
};
现在,如果你试图让企鹅飞,编译器会对你的背信加以谴责:
Penguin p;
p.fly(); //错误
这和采取“令程序于运行期发生错误”的解法极为不同。若以那种做法,编译器不会对 p.fly调用式发出任何抱怨。条款18说过:好的接口可以防止无效的代码通过编译,因此你应该宁可采取“在编译期拒绝企鹅飞行”的设计,而不是“只在运行期才能侦测它们”的设计。
或许你承认你对鸟类缺乏直觉,但基础几何学得不错。喔,是吗?那么我请问,正方形和矩形之间可能有多么复杂?
好,请回答这个简单的问题:class Square 应该以 public 形式继承 class 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的高度是否未曾改变
}
显然,上述的assert结果永远为真。因为makeBigger只改变r的宽度;r的高度从未被更改。
现在考虑这段代码,其中使用public继承,允许正方形被视为一种矩形:
class Square: public Rectangle {...};
Square s;
...
assert(s.width() == s.height());//这对所有正方形一定为真。
makeBigger(s); //由于继承,s是一种(is-a)矩形,所以我们可以增加其面积
assert(s.width() == s.height());//对所有正方形应该仍然为真。
这也很明显,第二个 assert结果也应该永远为真。因为根据定义,正方形的宽度和其高度相同。
但现在我们遇上了一个问题。我们如何调解下面各个assert判断式:
■ 调用makeBigger之前,s的高度和宽度相同;
■ 在makeBigger函数内,s的宽度改变,但高度不变;
■ makeBigger返回之后,s的高度再度和其宽度相同。(注意s是以by reference方式传给makeBigger,所以 makeBigger 修改的是s自身,不是s的副本。)
怎么样?
欢迎来到“public继承”的精彩世界。你在其他领域(包括数学)学习而得的直觉,在这里恐怕无法如预期般地帮助你。本例的根本困难是,某些可施行于矩形身上的事情(例如宽度可独立于其高度被外界修改)却不可施行于正方形身上(宽度总是应该和高度一样)。但是public继承主张,能够施行于base class对象身上的每件事情,每件事情唷,也可以施行于derived class对象身上。在正方形和矩形例子中(另一个类似例子是条款38的sets和lists),那样的主张无法保持,所以以public继承塑模它们之间的关系并不正确。编译器会让你通过,但是一如我们所见,这并不保证程序的行为正确。就像每一位程序员一定学过的(某些人也许比其他人更常学到):代码通过编译并不表示就可以正确运作。
不要因为你发展经年的软件直觉在与面向对象观念打交道的过程中失去效用,便心慌意乱起来。那些知识还是有价值的,但现在你已经为你的“设计”军械库加上继承(inheritance)这门大炮,你也必须为你的直觉添加新的洞察力,以便引导你适当运用“继承”这一支神兵利器。当有一天有人展示一个长达数页的函数给你看,你终将回忆起“令Penguin继承Bird,或是令Square继承Rectangle”的概念和趣味;这样的继承有可能接近事实真象,但也有可能不。
is-a并非是唯一存在于classes之间的关系。另两个常见的关系是has-a(有一个)和is-implemented-in-terms-of(根据某物实现出)。这些关系将在条款38和39讨论。将上述这些重要的相互关系中的任何一个误塑为is-a而造成的错误设计,在 C++中并不罕见,所以你应该确定你确实了解这些个“classes 相互关系”之间的差异,并知道如何在C++中最好地塑造它们。
请记住
■ “public继承”意味is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。