Effective C++ 3nd 继承与面向对象编程
确定你的 public 继承塑模出 is-a 关系
以 C++ 进行面向对象编程,最重要的一个规则是:public inheritance(公开继承)意味 “is-a”(是一种)的关系。假设 class D 公有继承自 class B ,那么就是说,每一个类型为 D 的对象同时也是一个类型为 B 的对象,反之不成立
在 C++ 领域中,任何函数如果接受一个类型为 B 的实参,也会接受一个类型为 D 的对象。这个论点只对 public 继承成立
但是在设计这种关系时有可能会出现一些错误,比如企鹅是鸟,鸟能飞,那么继承关系如下:
class Bird{
public:
virtual void fly(){ ... }
};
class Penguin:public Bird { ... }; // 企鹅是一种鸟,正确
// 企鹅会飞,不正确
//----------------------------------分割线----------------------------------------
// 此时我们可以通过两种方法来避免这种不正确的关系
// 方法1:再声明一个会飞的鸟的类,与不会飞的鸟区分开
class Bird { ... }; // Bird 类中没有 fly 函数
class FlyingBird:public Bird{
public:
virtual void fly();
};
class penguin:public Bird { ... }; // 这样就对了
// 方法2:在企鹅类中的 fly 函数中产生一个运行错误
class Bird {public: virtual void fly(); };
class penguin:public Bird{
public:
virtual void fly() { error( "..." ); }
}; // 这样也可以,意思是企鹅会飞,但是这种尝试是错误的
根据条款 18 的说法:“好的接口可以防止无效的代码通过编译” ,上面两种方法,第一种更好。我们应该采取 “在编译期拒绝企鹅的飞行” ,而不是 “只在运行期才能侦测他们” 。
再比如设计正方形与矩形:显然正方形属于矩形,那么正方形应该共有继承自矩形。如果此时矩形类中有一个函数 function 的作用是仅仅改变矩形的高度,那么正方形类中也会有一个这样的函数。当我们在正方形类中调用这个函数时,正方形就不再是正方形,而是矩形了。
这里的困难是,某些可以施行于矩形上的操作不能施行于正方形上,但是共有继承主张:能够施行在基类上的操作也能施行于派生类上。
请记住:
- “public 继承” 意味着 is-a 。适用于基类身上的每一件事一定也适用于派生类身上,因为每一个派生类对象也是一个基类对象
避免遮掩继承而来的名称
本条款的内容主要和作用域有关,和继承无关。
C++ 的名称遮掩规则所作的唯一事情就是:遮掩名称。至于名称是否应和相同或不同的类型,并不重要。在函数中是内层作用域的名称会遮掩外围作用域的名称,也就是局部变量会遮盖全局变量。在继承体系中也是这样,派生类的作用域被嵌套在基类作用域内,如:
class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf2();
void mf3();
};
class Derived:public Base{
public:
virtual void mf1();
void mf4();
}
// 假设 Derived 类中的 mf4 函数定义为
void Derived::mf4(){
...
mf2();
}
当编译器看到这个 mf2 时会查找各作用域,在本例中,首先查找 mf4 的作用域,再查找 Derived 作用域,再查找 Base 作用域,就会找到一个 mf2 的函数,于是停止查找。如果没有找到,则会继续查找 Base 所在命名空间的作用域,最后找到全局作用域。
再进一步,假如对 mf1 和 mf3 函数重载:
class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1( int );
virtual void mf2();
void mf3();
void mf3( double );
};
class Derived:public Base{
public:
virtual void mf1();
void mf3();
void mf4();
}
此时 Derived 中的 mf1 和 mf3 函数会遮掩掉 Base 中的 mf1 和 mf3 ,从名称查找的规则来看,Base::mf1 和 Base::mf3 就不再被 Derived 继承
这些行为背后的基本理由是为了防止你在程序库或应用框架内建立新的派生类时附带地从疏远的基类继承重载函数。不幸的是你通常会想继承重载函数
解决办法是:可以通过 using 声明式显示得告诉编译器我们要使用 Base 的函数
class Derived:public Base{
public:
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
};
此时,Base 类中名为 mf1 和 mf3 的所有东西在 Derived 作用域类都可见
如果我们不像继承 Base 中的所有函数,可以定义一个简单的转交函数来实现
请记住:
- 派生类内的名称会遮掩基类内的名称。在 public 继承下从来没有人希望如此
- 为了让被遮掩的名称重见天日,可使用 using 声明式或转交函数
区分接口继承和实现继承
上代码,考虑一个展现绘图程序中几种几何形状的 class 继承体系:
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 类中的纯虚函数提供定义,但是在调用时必须明确指出其类名(Shape)
- 声明虚函数的目的是让派生类继承该函数的接口和默认实现
但是,允许虚函数同时指定函数声明和函数默认行为可能会造成危险。如果在编程时忘记在派生类中重新定义该虚函数,编译器不会报错,此时派生类会执行该函数的默认行为,但是程序执行结果不会是我们想要的。此问题在于派生类在未明白说出 “我要” 的情况下就继承了该默认行为。解决办法就是为纯虚函数提供定义
- 声明非虚函数的目的是为了令派生类继承函数的接口及一份强制性实现
请记住:
- 接口继承和实现继承不同。在公有继承下,派生类总是继承基类的接口
- 纯虚函数只具体指定接口继承
- 虚函数具体指定接口继承和默认实现继承
- 非虚函数具体指定接口继承及强制性实现继承
考虑虚函数以外的其他选择
上代码
class GameCharacter{
public:
virtual int healthValue() const;
};
以 Non-Virtual Interface 手法实现 Template Method 模式
class GameCharacter{
public:
int healthValue() const{ // 健康计算函数
...
int retVal = doHealthValue();
return retVal;
...
}
private:
virtual int doHealthValue() const { ... }
};
该方法以 public non-virtual 成员函数调用 private virtual 函数实现
这种手法称为 non-virtual interface(NVI)手法,它是 Template Method 设计模式的一个独特表现形式
这样做的好处在于调用 doHealthValue 之前可以准备好环境,调用结束后可以处理好环境。直接调用 doHealthValue 则做不到。在 NVI 手法下其实没有必要让 virtual 函数一定得是 private
藉由 Function Pointer 实现 Strategy 模式
class GameCharater;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf) {}
int healthValue() const { return healthFunc(*this); }
private:
HealthCalcFunc healthFunc;
};
这样通过提供不同的函数指针指向不同的函数,也可以实现 virtual 函数的功能。但这样做也有缺点:non-member 成员函数无法调用类中的 non-public 成员。唯一能够解决此问题的办法是:弱化类的封装(将函数声明为友元函数或在函数实现内提供一个 public 函数访问类中的成员)
藉由 tr1::function 完成 Strategy 模式
将之前的代码改写成使用 tr1::function:
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf){}
int healthValue() const { return healthFunc( *this ); }
private:
HealthCalcFunc healthFunc;
};
和前一个设计比较,这个设计几乎相同。唯一不同的是如今 GameCharacter 持有一个 tr1::function 对象,相当于一个指向函数的泛化指针。这样做的好处是允许客户在计算任务健康指数时使用任何兼容的可调用物
古典的 Strategy 模式
传统的 Strategy 做法会将健康计算函数做成一个分离的继承体系中的虚成员函数
class GameCharacter;
class HealthCalcFunc{
public:
virtual int calc(const GameCharacter& gc) const { ... }
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter{
public:
explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc):pHealthCalc(phcf){}
int healthValue() const { return pHealthCalc->calc( *this ); }
private:
HealthCalcFunc* pHealthCalc;
};
这样做的好处在于可以方便的扩充健康计算函数——只要往 HealthCalcFunc 继承中添加一个派生类即可
总结:
- 使用 non-virtual interface(NVI)手法,那是 Template Method 设计模式的一种特殊形式。它以 public non-virtual 成员函数包裹较低访问性的虚函数
- 将虚函数替换为 “函数指针成员变量” ,这是 Strategy 设计模式的一种分解表现形式
- 以 tr1::function 成员变量替换 virtual 函数,因而允许使用任何可调用物搭配一个兼容于需求的签名式。这也是 Strategy 设计模式的某种形式
- 将继承体系内的虚函数替换为另一个继承体系内的虚函数。这是 Strategy 设计模式的传统实现手法
- tr1::function 对象的行为就像一般函数指针。这样的对象可接纳 “与给定之目标签名式兼容” 的所有可调用物
绝不重新定义继承而来的 non-virtual 函数
因为 non-virtual 函数是静态绑定的,在使用基类指针调用派生类中的函数时,只会根据指针类型来调用相关函数。但虚函数是动态绑定的,所以不会存在这个问题
绝不重新定义继承而来的默认参数值
和前面也是相似的道理,将本条款的目光放到虚函数上。在运行过程中,虚函数是动态绑定的,但是其默认参数值却是静态绑定的。在使用指针调用派生类相关函数时,其参数会继承基类提供的默认参数值
通过复合塑模出 has-a 或 “根据某物实现出” & 明智而审慎地使用 private 继承
本小节两个条款一起总结
类似之前提到的 public 继承意味着“是一个”(is-a)关系,private 继承意味着“有一个”(has-a)关系。但是 private 继承意味着只有实现部分被继承,接口部分应略去。
例如:
class person { ... };
class student : private person { ... };
void eat(const person& p);
void study(const student& s);
person p; student s;
eat(p); // 正确
eat(s); // 错误,因为是 private 继承,所以编译器不会把 s 隐式转换成 person
// 也就是只有实现部分被派生类继承
private 继承只在 “软件实现” 层面上有意义,在 “软件设计” 上没有意义。条款指出:应尽可能使用复合,必要时才使用 private 继承
什么是复合?
复合是类型之间的一种关系,当某种类型的对象内含它种类型的对象,这样便是复合。
使用复合,通过在派生类的私有域中声明基类对象,模拟出 private 继承
但是,当涉及到 protected 成员和/或 virtual 函数的时候,可以使用 private 继承
class Timer{
public:
explicit Timer(int tickFrequency);
virtual void onTick() const;
};
// 可以以两种方式重写 Timer 里的虚函数
// 方法 1,继承(略)
// 方法 2,继承+复合
class Widget{
private:
class WidgetTimer : public Timer{
public:
virtual void onTick() const;
};
WidgetTimer timer;
};
为什么要选择第二种方法(继承+复合),而不是第一种方法。作者给出了两个原因:
- 如果我们希望 Widget 的派生类不重写虚函数,显然方法一无法做到。但如果采用方法二,因为 WidgetTimer 是 Widget 内部的一个 private 成员,Widget 的派生类将无法取用 WidgetTimer,这样就无法继承或重新定义它的虚函数
- 方法二可以将 Wdiget 的编译依存关系降至最低。如果采用方法一,则当 Widget 编译时,Timer 的定义必须可见,这样在 Widget 的文件内就要 #include “Timer.h”。但如果 WidgetTimer 移出 Widget 之外而 Widget 内含指针指向一个 WidgetTimer,Widget 可以只带着一个简单的 WidgetTimer 声明式,不再需要 #include 任何与 Timer 有关的东西。对大型系统而言,如此的解耦合可能是重要措施。
还有一种情况:当涉及到空间最优化时,可能会促使你选择 private 继承而不是继承+复合
在这种情况下,只适合你所处理的类不带任何数据时。这样的类没有 non-static 成员变量,没有虚函数,也没有虚基类。例如:
class Empty { };
class HoldsAnInt{
private:
int x; Empty e;
};
测试会发现 sizeof (HoldsAnInt) > sizeof (int),因为面对 “大小为零的独立(非附属)对象”,通常 C++ 官方勒令默默安插一个 char 到空对象中。然而齐位需求可能造成编译器为类似 HoldsAnInt 这样的类加上一些衬垫,所以有可能 HoldsAnInt 对象不只获得一个 char 大小,也许实际上又被放大到足够又存放一个 int。
但上面这个约束并不适用于派生类对象内的基类成分,因为它们并非独立(非附属)。
class HoldsAnInt : private Empty{
private:
int x;
};
// 此时 sizeof(HoldsAnInt) = sizeof(int)
这就是 “空白基类最优化(EBO)” 。另外还得注意,EBO 一般只在单一继承下才可行。
作者指出:大多数 classes 并非 Empty,所以 EBO 很少成为 private 继承的正当理由。更进一步说,大多数继承相当于 is-a。复合和 private 继承都意味着 is-implemented-in-terms-of,但复合比较容易理解,所以无论何时,只要可以,还是应该选择复合。
请记住:
- 复合的意义和 public 继承完全不同
- 在应用域,复合意味 has-a(有一个)。在实现域,复合意味 is-implemented-in-terms-of(根据某物实现出)
- Private 继承意味着 is-implemented-in-terms-of(根据某物实现出)。它通常比复合的级别低。但是当派生类需要访问 protected base class 的成员,或需要重新定义继承而来的虚函数时,使用 private 继承是合理的
- 和复合不同,private 继承可以造成 empty class 最优化。这对致力于 “对象尺寸最小化” 的程序库开发者来说,可能很重要
明智而审慎地使用多重继承
本条款主要说明多重继承的两个观点
多重继承的意思是继承一个一以上的 base class,但这些 base class 并不常在继承体系中又有更高级的 base classes,因为那会导致要命的 “钻石型多重继承”
要解决因多重继承而带来的额外空间开销,可以令基类成为虚基类,也就是令所有直接继承自基类的继承方式都采用虚继承。
但是使用虚继承会产生额外的空间开销,而且访问虚基类成员变量的速度比访问非虚基类成员变量的速度慢。除此以外,虚继承还会产生其他成本:virtual base 的初始化责任是由继承体系中的最低层类负责,这暗示:
- classes 若派生自 virtual bases 而需要初始化,必须认知其 virtual bases——不论那些 bases 距离多远
- 当一个新的派生类加入继承体系中,它必须承担其 virtual bases 的初始化责任
作者建议:非必要不要使用虚继承,如果必须使用,尽可能不要在虚基类中放置数据
请记住:
- 多重继承比单一继承复杂。它可能导致新的歧义性,以及对 virtual 继承的需要
- virtual 继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果 virtual bases classes 不带任何数据,将是最具实用价值的情况
- 多重继承的确有正当用途。其中一个情节涉及 “public 继承某个 Interface class” 和 “private 继承某个协助实现的 class” 的两相组合