读书笔记 effective c++ Item 34 区分接口继承和实现继承
看上去最为简单的(public)继承的概念由两个单独部分组成:函数接口的继承和函数模板继承。这两种继承之间的区别同本书介绍部分讨论的函数声明和函数定义之间的区别完全对应。
1. 类函数的三种实现
作为一个类设计者,有时候你只想派生类继承成员函数的接口(声明)。有时候你想让派生类同时继承接口和实现,但是你允许它们覆盖掉继承而来的函数实现。但有时候你却想让派生类继承一个函数的接口和实现并且不允许它们被覆盖掉。
为了对这些不同的选择有一个更好的理解,考虑表示几何图形的类继承体系:
class Shape {
public:
virtual void draw() const = ;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
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:
class Shape {
public:
virtual void draw() const = ;
...
};
纯虚函数的两个最具特色的特征是:它们必须被继承它们的任何具现类重新声明;在抽象类中它们通常情况下没有定义。将这两个特征放在一起,你就会发现:
- 声明纯虚函数的意图是让派生类只继承函数接口
这使得Shape::draw函数是非常有意义的,因为对于所有的Shape对象来说能够被画出来是一个合理的需求,但是Shape类不能为这个函数提供合理的默认实现,比如,画一个椭圆的算法和画一个矩形的算法是不一样的。Shape::draw的声明对派生具现类的设计者说,“你必须提供一个draw函数,但是我并不知道你该如何实现它。”
顺便说一下,为一个纯虚函数提供一个定义也是可能的。也就是你可以为Shape::draw提供一个实现,C++不会发出抱怨,但是调用它的唯一方式是在函数名前加上类名限定符:
Shape *ps = new Shape; // error! Shape is abstract Shape *ps1 = new Rectangle; // fine ps1->draw(); // calls Rectangle::draw Shape *ps2 = new Ellipse; // fine ps2->draw(); // calls Ellipse::draw ps1->Shape::draw(); // calls Shape::draw ps2->Shape::draw(); // calls Shape::draw
除了帮助你在鸡尾酒会上给你的程序员伙伴留下深刻印象之外,这个特性通常来说效用有限。然而,你在下面会看到,它可以作为一种机制为简单的(非纯的)虚函数提供比平常更加安全的默认实现。
1.2 非纯的虚函数
简单虚函数背后的故事同纯虚函数有些不太一样。通常情况下来说,派生类继承函数接口,但是简单虚函数提供了可能会被派生类覆盖的实现。如果你再想想,你会意识到:
- 声明一个简单虚函数的目的是让派生类继承一个函数接口或者一个默认实现。
考虑Shape::error的情况:
class Shape {
public:
virtual void error(const std::string& msg);
...
};
这个接口表明在遇到错误的时候每个类必须提供一个错误函数,但是每个类对错误如何进行处理可以自由控制。如果一个类不想做任何特殊的事情,那么调用基类Shape中error的默认实现就可以了。也就是Shape::error的声明对派生类的设计者说,“你可以支持error函数,但如果你不想自己实现,你可以使用Shape类中的默认版本。”
1.2.1 同时为简单虚函数提供函数接口和默认实现是危险的
同时为简单虚函数提供函数接口和默认实现是危险的。为什么?考虑为XYZ航空公司设计了飞机继承体系。XYZ只有两种类型的的飞机,型号A和型号B,同种飞机的飞行方式相同。因此,XYZ设计了如下的继承体系:
class Airport { ... }; // represents airports class Airplane { public: virtual void fly(const Airport& destination); ... }; void Airplane::fly(const Airport& destination) { default code for flying an airplane to the given destination } class ModelA: public Airplane { ... }; 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函数:
class ModelC: public Airplane { ... // no fly function is declared };
在他们的代码中有类似下面的实现:
Airport PDX(...); // PDX is the airport near my home Airplane *pa = new ModelC; ... pa->fly(PDX); // calls Airplane::fly!
这会是一个灾难:型号C的飞机尝试用型号A或者型号B的飞行方式去飞行。这不是增加旅客信心的行为。
1.2.2 解决方法一,将默认实现分离成单独函数
这里的问题不在于Airplane::fly有默认的行为,而在于允许 Model C在没有明确说明它需要基类行为的情况下继承了基类的行为。幸运的是,很容易为派生类提供只有在它们需要的情况下才为其提供的默认行为。这个窍门断绝了虚函数接口和默认实现之间的联系。下面是实现的方法:
class Airplane {
public:
virtual void fly(const Airport& destination) = ;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
default code for flying an airplane to the given destination
}
注意Airplane::fly已经转成了一个纯虚函数。它为飞行提供了接口。在Airplane类中同样展示出了默认实现,但是现在它是以独立函数的形式存在,defaultFly。像ModelA和ModelB这样的类如果想使用默认实现,只要在fly函数体内调用Inline函数defaultFly就可以了(Item30中有inline函数和虚函数之间交互的信息):
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)
{
code for flying a ModelC airplane to the given destination
}
这个机制也不是十分安全的(程序员仍然能够复制粘贴而导致错误),但是它比原来的设计可靠多了。因为对于Airplane::defaultFly来说,它是protected的是因为它是Airplane和它的派生类中的实现细节。使用airplane的客户只关心它们能够起飞,而不管飞行是如何实现的。
Airplane::defaultFly是一个非虚函数同样重要。因为没有派生类可以重定义这个函数,这也是Item36所描述的真理。如果defaultFly是虚的,就会有一个循环问题:当派生类想重新定义defaultFly但是忘了会怎样?
1.2.3 解决方法二,利用纯虚函数提供默认实现
一些人反对将函数接口和默认实现分离的想法,就像上面的fly和defaultFly一样。首先,它们意识到,繁殖出十分相关的函数名字污染了类命名空间。但是它们仍然同意将函数接口和默认实现分离。它们如何处理这种看上去矛盾的事情呢?通过利用纯虚函数必须在具现派生类中重新声明这个事实,但是它们也有可能有自己的实现。下面的例子展示了Airplane继承体系是如何利用定义纯虚函数的能力的:
class Airplane {
public:
virtual void fly(const Airport& destination) = ;
...
}; void Airplane::fly(const Airport& destination) // an implementation of
{ // a pure virtual function
default code for flying an airplane to
the given 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)
{
code for flying a ModelC airplane to the given destination
}
这个设计同前面的设计是基本相同的,除了纯虚函数体Airplane::fly代替了独立函数Airplane::defaultFly。从本质上来说,fly已经被分成了两个基本的组件。它的声明指定了接口(派生类必须使用它),同时它的定义指定了默认行为(派生类可能会使用,但是只有在显示的请求的时候才会使用)。将fly和defaultFly合并到一起,你就会失去为两个函数提供不同保护级别的能力:过去是protected的代码(在defaultFly中)现在变成了public的(因为它在fly中)。
1.3 非虚函数
最后,让我们看一看Shape的非虚函数,objectID:
class Shape {
public:
int objectID() const;
...
};
当一个成员函数是非虚的,就不想其在派生类中有不同的行为。事实上,一个非虚成员函数指定了一种超越特化的不变性(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继承下,派生类总是会继承基类接口。
- 纯虚函数只是指定了接口继承。
- 简单虚函数指定了接口继承外加一个默认实现。
- 非虚函数指定了一个接口继承外加一个强制实现。
读书笔记 effective c++ Item 34 区分接口继承和实现继承的更多相关文章
- Effective C++ Item 34 区分接口继承与实现继承
本文为senlie原创,转载请保留此地址:http://blog.csdn.net/zhengsenlie 关联条款 Item 36 接口继承和实现继承不同.在 public 继承下, derived ...
- 读书笔记 effective c++ Item 18 使接口容易被正确使用,不容易被误用
1. 什么样的接口才是好的接口 C++中充斥着接口:函数接口,类接口,模板接口.每个接口都是客户同你的代码进行交互的一种方法.假设你正在面对的是一些“讲道理”的人员,这些客户尝试把工作做好,他们希望能 ...
- 读书笔记 effective c++ Item 19 像设计类型(type)一样设计
1. 你需要重视类的设计 c++同其他面向对象编程语言一样,定义了一个新的类就相当于定义了一个新的类型(type),因此作为一个c++开发人员,大量时间会被花费在扩张你的类型系统上面.这意味着你不仅仅 ...
- 读书笔记 effective c++ Item 31 把文件之间的编译依赖降到最低
1. 牵一发而动全身 现在开始进入你的C++程序,你对你的类实现做了一个很小的改动.注意,不是接口,只是实现:一个私有的stuff.然后你需要rebuild你的程序,计算着这个build应该几秒钟就足 ...
- 读书笔记 effective c++ Item 35 考虑虚函数的替代者
1. 突破思维——不要将思维限定在面向对象方法上 你正在制作一个视频游戏,你正在为游戏中的人物设计一个类继承体系.你的游戏处在农耕时代,人类很容易受伤或者说健康度降低.因此你决定为其提供一个成员函数, ...
- 读书笔记 effective c++ Item 27 尽量少使用转型(casting)
C++设计的规则是用来保证使类型相关的错误不再可能出现.理论上来说,如果你的程序能够很干净的通过编译,它就不会尝试在任何对象上执行任何不安全或无意义的操作.这个保证很有价值,不要轻易放弃它. 不幸的是 ...
- 读书笔记 effective c++ Item 19 像设计类型(type)一样设计类
1. 你需要重视类的设计 c++同其他面向对象编程语言一样,定义了一个新的类就相当于定义了一个新的类型(type),因此作为一个c++开发人员,大量时间会被花费在扩张你的类型系统上面.这意味着你不仅仅 ...
- 读书笔记 effective c++ Item 39 明智而谨慎的使用private继承
1. private 继承介绍 Item 32表明C++把public继承当作”is-a”关系来对待.考虑一个继承体系,一个类Student public 继承自类Person,如果一个函数的成功调用 ...
- 读书笔记 effective C++ Item 40 明智而谨慎的使用多继承
1. 多继承的两个阵营 当我们谈论到多继承(MI)的时候,C++委员会被分为两个基本阵营.一个阵营相信如果单继承是好的C++性质,那么多继承肯定会更好.另外一个阵营则争辩道单继承诚然是好的,但多继承太 ...
随机推荐
- SoapUI:mock service的使用
mock service就是服务模拟,当我们的接口完成而服务端还没完成的时候,我们就可以用mock service来替代服务端进行接口测试. 1.1 创建MockService 创建moc ...
- 【python基础】 Tkinter小构件之canvas 画布
[python之tkinter画布] 要画布就要使用tkinter的小构件,canvas(结构化的图形,用于绘制图形,创建图形编辑器以及实现自定制的小构件类) 我们先使用create_rectangl ...
- JS日期时间加减实现
首先,上代码 var diffDate = function(date, diff) { return new Date( Date.UTC( date.getUTCFullYear(), date. ...
- Chrome 插件集锦
原文出处:CN_Simo 子曾曰:"工欲善其事,必先利其器.居是邦也."--语出<论语·卫灵公>:其后一百多年,荀子也在其<劝学>中倡言道:"吾尝 ...
- MongoDB复制集之将现有的单节点服务器转换为复制集
服务器情况: 现有的单节点 Primary 192.168.126.9:27017 新增的节点 Secondry 192.168.126.8:27017 仲裁节点 ...
- JavaScript从作用域到闭包
目录 作用域 全局作用域和局部作用域 块作用域与函数作用域 作用域中的声明提前 作用域链 函数声明与赋值 声明式函数.赋值式函数与匿名函数 代码块 自执行函数 闭包 作用域(scope) 全局作用域 ...
- 文件下载类型__response
response.setContentType(MIME)的作用是使客户端浏览器,区分不同种类的数据,并根据不同的MIME调用浏览器内不同的程序嵌入模块来处理相应的数据. 例如web浏览器就是通过MI ...
- javascript运行机制详解: 再谈Event Loop(转)
作者: 阮一峰 日期: 2014年10月 8日 一年前,我写了一篇<什么是 Event Loop?>,谈了我对Event Loop的理解. 上个月,我偶然看到了Philip Roberts ...
- 记一次阿里云Linux服务器安装.net core sdk的问题以及解决方法
因为公司领导要求新的项目能跨平台部署,也就是说能部署到Linux服务器上,故新的项目采用了Asp.net mvc core 1.1 进行开发.开发过程一切都比较顺利,然后在之前申请试用的一台微软Azu ...
- uml系列图(一)——与uml的第一次约会
uml视频终于开始看了,再看之前先大概了解了一下uml都有啥. 老规矩,有图有真相: 暂时的理解就这么多,等到uml看完的时候总结跟现在这张图比一下,应该是有很大的区别吧. uml是一种可视化的建模语 ...