第34条:
区分清接口继承和实现继承
公有继承的概念看似简单,似乎很轻易就浮出水面,然而仔细审度之后,我们会发现公有继承的概念实际上包含两个相互独立的部分:函数接口的继承和函数实现的继承。二者之间的差别恰与函数声明和函数实现之间相异之处等价(本书引言中有介绍)。
假如你是一个类设计人员,某些场合下你需要使派生类仅仅继承基类成员函数的接口(声明)。而另一些时候你需要让派生类继承将函数的接口和实现都继承过来,但还期望可以覆盖继承来的具体实现。另外,你还可能会希望在派生类中继承函数的接口和实现,同时不允许覆盖任何内容。
为了获取上述三种选项的直观感受,可以参考下面的类层次结构实例,该实例用于在图形程序中表示几何形状:
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
成员函数的接口总会被继承下来。就像第
12
条中所解释的,共公继承意味着“是一个”的关系,因此对于基类成立的任何东西,对于派生类也应成立。由此可知,如果一个函数应用于一个类中,那么它同样也存在于这个类的派生类中。
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);
...
};
接口要求每个类必须要提供一个函数,以便在程序出错时调用,但是每个类都有适合自己的处理错误的方法。如果一个类并不想提供特殊的错误处理机制,那么它就可以返回调用
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
非虚函数要求派生类继承接口和强制内容的实现。