洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目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对所有由它(公共)继承出的类有着较强的影响,因为

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

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);

  ...

};

接口要求每个类必须要提供一个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函数实现。然而,为了避免在ModelAModelB中出现同样的代码,我们将默认的飞行行为放置在Airplane::fly中,由ModelAModelB来继承。

这是一个经典的面向对象设计方案。当两个类共享同一特征(即它们实现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

这将是一场灾难:因为此处做了一项可怕的尝试,那就是让ModelCModelAModelB的形式飞行。你将为这一尝试付出惨痛的代价。

问题的症结不在于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。诸如ModelAModelB此类需要使用默认行为的类,只需要简单地在它们的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,那么它会怎样呢?

类似于上文中介绍的flydefaultFly函数,为接口和默认实现分别提供不同函数的方法,受到了一些人的质疑。他们指出,尽管他们不怀疑将接口和默认实现分开处理的必要性,但是这样做导致一些近亲函数名字,从而污染了类名字空间。那么如何解决这一看上去自相矛盾的难题呢?我们知道纯虚函数在具体的派生类中必须得到重新声明,但是纯虚函数自身也可以有具体实现,借助这一点问题便迎刃而解。下面代码中的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被分割成了两个基本的组成部分,它的声明确定它的接口,而它的定义确定它的默认行为(派生类可以使用这一定义,但只有在现实请求的前提下才可以)。然而将flydefaultFly合并起来,你就失去了将这两个函数置于不同保护层次的能力:原先受保护的代码(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中的接口类就是证据。然而,这样做给人的印象就是这个类的设计者缺乏主心骨。在派生类中一些函数不应该进行重定义,你必须要通过将这些函数声明为非虚函数才能确保这一点。你应该清楚,并不是让客户去重定义所有的函数,你的类就成了万能的了。如果你的类中包含个性化壁垒,那么就应该大胆的将其声明为非虚函数。


时刻牢记

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

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

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

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

posted on 2008-07-06 22:58 ★ROY★ 阅读(2569) 评论(3)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【翻译】[Effective C++中文版第3版][第34条]区分清接口继承和实现继承  回复  更多评论   

[quote]首先,第一类错误是:将所有函数都声明为纯虚的。这样做可以说断送了派生类进行拓展的后路。[/quote]
这句?
2008-07-14 17:06 | HUST

# re: 【翻译】[Effective C++中文版第3版][第34条]区分清接口继承和实现继承  回复  更多评论   

支持!~
2008-10-13 15:18 | sandy

# re: 【翻译】[Effective C++中文版第3版][第34条]区分清接口继承和实现继承  回复  更多评论   

第一类错误:将所有函数声明为纯虚函数。这样做使得派生类完全没有拓展的空间。
不明白。。
2012-05-23 19:27 | 恩戴米恩

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