Shuffy

不断的学习,不断的思考,才能不断的进步.Let's do better together!
posts - 102, comments - 43, trackbacks - 0, articles - 19
【转】http://www.cppblog.com/tiandejian/archive/2008/07/06/ec_34.html

第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 对所有由它(公共)继承出的类有着深远的影响,因为

成员函数的接口总会被继承下来。就像第 12 条中所解释的,共公继承意味着“是一个”的关系,因此对于基类成立的任何东西,对于派生类也应成立。由此可知,如果一个函数应用于一个类中,那么它同样也存在于这个类的派生类中。

Shape 中声名了三个函数,第一个是 draw ,用于把当前对象绘制在一个假想的显示设备上。第二个是 error ,在成员函数需要报告错误时它将被调用。第三个是 objectID ,为当前对象返回一个标识身份的整数值。每个函数的声明方式各不相同: draw 是一个纯虚函数, error 是一个简单(非纯虚的)虚函数, objectID 是一个非虚函数。那么这些不同的声明方式的具体实现又是什么样的呢?

首先请看纯需函数 draw

class Shape {

 

public:

 

 virtual void draw() const = 0;

 

 ...

 

};

纯虚函数最为显著的两个特征是:首先,在所有具体的派生类中,必须要对它们进行重新声明;其次,在一般情况下,纯虚函数在抽象类中没有定义内容。融合以上两点我们可以看出:

定义纯虚函数的目的就是让派生类仅仅继承函数接口

对于 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++ 特征,除了作为你在鸡尾酒会上的谈资以外,似乎真正的用处很有限。然而就像你在下文中见到的一样,这一特征也有一定的用武之地,它可以为简单(非纯)虚函数提供“超常安全”的默认具体实现。

简单虚函数的背后隐藏的内情与纯虚函数有些许不同。一般情况下,派生类继承函数接口,但是简单虚函数提供了一个具体实现,派生类中可以覆盖这一实现。如果你稍加思索,你就会发现:

声明简单虚函数的目的就是:让派生类继承函数接口的同时,继承一个默认的具体实现。

请观察以下情形中的 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;

 ...

};

当一个成员函数是非虚函数时,你不应该期待它会在不同的派生类中存在不同的行为。事实上,非虚拟的成员函数确立了一个超具体化的恒量,因为它确保了无论派生类多么千变万化,其行为不能被改变。也就是说:

声明一个非虚函数的目的就是让派生类继承这一函数的接口,同时强制继承其具体实现。

你可以把 shape::objectID 的声明想象成:每个 Shape 对象都有一个函数能生成“对象身份标识”的函数。这一“对象身份标识”总是以同一方式运行。这一方式由 Shape::objectID 的定义确定,任何派生类都不能偿试更改这一方式。因为虚函数确定了超具体化的恒量,所以在任何的派生类中都不允许重定义该函数,这一点将在第 36 条中详细讲解。

纯虚函数,简单虚函数和非虚函数,不同的声明方式使你能够精确地指定你的派生类需要继承什么:是仅仅继承接口,还是同时继承接口和实现,抑或接口和强制内容的实现。由于这些不同种类的声明意味着,在基础层面存在着不同,因此你在声明成员函数时,一定要仔细斟酌。如果你这样做了,那么你将避免缺乏经验的类设计人员常犯的两类错误:

首先,第一类错误是:将所有函数都声明为纯虚的。这样做可以说断送了派生类进行拓展的后路。非虚拟的析构函数更是陷阱重重(参见第 7 条)。当然,设计一个不需要作为基类的类无可厚非,这种情况下,清一色的一组非虚函数也是合乎情理的。然而,由于人们常常忽视虚函数和非虚函数之间的差异,还有非虚函数会对性能产生影响的无端猜疑。人们对于包含虚函数的类的接受程度并不高。但事实上,几乎每个需要充当基类的类都需要虚函数的支持。(同样请参见第 7 条)

如果你谈到虚函数的性能开销问题,请允许我引用现实中提炼出的“ 80-20 法则”(同样参见第 30 条),在一个典型的程序中, 80% 的运行时间将花费在 20% 的代码上。这一法则十分重要,因为它意味着在一般情况下, 80% 的虚函数调用将不会对你的程序的整体性能造成任何影响。与其为虚函数是否会带来无法承受的性能开销而顾忌重重,还不如把精力放在程序中真正会带来影响的那 20% 上。

另一个一般的问题是:将所有的成员函数都声明为虚函数。有时候这么做是正确的——第 31 条中的接口类就是证据。然而,这样做给人的印象就是这个类的设计者缺乏主心骨。在派生类中一些函数不应该进行重定义,你必须要通过将这些函数声明为非虚函数才能确保这一点。你应该清楚,并不是让客户端程序员去重定义所有的函数,你的类就成了万能的了。如果你的类中包含超具体化的衡量,那么就应该大胆的将其声明为非虚函数。

铭记在心

接口继承与实现继承存在着不同。在公共继承体系下,派生类总是继承基类的接口。

纯虚函数要求派生类仅继承接口。

简单(非纯)虚函数要求派生类在继承接口的同时继承默认的实现。

非虚函数要求派生类继承接口和强制内容的实现。


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理