读书笔记 effective c++ Item 34 区分接口继承和实现继承

 

看上去最为简单的(public)继承的概念由两个单独部分组成:函数接口的继承和函数模板继承。这两种继承之间的区别同本书介绍部分讨论的函数声明和函数定义之间的区别完全对应。

1. 类函数的三种实现

作为一个类设计者,有时候你只想派生类继承成员函数的接口(声明)。有时候你想让派生类同时继承接口和实现,但是你允许它们覆盖掉继承而来的函数实现。但有时候你却想让派生类继承一个函数的接口和实现并且不允许它们被覆盖掉

为了对这些不同的选择有一个更好的理解,考虑表示几何图形的类继承体系:

1 class Shape {
2 public:
3 virtual void draw() const = 0;
4 virtual void error(const std::string& msg);
5 int objectID() const;
6 ...
7 };
8 class Rectangle: public Shape { ... };
9 class Ellipse: public Shape { ... };

 

Shape是一个抽象类;是由纯虚函数draw所标记的。因此客户不能创建Shape类的实例,而只有它的派生类才可以。尽管如此,Shape对(public)继承自它的所有类会产生很大的影响,因为:

  • 成员函数接口总是被继承,正如Item 32中解释的,public继承意味着“is-a”,也就是对基类来说为真的任何东西对派生类来说也必须为真。因此,如果一个函数可以被应用在一个类中,它也必须能被应用到它的派生类中。

Shape类中声明了三个函数。第一个,draw,画出当前对象;第二个,error,当error需要被记录的时候被调用。第三个,objectID,为当前对象返回一个唯一的整型标识符。每个函数以一种不同的方式被声明:draw是纯虚函数;error是简单的(不是纯的)虚函数;objectID是非虚函数。这些不同声明隐藏的含义是什么呢?

 

1.1 纯虚函数

考虑第一个纯虚函数draw:

1 class Shape {
2 public:
3 virtual void draw() const = 0;
4 ...
5 };

 

纯虚函数的两个最具特色的特征是:它们必须被继承它们的任何具现类重新声明;在抽象类中它们通常情况下没有定义。将这两个特征放在一起,你就会发现:

  • 声明纯虚函数的意图是让派生类只继承函数接口

这使得Shape::draw函数是非常有意义的,因为对于所有的Shape对象来说能够被画出来是一个合理的需求,但是Shape类不能为这个函数提供合理的默认实现,比如,画一个椭圆的算法和画一个矩形的算法是不一样的。Shape::draw的声明对派生具现类的设计者说,“你必须提供一个draw函数,但是我并不知道你该如何实现它。”

顺便说一下,为一个纯虚函数提供一个定义也是可能的。也就是你可以为Shape::draw提供一个实现,C++不会发出抱怨,但是调用它的唯一方式是在函数名前加上类名限定符:

 1 Shape *ps = new Shape;     // error! Shape is abstract
 2 
 3 Shape *ps1 = new Rectangle; // fine
 4 
 5  
 6 
 7 ps1->draw();                // calls Rectangle::draw
 8 
 9 Shape *ps2 = new Ellipse;      // fine
10 
11  
12 
13 ps2->draw();        // calls Ellipse::draw
14 
15 ps1->Shape::draw(); // calls Shape::draw
16 
17 ps2->Shape::draw(); // calls Shape::draw

 

除了帮助你在鸡尾酒会上给你的程序员伙伴留下深刻印象之外,这个特性通常来说效用有限。然而,你在下面会看到,它可以作为一种机制为简单的(非纯的)虚函数提供比平常更加安全的默认实现。

1.2 非纯的虚函数

 

简单虚函数背后的故事同纯虚函数有些不太一样。通常情况下来说,派生类继承函数接口,但是简单虚函数提供了可能会被派生类覆盖的实现。如果你再想想,你会意识到:

  • 声明一个简单虚函数的目的是让派生类继承一个函数接口或者一个默认实现。

考虑Shape::error的情况:

1 class Shape {
2 public:
3 virtual void error(const std::string& msg);
4 ...
5 };

 

这个接口表明在遇到错误的时候每个类必须提供一个错误函数,但是每个类对错误如何进行处理可以自由控制。如果一个类不想做任何特殊的事情,那么调用基类Shape中error的默认实现就可以了。也就是Shape::error的声明对派生类的设计者说,“你可以支持error函数,但如果你不想自己实现,你可以使用Shape类中的默认版本。”

1.2.1 同时为简单虚函数提供函数接口和默认实现是危险的

同时为简单虚函数提供函数接口和默认实现是危险的。为什么?考虑为XYZ航空公司设计了飞机继承体系。XYZ只有两种类型的的飞机,型号A和型号B,同种飞机的飞行方式相同。因此,XYZ设计了如下的继承体系:

 1 class Airport { ... };                                                                    // represents airports
 2 
 3 class Airplane {                                                                       
 4 
 5 public:                                                                                     
 6 
 7 virtual void fly(const Airport& destination);                           
 8 
 9 ...                                                                                             
10 
11 };                                                                                             
12 
13 void Airplane::fly(const Airport& destination)                        
14 
15 {                                                                                              
16 
17 default code for flying an airplane to the given destination      
18 
19 }                                                                                              
20 
21 class ModelA: public Airplane { ... };                                         
22 
23 class ModelB: public Airplane { ... };

 

为了表示所有的飞机必须支持fly函数,还有不同型号的飞机可能需要fly的不同实现,因此Airplane::fly被声明为virtual。然而,为了防止在ModelA和ModelB中实现同一份代码,我们为Airplane::fly提供了默认实现,ModelA和ModelB可以同时继承。

 

这是典型的面向对象设计。两个类分享同一个特征(实现fly的方式),所以一般的特征都会移到基类中,然后被派生类继承。这种设计使得类的普通特性比较清晰,防止代码重复,可以促进将来的增强实现,使长期维护更加容易——这是面向对象如此受欢迎的原因,XYZ应该为此感到骄傲。

 

现在假设XYZ公司界定引入新类型的飞机,Model C。型号C和型号A和B不一样,它的飞行方式变了。

 

XYZ的程序员为Model C在继承体系中添加了新类,但是他们如此匆忙的添加新类,以至于忘了重新定义fly函数:

1 class ModelC: public Airplane {
2 
3  
4 
5 ...                                            // no fly function is declared
6 
7 };            

                         

 

在他们的代码中有类似下面的实现:

1 Airport PDX(...);                              // PDX is the airport near my home
2 
3 Airplane *pa = new ModelC;          
4 
5 ...                                                   
6 
7 
8 pa->fly(PDX); // calls Airplane::fly!

 

这会是一个灾难:型号C的飞机尝试用型号A或者型号B的飞行方式去飞行。这不是增加旅客信心的行为。

 

1.2.2 解决方法一,将默认实现分离成单独函数

这里的问题不在于Airplane::fly有默认的行为,而在于允许 Model C在没有明确说明它需要基类行为的情况下继承了基类的行为。幸运的是,很容易为派生类提供只有在它们需要的情况下才为其提供的默认行为。这个窍门断绝了虚函数接口和默认实现之间的联系。下面是实现的方法:

 1 class Airplane {
 2 public:
 3 virtual void fly(const Airport& destination) = 0;
 4 ...
 5 protected:
 6 void defaultFly(const Airport& destination);
 7 };
 8 void Airplane::defaultFly(const Airport& destination)
 9 {
10 default code for flying an airplane to the given destination
11 }

 

注意Airplane::fly已经转成了一个纯虚函数。它为飞行提供了接口。在Airplane类中同样展示出了默认实现,但是现在它是以独立函数的形式存在,defaultFly。像ModelA和ModelB这样的类如果想使用默认实现,只要在fly函数体内调用Inline函数defaultFly就可以了(Item30中有inline函数和虚函数之间交互的信息):

 1 class ModelA: public Airplane {
 2 public:
 3 virtual void fly(const Airport& destination)
 4 { defaultFly(destination); }
 5 ...
 6 };
 7 class ModelB: public Airplane {
 8 public:
 9 virtual void fly(const Airport& destination)
10 { defaultFly(destination); }
11 ...
12 };

 

对于ModelC类来说,偶然的继承fly的不正确实现将不再可能,因为Airplane中的纯虚函数强制ModelC提供它自己版本的fly。

1 class ModelC: public Airplane {
2 public:
3 virtual void fly(const Airport& destination);
4 ...
5 };
6 void ModelC::fly(const Airport& destination)
7 {
8 code for flying a ModelC airplane to the given destination
9 }

 

 

这个机制也不是十分安全的(程序员仍然能够复制粘贴而导致错误),但是它比原来的设计可靠多了。因为对于Airplane::defaultFly来说,它是protected的是因为它是Airplane和它的派生类中的实现细节。使用airplane的客户只关心它们能够起飞,而不管飞行是如何实现的。

Airplane::defaultFly是一个非虚函数同样重要。因为没有派生类可以重定义这个函数,这也是Item36所描述的真理。如果defaultFly是虚的,就会有一个循环问题:当派生类想重新定义defaultFly但是忘了会怎样? 

1.2.3 解决方法二,利用纯虚函数提供默认实现

一些人反对将函数接口和默认实现分离的想法,就像上面的fly和defaultFly一样。首先,它们意识到,繁殖出十分相关的函数名字污染了类命名空间。但是它们仍然同意将函数接口和默认实现分离。它们如何处理这种看上去矛盾的事情呢?通过利用纯虚函数必须在具现派生类中重新声明这个事实,但是它们也有可能有自己的实现。下面的例子展示了Airplane继承体系是如何利用定义纯虚函数的能力的:

 1 class Airplane {
 2 public:
 3 virtual void fly(const Airport& destination) = 0;
 4 ...
 5 };
 6 
 7 void Airplane::fly(const Airport& destination) // an implementation of
 8 { // a pure virtual function
 9 default code for flying an airplane to
10 the given destination
11 }
12 class ModelA: public Airplane {
13 public:
14 virtual void fly(const Airport& destination)
15 { Airplane::fly(destination); }
16 ...
17 };
18 class ModelB: public Airplane {
19 public:
20 virtual void fly(const Airport& destination)
21 { Airplane::fly(destination); }
22 ...
23 };
24 class ModelC: public Airplane {
25 public:
26 virtual void fly(const Airport& destination);
27 ...
28 };
29 void ModelC::fly(const Airport& destination)
30 {
31 code for flying a ModelC airplane to the given destination
32 }

 

这个设计同前面的设计是基本相同的,除了纯虚函数体Airplane::fly代替了独立函数Airplane::defaultFly。从本质上来说,fly已经被分成了两个基本的组件。它的声明指定了接口(派生类必须使用它),同时它的定义指定了默认行为(派生类可能会使用,但是只有在显示的请求的时候才会使用)。将fly和defaultFly合并到一起,你就会失去为两个函数提供不同保护级别的能力:过去是protected的代码(在defaultFly中)现在变成了public的(因为它在fly中)。

 

1.3 非虚函数

最后,让我们看一看Shape的非虚函数,objectID:

1 class Shape {
2 public:
3 int objectID() const;
4 ...
5 };

 

 

当一个成员函数是非虚的,就不想其在派生类中有不同的行为。事实上,一个非虚成员函数指定了一种超越特化的不变性(invariant over specialization),无论一个派生类被如何特化,它的行为不可改变。

  • 声明一个非虚函数的意图在于让派生类继承一个函数接口,并且有一个强制的实现

你可以将Shape::objectID的声明想象成如下,“每一个Shape对象都有一个函数来产生一个对象标识符,这个对象标识符以相同的方式计算出来。计算方式由Shape::objectID的定义来决定,任何派生类都不应该尝试去修改它的定义”。因为一个非虚函数确定了一个超越特化的不变性,它永远不会在派生类中被定义,这一点将在Item36中进行讨论。 

 

2. 类设计者容易犯的两种错误

对纯虚函数,简单虚函数和非虚函数进行声明的不同点在于允许你精确的指定派生类会继承什么:只继承接口,继承接口和默认实现或者接口和强制实现。因为从根本上来说这些不同的声明类型意味着不同的东西,在你声明成员函数的时候你必须在他们之间进行选择。如果你这么做了,你就应该能够避免没有经验的类设计者才会犯的两种普通错误。

 2.1 错误一,将所有函数声明为非虚

第一种错误是将所有函数声明成非虚。这没有给派生类的特化留下任何余地;特别对于非虚析构函数来说是有问题的(Item 7)。当然,我们有足够的理由设计一个不被当作基类的类,在这种情况下,只声明非虚函数是合适的。然而通常情况下,这些类在下面两种情况下被创建出来:要么是忽略了虚函数和非虚函数的区别,要么就是过度担心虚函数所花费的开销。事实是基本上任何被用作基类的类都会使用虚函数。(Item 7

 

如果你关心虚函数的开销,允许我拿出80-20法则(Item 30也提到了),它表明了在一个典型的程序中,20%的代码会花费80%的运行时间。这个法则很重要,因为它意味着,平均来说,你的程序中的80%的函数调用可以是虚函数调用,但对你的程序的性能影响却是很轻微的。在你对能否负担的起虚函数的开销进行担心之前,确保你所关注的代码是对程序有重大影响的20%的那一部分。

 

 2.1 错误二,将所有函数声明为虚函数

另外一个普通的问题是将所有成员函数声明成虚函数。有时候这么做是对的——Item 31中的接口类就是这么做的。然而,这也是一个类设计者缺乏坚定立场的标志。一些函数不应该在派生类中被重定义,当碰到这种情况,你就应该把这个函数定义为非虚。不是说只要花费一点时间对函数进行重定义,就能使使类满足所有人的需求。如果你需要特化上的不变性,不要害怕说不!

3. 总结

    • 接口继承不同于实现继承。在public继承下,派生类总是会继承基类接口。
    • 纯虚函数只是指定了接口继承。
    • 简单虚函数指定了接口继承外加一个默认实现。
    • 非虚函数指定了一个接口继承外加一个强制实现。
posted @ 2017-03-20 22:26 HarlanC 阅读(...) 评论(...) 编辑 收藏