C++中允许多重继承,并且可以指定继承是否是public or private等。成员函数也可以是虚函数或者非虚函数。如何在OOP这一C++联邦中的重要一员的规则下,写出易于拓展,易于维护且高效的代码?
32 确定public
继承塑模出Is-a的关系
请把这条规则记在心中:public
继承意味着Is-a(XX是X的一种)的关系。适用于base class上的东西也一定能够用在derived class身上。因为每一个derived class对象也是一个base class的对象。
不过,在实际使用时,可能并不是那么简单。举个例子,在鸟类这个基类中定义了fly()
这一虚函数,而企鹅很显然是一种鸟,但是却没有飞翔的能力。类似的情况需要在编程实践中灵活处理。
33 避免遮掩继承而来的名称
这个题材实际和作用域有关。当C++遇到某个名称时,会首先在local域中寻找,如果找到,就不再继续寻找。这样,derived class中的名称可能会遮盖base class中的名称。
一种解决办法是使用using
声明。如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Base {
public:
virtual void f1() = 0;
void f3();
void f3(double);
};
class Derived: public Base {
public:
using Base::f3;
virtual void f1();
void f3();
};
Derived d;
d.f1(); // 没问题,调用了Derived中的f1
d.f3(); // 没问题,调用了Derived中的f3
double x;
d.f3(x); // 没问题,调用了Base中的f3。
// 但是如果没有using声明的话,Base::f3会被冲掉。
34 区分接口继承和实现继承
表面上直截了当的public
继承,可以细分为函数接口继承和函数实现继承。以下面的这个例子来说明:
1 | class Shape { |
纯虚函数
声明纯虚函数(如draw()
函数)是为了让derived class只继承函数接口。乃是一种约定:“你一定要实现某某,但是我不管你如何实现”。
不过,你仍然可以给纯虚函数提供函数定义。虚函数
非纯虚函数(如error()
函数)的目的是,让derived class继承该函数的接口和缺省实现。乃是约定“你必须支持XX,但是如果你不想自己实现,可以用我提供的这个”。
然而可能会出现这样一种局面:derived class的表现与base class不同,但是又忘记了重写这个虚函数。为了避免这种情况,可以使用下面的技术来达到“除非你明确要求,否则我才不给你提供那个缺省定义”的目的。
1 | class Base { |
不过这样导致一个多余的default_fun()
函数。如果不想添加额外的函数,我们可以使用上述提到的拥有定义的纯虚函数来实现。
1 | class Base { |
- 非虚函数
这意味着你不应该在derived class中定义不同的行为(老老实实用我给你的!),使得其继承了一份接口和强制实现。
35 考虑virtual
函数之外的其他选择
虚函数使得多态成为可能。不过在一些情况下,为了实现多态,不一定非要使用虚函数。本条款介绍了一些相关技术。
在某游戏中,需要设计一个计算角色剩余血量的函数。下面是一种惯常的设计。1
2
3
4class GameCharacter {
public:
virtual int healthValue() const;
};
- 使用non-virtual interface实现template method模式
这种流派主张virtual
函数应该几乎总是私有的。较好的设计时将healthValue()
函数设为非虚函数,并调用虚函数进行实现。这个调用函数中,可以做一些预先准备(互斥锁,日志等),后续可以做一些打扫工作。
1 | class GameCharacter { |
这样做的好处是基类明确定义了该如何实现求血量这个行为,同时又给了一定的自由,派生类可以重写doHealthValue()
函数,针对自身的特点计算血量。
- 使用函数指针实现策略模式
上述方案实际上是对虚函数的调用进行了一次包装。我们还可以借由函数指针实现策略模式,为不同的派生类甚至不同的对象实例做出不同的实现。
1 | class GameCharacter; // 前置声明 |
这样,我们通过在构造时候传入相应的函数指针,就可以实现计算血量的个性化设置。比如两个同样的boss,血量下降方式就可以不一样。
或者我们可以在运行时候,通过设定healthFunc
,来实现动态血量计算方法的变化。
- 借由
std::function
实现策略模式
作为上面的改进,我们可以使用std::function
(C++11),这样,不止函数指针可以使用,函数对象等也都可以了。(关于std::function
的大致介绍,可以看这里)。
我们只需将上面的typedef
改掉即可。不再使用函数指针,而是更加高级更加通用的std::function
。
1 | typedef std::function<int(const GameCharacter&)> HealthCalcFun; |
- 使用古典的策略模式
如下图所示。对于血量计算,我们单独抻出来一个基类,并有不同的实现。GameCharacter
类中则含有一个指向HealthCalcFun
类实例的指针。
1 | //我们首先定义HealthCalcFunc基类 |
该条款给出了虚函数的若干替代方案。
36 绝不重新定义继承而来的非虚函数
在条款34中已经指出,非虚函数是一种实现继承的约定。派生类不应该重新定义非虚函数。这破坏了约定。
如下所示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class B {
public:
void mf() {...}
};
class D: public B {
public:
void mf() {...}
};
D d;
B* pb = &d;
D* pd = &d;
pb->mf(); // 调用的是B::mf()
pd->mf(); // 调用的是D::mf()
这是因为非虚函数的绑定是编译期行为(和虚函数的动态绑定相对,其发生在运行时)。由于pb
被声明为一个指向B
的指针,所以其调用的是B
的成员函数mf()
。
为了不至于让自己陷入精神分裂与背信弃义的境地,请不要重新定义继承而来的非虚函数。
37 绝不重新定义继承而来的缺省参数值
由于条款36的分析,所以我们只讨论继承而来的是带有缺省参数的虚函数。这样一来,本条款背后的逻辑就很清晰了:因为缺省参数同样是静态绑定的,而虚函数却是动态绑定。让我们再解释一下。
静态类型是指在程序中被声明时的类型(不论其真实指向是什么)。1
2
3// Circle是Shape的派生类
Shape* ps;
Shape* pc = new Circle; // 静态类型都是Shape
动态类型是指当前所指对象的类型。就上例来说,pc
的动态类型是Circle*
,而ps
没有动态类型,因为它并没有指向任何对象实例。动态类型常常可以通过赋值改变。1
ps = new Circle; // 现在ps的动态类型是Circle*
虚函数是运行时决定的,取决于发出调用的那个对象的动态类型。
不过遵守此项条款,有时又会造成不便。看下例:
1 | class Shape { |
第一个问题,代码重复,我写了两遍缺省参数。第二造成了代码依存。比如我想换成GREEN
为默认参数,需要在基类和派生类中同时修改。
一种解决方法是采用条款35中的替代设计,如NVI方法。令基类中的一个public的非虚函数调用私有的虚函数,而后者可以被派生类重新定义。我们只需要在public的非虚函数中定义缺省参数即可。
1 | class Shape { |
38 通过复合塑模has-a或“根据某物实现出”
复合是指某种对象内含其他对象。复合实际有两层意义,一种较好理解,即has-a,如人有名字、性别等他类,一种是指根据某物实现(is-implemented-in-terms-of)。例如实现消息管理的某个类中含有队列作为实现。
39 明智而审慎地使用private
继承
私有继承意味着条款38中的“根据某物实现出”。例如D
私有继承自B
,不是说D
是某种B
,私有继承完全是一种技术上的实现(和对现实的抽象没有半毛钱关系)。B
的每样东西在D
中都是不可见的,也就是成了黑箱,因为它们本身就是实现细节,你只是考虑用B
来实现D
的功能而已。
但是复合也能达到相同的效果啊~我在D
中加入一个B
的对象实例不就好了?很多情况下的确是这样,如果没有必要,不建议使用私有继承。
40 明智而审慎地使用多重继承
使用多重继承有可能造成歧义。例如,C
继承自A
和B
,而两个基类中都含有成员函数mf()
。那么当d.mf()
的时候,究竟是在调用哪个呢?你必须明确地指出,d.A::mf()
。
使用多重继承还可能会造成“钻石型”继承。任何时候继承体系中某个基类和派生类之间有一条以上的相通路线,就面临一个问题,是否要让基类中的每个成员变量经由每一条路线被复制?如果只想保留一份,那么需要将File
定为虚基类,所有直接继承自它的类采用虚继承。
1 | class File {...}; |
从正确的角度看,public的继承总应该是virtual的。不过这样会造成代码体积的膨胀和执行效率的下降。
所以,如无必要,不要使用虚继承。即使使用,尽可能避免在其中放置数据(类似Java或C#中的接口Interface)
附注 std::function
的基本使用
std::function
的作用类似于函数指针,但是能力更加强大。我们可以将函数指针,函数对象,lambda表达式或者类中的成员函数作为std::function
。
如下所示:
1 |
|