公共继承的概念看似简单,似乎很轻易就浮出水面,然而仔细审度之后,我们会发现公共继承的概念实际上包含两个相互独立的部分:函数接口的继承和函数实现的继承。二者之间的差别恰与函数声明和函数实现之间相异之处(本书引言中有介绍)等价。
假如你是一个类设计人员,某些场合下你需要使派生类仅仅继承基类成员函数的接口(声明)。而另一些时候你需要让派生类继承将函数的接口和实现都继承过来,但还期望可以覆盖继承而来的具体实现。另外,你还可能会希望在派生类中继承函数的接口和实现,而不允许覆盖任何内容。
为了获取上述三种选项的直观感受,可以参考下面的类层次结构实例,该实例用于在图形程序中表示几何形状:
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };
Shape是一个抽象类,纯虚函数draw标示着这一点。因此客户便无法创建Shape类的实例,只能由Shape类继承出新的派生类。不过,Shape对所有由它(公共)继承出的类有着较强的影响,因为
l 成员函数的接口总会被继承下来。就像条目32中所解释的,公共继承意味着“A是一个B”的关系,因此对于基类成立的任何东西,对于派生类也应成立。由此可知,如果一个函数对某个类适用,那么它同样也适用于这个类的派生类。
Shape类中声名了三个函数,第一个是draw,用于把当前对象绘制在一个假想的显示设备上。第二个是error,在成员函数需要报告错误时它将被调用。第三个是objectID,为当前对象返回一个标识身份的整数值。每个函数的声明方式各不相同:draw是一个纯虚函数,error是一个简单(非纯虚的)虚函数,objectID是一个非虚函数。那么这些不同的声明方式的具体实现又是什么样的呢?
首先请看纯需函数draw:
class Shape {
public:
virtual void draw() const = 0;
...
};
纯虚函数最为显著的两个特征是:首先,在所有派生出的实体类中,必须要对它们进行重新声明;其次,纯虚函数在抽象类中一般没有定义内容。融合以上两点我们可以看出:
l 声明纯虚函数的目的就是让派生类仅仅继承函数接口。
对于Shape::draw函数来说上面的分析再恰当不过了,因为“所有的Shape对象必须能够绘制出来”这一要求十分合理,但是“由Shape类来提供缺省的具体实现”就显得很牵强了。比如说,绘制一个椭圆的算法与绘制一个长方形大相径庭。Shape::draw告诉具体派生类的设计者:“你必须要提供一个draw函数,但是我可不知道你要怎么去实现它。”
顺便说一句,为纯虚函数提供一个定义并没有被C++所禁止。也就是说,你可以为Shape::draw提供一套具体实现,而C++不会报错,但是在调用这种函数时,必须要加上类名:
Shape *ps = new Shape; // 错!Shape是抽象类
Shape *ps1 = new Rectangle; // 正确
ps1->draw(); // 调用Rectangle::draw
Shape *ps2 = new Ellipse; // 正确
ps2->draw(); // 调用Ellipse::draw
ps1->Shape::draw(); // 调用Shape::draw
ps2->Shape::draw(); // 调用 Shape::draw
上文所述的这一C++特征,除了作为你在鸡尾酒会上的谈资以外,似乎真正的用处很有限。然而就像你在下文中见到的一样,这一特征也有一定的用武之地,它可以为简单(非纯)虚函数提供“超常安全”的默认具体实现。
简单虚函数背后隐藏的内情与纯虚函数有些许不同。一般情况下,派生类继承函数接口,但是简单虚函数提供了一个具体实现,派生类中可以覆盖这一实现。如果你稍加思索,你就会发现:
l 声明简单虚函数的目的就是:让派生类继承函数接口的同时,继承一个默认的具体实现。
请观察以下情形中的Shape::error:
class Shape {
public:
virtual void error(const std::string& msg);
...
};
接口要求每个类必须要提供一个error函数,以便在程序出错时调用,但是每个类都有适合自己的处理错误的方法。如果一个类并不想提供特殊的错误处理机制,那么它就可以返回调用Shape中提供的默认机制。也就是说,Shape::error的声明就是告诉派生类的设计者,“你应该提供一个error函数,但是如果你不想自己编写,那么也可以借助于Shape类的默认版本。”
实践表明,允许简单虚函数同时提供函数接口和默认实现是不安全的。至于原因,你可以设想一个XYZ航空公司的航班层次结构。XYZ只有两种飞机:A型和B型,它们飞行的航线是完全一致的。于是,XYZ这样设计了层次结构:
class Airport { ... }; // 表示飞机场
class Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void Airplane::fly(const Airport& destination)
{
默认代码:使飞机抵达给定的目的地
}
class ModelA: public Airplane { ... };
class ModelB: public Airplane { ... };
此处Airplane::fly声明为虚函数,这是为了表明所有飞机必须要提供一个fly函数,同时也基于以下事实:理论上讲,不同型号的飞机需要提供不同版本的fly函数实现。然而,为了避免在ModelA和ModelB中出现同样的代码,我们将默认的飞行行为放置在Airplane::fly中,由ModelA和ModelB来继承。
这是一个经典的面向对象设计方案。当两个类共享同一特征(即它们实现fly的方式)时,我们将这一共同特征移动到一个基类中,然后由两个派生类来继承这一共同特征。这一设计方案使得共同特征显性化,避免了代码重复,为未来的更新工作提供了便利,减轻了长期维护的负担——所有的一切都是面向对象技术极力倡导的。XYZ航空公司应该感到十分骄傲了。
现在请设想:XYZ公司有了新的业务拓展,他们决定引进一款新型飞机——C型。C型飞机在某些方面与A型和B型有着本质的区别,尤其是,C型飞机的飞行方式与前两者完全不同。
XYZ的程序员将C型飞机添加进层次结构,但是由于他们急于让新型飞机投入运营,他们忘记了重定义fly函数:
class ModelC: public Airplane {
... // 没有声明任何fly函数
};
于是,在他们的代码中将会遇到下面代码中类似的问题:
Airport PDX(...); // PDX是我家附近一个飞机场
Airplane *pa = new ModelC;
...
pa->fly(PDX); // 调用了Airplane::fly!
这将是一场灾难:因为此处做了一项可怕的尝试,那就是让ModelC以ModelA和ModelB的形式飞行。你将为这一尝试付出惨痛的代价。
问题的症结不在于Airplane::fly使用默认的行为,而是在没有显式说明的情况下ModelC需要继承该行为的情况下就继承了它。幸运的是以下这一点我们很容易做到:根据需要为派生类提供默认行为,如果派生类没有显式说明,那么就不为其提供。做到这一点的秘诀是:切断虚函数的接口和默认实现之间的联系。以下是一种实现方法:
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
默认代码:使飞机抵达给定目的地
}
请注意这里的Airplane::fly是如何转变成一个纯虚函数的。它为飞行提供了接口。默认实现在Airplane类中也会出现,但是现在它是以一个独立函数的形式存在的——defaultFly。诸如ModelA和ModelB此类需要使用默认行为的类,只需要简单地在它们的fly函数中内联调用defaultFly即可(请参见条目30中介绍的关于内联和虚函数之间的联系):
class ModelA: public Airplane {
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination); }
...
};
class ModelB: public Airplane {
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination); }
...
};
对于ModelC类而言,继承不恰当的fly实现是根本不可能的,因为Airplane中的纯虚函数fly强制ModelC提供自己版本的fly。
class ModelC: public Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
使C型飞机抵达目的地的代码
}
这一方案亦非天衣无缝(程序员仍然会“复制/粘贴”出新的麻烦),但是它至少要比原始的设计方案更可靠。至于Airplane::defaultFly,由于此处它是Airplane及其派生类真实的实现。客户只需要关注飞机可以飞行,而无须理会飞行功能是如何实现的。
Airplane::defaultFly是一个非虚函数,这一点同样重要。这是因为任何派生类都不应该去重定义这一函数,这是条目36所致力于讲述的议题。如果defaultFly是虚函数,那么你将会遇到一个递归的问题:如果一些派生类忘记了重定义defaultFly,那么它会怎样呢?
类似于上文中介绍的fly和defaultFly函数,为接口和默认实现分别提供不同函数的方法,受到了一些人的质疑。他们指出,尽管他们不怀疑将接口和默认实现分开处理的必要性,但是这样做导致一些近亲函数名字,从而污染了类名字空间。那么如何解决这一看上去自相矛盾的难题呢?我们知道纯虚函数在具体的派生类中必须得到重新声明,但是纯虚函数自身也可以有具体实现,借助这一点问题便迎刃而解。下面代码中的Airplane层次结构就利用了“纯虚函数自身可以被定义”这一点:
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
...
};
void Airplane::fly(const Airport& destination)
{ // 纯虚函数的具体实现
默认代码:使飞机抵达给定的目的地
}
class ModelA: public Airplane {
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination); }
...
};
class ModelB: public Airplane {
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination); }
...
};
class ModelC: public Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
使C型飞机抵达目的地的代码
}
这一设计方案与前一个几乎是一致的。只是这里用纯虚函数Airplane::fly代替了独立函数Airplane::defaultFly。从本质上讲,这里的fly被分割成了两个基本的组成部分,它的声明确定它的接口,而它的定义确定它的默认行为(派生类可以使用这一定义,但只有在现实请求的前提下才可以)。然而将fly和defaultFly合并起来,你就失去了将这两个函数置于不同保护层次的能力:原先受保护的代码(defaultFly中的代码)现在是公共的了(因为这些代码移动到了fly中)。
最后,让我们把话题转向Shape中的非虚函数——objectID:
class Shape {
public:
int objectID() const;
...
};
当一个成员函数不是虚函数时,你不应该期待它会在不同的派生类中存在不同的行为。事实上,非虚成员函数确立了一个“个性化壁垒”,因为它确保了无论派生类在其他部分如何进行个性化,本函数所确定的行为不能被改变。也就是说:
l 声明一个非虚函数的目的就是让派生类继承这一函数的接口,同时强制继承其固定的具体实现。
你可以把shape::objectID的声明想象成:每个Shape对象都有一个函数能生成“对象身份标识”的函数。这一“对象身份标识”总是以同一方式运行。这一方式由Shape::objectID的定义确定,任何派生类都不能偿试更改这一方式。因为一个非虚函数就确定了一个“个性化壁垒”,所以在任何的派生类中都不允许重定义该函数,这一点将在条目36中详细讲解。
纯虚函数,简单虚函数和非虚函数,不同的声明方式使你能够精确地指定你的派生类需要继承什么:是仅仅继承接口,还是同时继承接口和实现,抑或接口和一套固定的实现。由于这些不同种类的声明意味着彼此在基础层面存在着不同,因此你在声明成员函数时,一定要仔细斟酌。如果你这样做了,那么你将避免缺乏经验的类设计人员常犯的两类错误:
首先,第一类错误是:将所有函数都声明为非虚的。这样做可以说断送了派生类进行拓展的后路。非虚析构函数更是陷阱重重(参见条目7)。当然,设计一个不需要作为基类的类无可厚非,这种情况下,清一色的一组非虚函数也是合乎情理的。然而,由于人们常常忽视虚函数和非虚函数之间的差异,还有对于“虚函数会对性能产生影响”的无端猜疑,导致我们的程序中充斥着过量的完全不包含虚函数的类。但事实上,几乎每个需要充当基类的类都需要虚函数的支持。(同样请参见条目7)
如果你谈到虚函数的性能开销问题,请允许我引用基于经验主义的“80-20法则”(同样参见条目30),在一个典型的程序中,80%的运行时间将花费在20%的代码上。这一法则十分重要,因为它意味着在一般情况下,80%的虚函数调用将不会对你的程序的整体性能造成任何影响。与其为虚函数是否会带来无法承受的性能开销而顾忌重重,还不如把精力放在程序中真正会带来影响的那20%上。
另一个一般的问题是:将所有的成员函数都声明为虚函数。有时候这么做是正确的——条目31中的接口类就是证据。然而,这样做给人的印象就是这个类的设计者缺乏主心骨。在派生类中一些函数不应该进行重定义,你必须要通过将这些函数声明为非虚函数才能确保这一点。你应该清楚,并不是让客户去重定义所有的函数,你的类就成了万能的了。如果你的类中包含个性化壁垒,那么就应该大胆的将其声明为非虚函数。
时刻牢记
l 接口继承与实现继承存在着不同。在公共继承体系下,派生类总是继承基类的接口。
l 纯虚函数要求派生类仅继承接口。
l 简单(非纯)虚函数要求派生类在继承接口的同时继承一个默认的实现。
l 非虚函数要求派生类继承接口和强制固定内容的实现。