条款36: 区分接口继承和实现继

优质
小牛编辑
135浏览
2023-12-01

条款36: 区分接口继承和实现继承

(公有)继承的概念看起来很简单,进一步分析,会发现它由两个可分的部分组成:函数接口的继承和函数实现的继承。这两种继承类型的区别和本书简介中所讨论的函数声明和函数定义间的区别是完全一致的。

作为类的设计者,有时希望派生类只继承成员函数的接口(声明);有时希望派生类同时继承函数的接口和实现,但允许派生类改写实现;有时则希望同时继承接口和实现,并且不允许派生类改写任何东西。

为了更好地体会这些选择间的区别,看下面这个类层次结构,它用来表示一个图形程序中的几何形状:

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的巨大影响,因为:

· 成员函数的接口总会被继承。正如条款35所说明的,公有继承的含义是 "是一个" ,所以对基类成立的所有事实也必须对派生类成立。因此,如果一个函数适用于某个类,也必将适用于它的子类。

Shape类中声明了三个函数。第一个函数,draw,在某一画面上绘制当前对象。第二个函数,error,被其它成员函数调用,用于报告出错信息。第三个函数,objectID,返回当前对象的一个唯一整数标识符(条款17给出了一个怎样使用这种函数的例子)。每个函数以不同的方式声明: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),它为派生类仅提供函数接口,完全没有实现。协议类在条款34中介绍过,并将在条款43再次提及。

简单虚函数的情况和纯虚函数有点不一样。照例,派生类继承了函数的接口,但简单虚函数一般还提供了实现,派生类可以选择改写它们或不改写它们。思考片刻就可以认识到:

· 声明简单虚函数的目的在于,使派生类继承函数的接口和缺省实现。

具体到Shape::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 { ... };

为了表明所有飞机都必须支持fly函数,而且因为不同型号的飞机原则上都需要对fly有不同的实现, 所以Airplane::fly被声明为virtual。但是,为了避免在ModelA类和ModelB类中写重复的代码,缺省的飞行行为是由Airplane::fly函数提供的,ModelA和ModelB继承了这一函数。

这是典型的面向对象设计。两个类享有共同的特征(实现fly的方式),所以这一共同特征被转移到基类,并让这两个类来继承这一特征。这种设计使得共性很清楚,避免了代码重复,将来容易增强功能,并易于长期维护 ---- 所有这一切正是面向对象技术高度吹捧的。XYZ公司真得为此而骄傲。

现在假设XYZ公司发了大财,决定引进一种新型飞机,C型。C型和A型、B型有区别,特别是,飞行方式不一样。

XYZ的程序员在上面的层次结构中为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进行一个内联调用(关于内联和虚函数间的相互关系,参见条款33):

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是一个非虚函数也很重要。因为没有子类会重新定义这个函数,条款37说明了这一事实。如果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的定义决定,派生类不能改变它。" 因为非虚函数表示一种特殊性上的不变性,所以它决不能在子类中重新定义,关于这一点条款37进行了讨论。

理解了纯虚函数、简单虚函数和非虚函数在声明上的区别,就可以精确地指定你想让派生类继承什么:仅仅是接口,还是接口和一个缺省实现?或者,接口和一个强制实现?因为这些不同类型的声明指的是根本不同的事,所以在声明成员函数时一定要从中慎重选择。只有这样做,才可以避免没经验的程序员常犯的两个错误。

第一个错误是把所有的函数都声明为非虚函数。这就使得派生类没有特殊化的余地;非虚析构函数尤其会出问题(参见条款14)。当然,设计出来的类不准备作为基类使用也是完全合理的(条款M34就给出了一个你会这样做的例子)。这种情况下,专门声明一组非虚成员函数是适当的。但是,把所有的函数都声明为非虚函数,大多数情况下是因为对虚函数和非虚函数之间区别的无知,或者是过分担心虚函数对程序性能的影响(参见条款M24)。而事实上是:几乎任何一个作为基类使用的类都有虚函数(再次参见条款14)。

如果担心虚函数的开销,请允许我介绍80-20定律(参见条款M16)。它指出,在一个典型的程序中,80%的运行时间都花在执行20%的代码上。这条定律很重要,因为它意味着,平均起来,80%的函数调用可以是虚函数,并且它们不会对程序的整体性能带来哪怕一丁点可以觉察到的影响。所以,在担心是否承担得起虚函数的开销之前,不妨将注意力集中在那20%会真正带来影响的代码上。

另一个常见的问题是将所有的函数都声明为虚函数。有时这没错 ---- 比如,协议类(Protocol class)就是证据(参见条款34)。但是,这样做往往表现了类的设计者缺乏表明坚定立场的勇气。一些函数不能在派生类中重定义,只要是这种情况,就要旗帜鲜明地将它声明为非虚函数。不能让你的函数好象可以为任何人做任何事 ---- 只要他们花点时间重新定义所有的函数。记住,如果有一个基类B,一个派生类D,和一个成员函数mf,那么下面每个对mf的调用都必须工作正常:

D *pd = new D;
B *pb = pd;

pb->mf(); // 通过基类指针调用mf

pd->mf(); // 通过派生类指针调用mf

有时,必须将mf声明为非虚函数才能保证一切都以你所期望的方式工作(参见条款37)。如果需要特殊性上的不变性,就大胆地说出来吧!