公有继承的概念看起来很简单,进一步分析,我们会发现它由两个可分的部分组成:函数接口的继承
和函数实现的继承
。
作为类的设计者,有时希望派生类只继承成员函数的接口(声明);有时希望派生类同时继承函数的接口和实现,但允许派生类改写实现;有时则希望同时继承接口和实现,并且不允许派生类改写任何东西。
为了更好地体会这些选择间的区别,看下面这个类层次结构,它用来表示一个图形程序中的几何形状:
class Shape
{
public:
virtual void draw() const = 0;
virtual void error(const string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };
纯虚函数draw使得Shape成为一个抽象类。所以,我们用户不能创建Shape类的实例,只能创建它的派生类的实例。但是,从Shape(公有)继承而来的所有类都受到Shape的巨大影响,因为:
成员函数的接口总会被继承。
公有继承的含义是 “是一个” ,所以对基类成立的所有事实也必须对派生类成立。因此,如果一个函数适用于某个类,也必将适用于它的子类。
Shape类中声明了三个函数。第一个函数,draw,在某一画面上绘制当前对象。第二个函数,error,被其它成员函数调用,用于报告出错信息。第三个函数,objectID,返回当前对象的一个唯一整数标识符。每个函数以不同的方式声明:draw是一个纯虚函数;error是一个简单的(非纯)虚函数;objectID是一个非虚函数。这些不同的声明各有什么含义呢?
首先看纯虚函数draw。纯虚函数最显著的特征是:它们必须在继承了它们的任何具体类中重新声明,而且它们在抽象类中往往没有定义。把这两个特征放在一起,就会认识到:
定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
这对Shape::draw函数来说非常有意义,因为,让所有Shape对象都可以被绘制是很合理,但Shape类无法为Shape::draw提供一个合理的缺省实现。例如,绘制椭园的算法就和绘制矩形的算法大不一样。打个比方来说,上面Shape::draw的声明就象是在告诉子类的设计者,“你必须提供一个draw函数,但我不知道你会怎样实现它。”
为一个纯虚函数提供定义也是可能的。 也就是说,你可以为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,错误
它可以被应用为一种机制,为简单的(非纯)虚函数提供 “比一般做法更安全” 的缺省实现。
有时,声明一个除纯虚函数外什么也不包含的类很有用。这样的类叫协议类(Protocol class),它为派生类仅提供函数接口,完全没有实现。
简单虚函数 的情况和纯虚函数有点不一样。照例,派生类继承了函数的接口,但简单虚函数一般还提供了实现,派生类可以选择改写它们或不改写它们。思考片刻就可以认识到:
声明简单虚函数的目的在于,使派生类继承函数的接口和缺省实现。
具体到Shape::error,这个接口是在说,每个类必须提供一个出错时可以被调用的函数,但每个类可以按它们认为合适的任何方式处理错误。如果某个类不想做什么特别的事,可以借助于Shape类中提供的缺省出错处理函数。也就是说,Shape::error的声明是在告诉子类的设计者,“你必须支持error函数,但如果你不想写自己的版本,可以借助Shape类中的缺省版本。”
实际上,为简单虚函数同时提供函数声明和缺省实现是很危险的。 想知道为什么,看看LZY航空公司的这个飞机类的层次结构。LZY公司只有两种飞机,A型和B型,而且两种机型的飞行方式完全一样。所以,LZY设计了这样的层次结构:
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 { ... };
为了表明所有飞机都必须支持fly函数,而且因为不同型号的飞机原则上都需要对fly有不同的实现, 所以Airplane::fly被声明为virtual。但是,为了避免在ModelA类和ModelB类中写重复的代码,缺省的飞行行为是由Airplane::fly函数提供的,ModelA和ModelB继承了这一函数。
这是典型的面向对象设计。两个类享有共同的特征(实现fly的方式),所以这一共同特征被转移到基类,并让这两个类来继承这一特征。这种设计使得共性很清楚,避免了代码重复,将来容易增强功能,并易于长期维护 ---- 所有这一切正是面向对象技术高度吹捧的。LY公司真得为此而骄傲。
现在假设LY公司发了大财,决定引进一种新型飞机,C型。C型和A型、B型有区别,特别是,飞行方式不一样。
LY的程序员在上面的层次结构中为C型增加了一个类,但因为急于使新型飞机投入使用,他们忘了重新定义fly函数:
class ModelC: public Airplane {
... // 没有声明fly函数
};
然后,在程序中,他们做了类似下面的事:
Airport JFK(...);// JFK是纽约市的一个机场
Airplane *pa = new ModelC;
...
pa->fly(JFK); // 调用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进行一个内联调用
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中的纯虚函数强迫ModelC提供它自己版本的fly。
class ModelC: public Airplane
{
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
ModelC飞往某一目的地的代码
}
这个方法不会万无一失(程序员还会因为 “拷贝粘贴” 而出错),但它比最初的设计可靠多了。至于Airplane::defaultFly被声明为protected,是因为它确实只是Airplane及其派生类的实现细节。使用airplane的用户只关心飞机能飞,而不会关心是怎么实现的。
Airplane::defaultFly是一个非虚函数也很重要。因为没有子类会重新定义这个函数。如果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)
{
ModelC飞往某一目的地的代码
}
这一设计和前面的几乎一样,只是纯虚函数Airplane::fly的函数体取代了独立函数Airplane::defaultFly。从本质上说,fly已经被分成两个基本部分了。它的声明说明了它的接口(派生类必须使用),而它的定义说明了它的缺省行为(派生类可能会使用,但要明确地请求)。然而,将fly和defaultFly合并后,就不再能够为这两个函数声明不同的保护级别了:本来是protected的代码(在defaultFly中)现在成了public(因为它在fly中)。
最后,来谈谈Shape的非虚函数,objectID。当一个成员函数为非虚函数时,它在派生类中的行为就不应该不同。实际上,非虚成员函数表明了一种特殊性上的不变性,因为它表示的是不会改变的行为 ---- 不管一个派生类有多特殊。所以,
声明非虚函数的目的在于,使派生类继承函数的接口和强制性实现。
可以认为,Shape::objectID的声明就是在说,“每个Shape对象有一个函数用来产生对象的标识符,并且对象标识符的产生方式总是一样的。这种方式由Shape::objectID的定义决定,派生类不能改变它。” 因为非虚函数表示一种特殊性上的不变性,所以它决不能在子类中重新定义。
理解了纯虚函数、简单虚函数和非虚函数在声明上的区别,就可以精确地指定你想让派生类继承什么:仅仅是接口,还是接口和一个缺省实现?或者,接口和一个强制实现?因为这些不同类型的声明指的是根本不同的事,所以在声明成员函数时一定要从中慎重选择。只有这样做,才可以避免我们初学者常犯的两个错误。
第一个错误是把所有的函数都声明为非虚函数。这就使得派生类没有特殊化的余地;非虚析构函数尤其会出问题。当然,设计出来的类不准备作为基类使用也是完全合理的。这种情况下,专门声明一组非虚成员函数是适当的。但是,把所有的函数都声明为非虚函数,大多数情况下是因为对虚函数和非虚函数之间区别的无知,或者是过分担心虚函数对程序性能的影响。而事实上是:几乎任何一个作为基类使用的类都有虚函数。
至于开销问题,在一个典型的程序中,80%的运行时间都花在执行20%的代码上。这条定律很重要,因为它意味着,平均起来,80%的函数调用可以是虚函数,并且它们不会对程序的整体性能带来哪怕一丁点可以觉察到的影响。所以,在担心是否承担得起虚函数的开销之前,不妨将注意力集中在那20%会真正带来影响的代码上。
另一个常见的问题是将所有的函数都声明为虚函数。有时这没错 ---- 比如,协议类就是证据。但是,这样做往往表现了类的设计者缺乏表明坚定立场的勇气。一些函数不能在派生类中重定义,只要是这种情况,就要旗帜鲜明地将它声明为非虚函数。不能让你的函数好像可以为任何人做任何事 ---- 只要他们花点时间重新定义所有的函数。记住,如果有一个基类B,一个派生类D,和一个成员函数mf,那么下面每个对mf的调用都必须工作正常:
D *pd = new D;
B *pb = pd;
pb->mf(); // 通过基类指针调用mf
pd->mf();// 通过派生类指针调用mf
有时,必须将mf声明为非虚函数才能保证一切都以你所期望的方式工作