Effective C++ 读书笔记05
前言
本文是阅读《Effective C++ 改善程序与设计的55个具体做法(第三版)》的心得笔记第五部分,文章也会按照原书的顺序依次记录各个条款。
第一部分的阅读笔记参见effective C++ 读书笔记01。
第二部分的阅读笔记参见effective C++ 读书笔记02。
第三部分的阅读笔记参见effective C++ 读书笔记03。
第四部分的阅读笔记参见effective C++ 读书笔记04。
继承与面向对象设计
条款32:确定你的public 继承塑模出 is-a 关系
以 C++ 进行面向对象编程时,最重要的一条规则是:public inheritance(公开继承)意味着 is-a(是一种)的关系。也就是说,每一个公开继承的派生类对象同时也是一个基类对象,反之不成立,基类是更一般化的概念,派生类是更特殊化的概念。即里氏替换原则(Liskov Substitution Principle):任何基类可以出现的地方,子类一定可以出现。比如下面这个例子:
class Person { ... }; |
在上例中,我们可以认为,每个学生都是人,但并非每个人都是学生。人的概念比学生更一般化,学生是人的一种特殊形式。因此,在C++中,任何函数如果期望获得一个类型为Person(或Person指针或Person引用)的实参,也都愿意接受一个Student对象(或Student指针或Student引用)。但需要注意,这只在public继承的前提下才成立。
设计模式五大原则(SOLID):
Single Responsibility Principle:单一职责原则
Open Closed Principle:开闭原则
Liskov Substitution Principle:里氏替换原则
Interface Segregation Principle:接口隔离原则
Dependence Inversion Principle:依赖倒置原则
public 继承和 is-a 关系看似很好理解,但有时候会跟你的现实直觉相左。比如企鹅是一种鸟,但企鹅不会飞,那么我们就不能给Bird
类声明Fly
方法,因为并不是所有鸟都会飞。我们可以通过如下设计来解决:
class Bird { |
还有另外一个场景,数学概念中我们认为正方形是一种矩形,所以想当然地在C++代码中做如下继承设计class Square: public Rectangle
,但实际上适用于矩形的属性修改规则,并不适用于正方形。所以这种public继承关系并不正确。比如:
class Rectangle { |
条款33:避免遮掩继承而来的名称
所谓遮掩,是指对名称(变量名或函数名)的覆盖。最常见的就是,内层作用域的名称会遮掩外层作用域的名称。如下:
int x = 10; // global 变量 |
这里需要注意的是,遮掩的对象是名称。上例中x
变量的类型是否相同,都不影响名称的遮掩。
现在考虑继承体系,则有派生类作用域的名称会遮掩基类作用域的名称,同样只与名称有关,与名称类型无关。
class Base { |
在上例中,派生类中的mf1
和mf3
会遮掩基类中的所有同名函数,导致基类的重载不可用。
但是,一般情况下,我们是不希望遮掩发生的。因为我们使用了public继承,便希望Derived
is-a Base
,则Base
能做的事,Derived
也应该都能做。而遮掩违背了这样的原则,所以我们需要打破这种缺省的遮掩行为。方法有使用using声明式和使用转发函数。
using声明式
使用using声明式可以让Derived
忽略名称遮掩,看到Base
作用域内的函数。
... // 基类不变 |
转发函数(forward function)
在private继承中,强调继承实现而不是继承接口。因此,有时候我们只希望继承一部分函数实现,而不是所有函数。此时可以用转发函数实现。
... // 基类不变 |
条款34:区分接口继承和实现继承
public继承下,成员函数继承由两部分组成:函数接口继承和函数实现继承。我们在设计类时,一定要清楚我们希望的,到底是继承接口还是继承实现,还是两个都要。那么有以下几点规则可供参考:
-
对于public继承,成员函数(无论是非虚函数,虚函数还是纯虚函数)的接口总是会被继承。
public继承意味着 is-a 的关系,那么基类能做的事,派生类应当也都能做;
-
声明纯虚函数的目的是为了让派生类只继承函数接口。
对于纯虚函数,派生类必须重新实现该接口。需要注意的是,C++不会禁止基类给出其纯虚函数的实现,但是除非有必要的理由,我们一般不会这样做。
-
派生类可以同时继承基类的(非纯)虚函数的接口和缺省实现。
对于(非纯)虚函数,派生类可以选择重写其实现,也可以选择使用基类的缺省实现。但是这样选择上的自由,却可能带来隐患。比如派生类中确实是需要重写虚函数的实现,但是忘记了,此时就会使用缺省实现,编译器也不会给出任何提示。比如下面这个例子:
class Airport { ... }; // 机场类
class Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void Airplane::fly(const Airport& destination) {
... // 缺省实现
}
class ModelA: public Airplane { ... }; // A类飞机
class ModelB: public Airplane { ... }; // B类飞机 都可以使用缺省的fly方法
class ModelC: public Airplane { ... }; // C类飞机 也未重写fly 但飞行方式不同 有问题!!想要规避该隐患,核心思想在于切断virtual函数接口和其缺省实现之间的联系。有如下两种方法可供选择:
- 将
fly
函数改为纯虚函数,只提供飞行接口。飞行的缺省实现也会在Airplane
中提供,但放在一个独立的非虚的defaultFly
函数中。若派生类希望使用缺省实现,则可在Fly
函数的重写中调用defaultFly
,若不想,则可自定义Fly
函数。
class Airport { ... }; // 机场类
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
...
protected: // 与private相比,可以被派生类继承
void defaultFly(const Airport& destination); //
};
void defaultFly(const Airport& destination) {
... // 缺省实现
}
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);
}
...
};
class ModelC: public Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination) {
... // 重写实现
}- 将
fly
函数改为纯虚函数,并在Airplane
中给出其缺省实现。
class Airport { ... }; // 机场类
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) {
... // 重写实现
} - 将
-
声明非虚函数的目的是为了令派生类继承函数的接口和强制实现。
如果成员函数是个非虚函数,则表明它并不打算在派生类中有不同的行为。即非虚成员函数的不变性高于其特异性。即非虚函数绝不应该在派生类中被重写。
条款35:考虑virtual函数以外的其他选择
假设你正在写一个游戏软件,游戏中的人物会有健康值属性,不同的人物的健康值计算方式不同,那么你就可以做这样一个中规中矩的设计:基类给出一个public虚函数接口healthValue
,并提供缺省实现,不同派生类可以重写该函数。那么,有没有其他替代方式呢?
藉由 Non-Virtual Interface(NVI)手法实现 Template Method 模式
有一个流派主张 virtual 函数应该几乎总是 private 的(也有例外,比如多态中的基类析构函数)。基于这种主张,可以让healthValue
成为一个public non-virtual 函数,并调用一个private virtual的函数实现。如下:
class GameCharacter { |
这一基本设计,令用户通过 public non-virtual 成员函数间接调用 private virtual 函数,称为NVI手法。也就是所谓的 Template Method 设计模式(与C++ Template 无关),这个 non-virtual 函数被称为 virtual函数的外覆器(wrapper)。
NVI手法的优点是可以给真正的工作提供一些必要的事前准备工作和事后清理工作:事前准备工作包括锁定互斥器(locking a mutex),制造运转日志记录项(log entry),验证类约束条件,验证函数先决条件等;事后清理工作包括互斥器解除锁定(unlocking a mutex),验证函数的的事后条件,再次验证类的约束条件等。
NVI手法也有一些反直觉的地方,即在派生类中重写了private virtual函数,但这些重写函数却不会被派生类调用。但其实这并不矛盾,重写virtual,赋予了派生类“如何实现机制”的控制能力,但基类保留了诉说“函数何时被调用”的权利。
藉由 Function Pointers(函数指针)实现 Strategy 模式
主要思想是增加一个函数指针作为private成员变量,该函数通过外部传入,从而实现不同的计算健康值的行为。如下:
class GameCharacter; // 前置声明 |
这种设计就是所谓的 Strategy 模式。它提供了如下两个有趣的设计弹性:
-
同一人物的不同实体可以有不同的健康计算函数。如下:
class EvilBadGuy: public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf) { ... }
...
};
int loseHealthQuickly(const GameCharacter&); // 健康值计算函数1
int loseHealthSlowly(const GameCharacter&); // 健康值计算函数2
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly); // 相同的人物类型搭配不同的健康值计算方式 -
某已知人物之健康值计算函数可以在运行期变更。只需给
GameCharacter
提供一个setHealthCalculator
函数,用来替换当前的健康值计算函数指针。
这种设计意味着将健康值计算函数独立于GameCharacter
继承体系之外,成为一个non-member non-friend 函数,则健康值计算函数将无权访问类的non-public部分,若健康值计算需要这些non-public信息,则需要弱化class的封装:将健康值计算函数声明为友元函数,为该函数提供所需信息的pubic访问函数。因此,这种设计的优点(两个弹性)能否弥补其缺点(可能的封装性弱化),则需要根据实际情况进行斟酌。
藉由std::function
完成 Strategy 模式
将上述的函数指针的成员变量替换为std::function
对象,std::function
对象可以持有任何可调用物(函数指针,函数对象或成员函数指针),相当于一个更加泛化的函数指针,从而使得设计更具弹性。
class GameCharacter; // 前置声明 |
这里,由std::function
类型(即我们typedef的HealthCalcFunc
类型)产生的对象可以持有任何与签名式(int (const GameCharacter&)
)兼容的可调用物,所谓兼容,即为可调用物的参数可以被隐式转换为const GameCharacter&
,而其返回类型可被隐式转换为int
。可以传入的可调用物示例如下:
-
函数指针
short calcHealth(const GameCharacter&); // 返回值不是int,但可以隐式转换
// 用户代码
EvilBadGuy ebg1(calcHealth); // EvilBadGuy的声明如前
ebg1.healthValue(); // -
函数对象(仿函数)
struct HealthCalculator {
int operator() (const GameCharacter& gc) const { // 重载operator()
... // 省略实现
}
};
// 用户代码
EvilBadGuy ebg2(HealthCalculator());
ebg2.healthValue(); -
成员函数指针(std::bind)
class GameLevel {
public:
float health(const GameCharacter&) const; // 成员函数,返回值不是int
...
};
GameLevel currentLevel;
...
EvilBadGuy ebg3(std::bind(&GameLevel::health, currentLevel, // 传入对象
std::placeholders::_1));
EvilBadGuy ebg4(std::bind(&GameLevel::health, ¤tLevel, // 传入对象指针
std::placeholders::_1));
ebg3.healthValue(); // 调用时会先构造一个currentLevel的临时对象,再调用临时对象的health函数
ebg4.healthValue(); // 调用时会直接调用currentLevel对象的health函数其中
std::bind
是一个绑定器,函数原型为:template <class Fn, class... Args>
/* unspecified */ bind (Fn&& fn, Args&&... args);可以将传入的可调用对象(第一个参数
fn
)与其对应参数(用逗号分割的参数列表args
)进行绑定,返回一个新的可调用对象。参数列表args
中:- 若为一个具体的值,则返回的可调用对象将始终使用该值作为参数;
- 若为一个形如
_n
的占位符(从_1开始,随使用占位符的数量递增),则返回的可调用对象会转发传递给可调用对象的参数;
在上例中,使用
std::bind
来绑定GameLevel
类的成员函数health
,因为成员函数还有一个隐式参数,即this
指针。而HealthCalcFunc
的签名式只兼容单一参数GameCharacter
,所以需要使用std::bind
来固定其中的隐式参数。需要注意的是,给this
传入对象或者对象指针都是可以的,但调用函数的对象会有区别(见代码注释),这在被调用函数会修改调用对象的成员变量时尤其需要注意。 -
匿名函数(lambda表达式)
EvilBadGuy ebg5([&](const GameCharacter& gc) {
... // 省略实现
});
ebg5.healthValue();
古典的 Strategy 模式
传统的 Strategy 模式做法会将健康值计算函数做成一个分离的继承体系中的virtual成员函数。
class GameCharacter; // 前置声明 |
条款36:绝不重新定义继承而来的 non-virtual 函数
条款32中指出,public继承意味着 **is-a(是一种)**的关系。
条款33中指出,派生类作用域的名称会遮掩基类作用域的名称。
条款34中指出,声明非虚函数的目的是为了令派生类继承函数的接口和强制实现。
这一切都导向同一个结论,任何情况下都不应该重新定义一个继承而来的non-virtual函数。
条款7中也指出,多态基类(会被继承)的析构函数应当是virtual的。否则也会违反本条款。
可以看下面这个反例:
class B { |
在上例中,我们预期的是函数调用是与具体对象绑定的,而不是与对象指针绑定的(即期望是动态绑定)。但是由于non-virtual函数是静态绑定的,这意味着,由于pB
是一个基类指针,则通过pB
调用的non-virtual函数永远都是B
类所定义的版本,即使pB
指向派生类对象d
。
条款37:绝不重新定义继承而来的缺省参数值
由于条款36指出,重新定义一个继承而来的non-virtual函数永远都错误的,所以我们将讨论范围缩小到”继承一个带有缺省参数值的virtual函数“。在此前提下,本条款成立的理由就是:virtual函数是动态绑定的,而缺省参数值是静态绑定的。考虑如下例子:
enum class ShapeColor |
对象的静态类型(static type)即其被声明时所采用的类型。上述例子中ps
,pc
和pr
都被声明为Shape
指针类型,故它们的静态类型都为Shape*
。
对象的动态类型(dynamic type)即其目前所指向的对象的类型。动态类型可以在程序执行过程中改变。如下:
ps = pc; // ps的动态类型现在为Circle* |
由于virtual函数是动态绑定的,即究竟调用virtual函数的哪一种实现,取决于调用对象的动态类型。所以有如下:
pc->draw(ShapeColor::Red); // 调用Circle::draw(ShapeColor::Red) |
但是缺省函数值是静态绑定的,即使用哪一种实现的缺省函数值,取决于调用对象的静态类型。所有有如下:
pr->draw(); // 调用Rectangle::draw(ShapeColor::Red) |
在上述调用中,出现了基类和派生类的draw
函数声明式各出一半力的怪异现象,这不是我们所期望的。而C++之所以没有将缺省参数值也设计成动态绑定,主要是为了运行期效率而做出的取舍。该问题不仅局限于对象指针,将指针换成引用,该问题依然存在。
但是,如果你严格遵守本条款,给基类和派生类同时提供相同的缺省参数值,也不是一个好的选择。因为这会导致代码重复,如果基类的缺省参数值,所有重复给定缺省参数值的派生类都得做相应的修改,否则就会再次违背本条款。那么怎么做才好呢?答案是使用条款35中提到NVI(non-virtual interface)手法:
enum class ShapeColor |
条款38:通过复合塑模出 has-a 或“根据某物实现出”
复合(composition)是一种常见的类关系,当某种类型的对象内含有其他类型的对象时,便是这种关系。
复合关系有两种意义:
-
has-a(有一个) 关系:
对象属于应用域(application domain),即对象相当于塑造现实世界中的某些事物。例如
Person
类有Address
、PhoneNumber
等类型的成员变量。 -
is-implemented-in-terms-of(根据某物实现出)关系:
对象属于实现域(implementation domain),即其他对象纯粹是实现细节的人工设计。例如缓冲区(Buffers)、互斥器(Mutexes)等。
比如我们想通过
std::list
实现自定义的Set
模板类(std::set
是通过平衡查找树实现,效率更高,但空间开销也更大,假设这里我们的环境是空间比速度更重要),我们也许会这么做:template <typename T>
class Set: public std::list<T> { ... };这是不对的,因为public继承意味着 is-a 关系,但是
std::list
允许存在重复元素,但是Set
不行。正确的实现方式如下:template <typename T>
class Set {
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
std::size_t size() const;
private:
std::list<T> rep;
}
template <typename T>
bool Set<T>::member(const T& item) const {
return std::find(rep.begin(), rep.end(), item) != rep.end();
}
template <typename T>
void Set<T>::insert(const T& item) {
if (!member(item)) rep.push_back(item);
}
template <typename T>
void Set<T>::remove(const T& item) {
typename std::list<T>::iterator it = // 关于typename的讨论见条款42
std::find(rep.begin(), rep.end(), item);
if (it != rep.end()) rep.erase(it);
}
template <typename T>
std::size_t Set<T>::size() const {
return rep.size();
}
条款39:明智而审慎地使用 private 继承
首先需要明确的是private继承的两个规则:
- 如果类之间的继承关系是private,则编译器不会自动地将一个派生类对象隐式转换成一个基类对象;
- 由private继承而来的所有成员,在派生类中都会变成private属性;
private继承意味着根据某物实现出(implemented-in-terms-of),只是一种实现技术,是为了让派生类继承基类中已经准备好的一些实现,而不是因为派生类和基类存在任何客观上的关联关系。换句话说,私有继承只有实现部分被继承,而接口部分被略去,这也解释了上述规则2成立的原因。
但是,由条款38可知,复合的意义也是根据某物实现出(implemented-in-terms-of),我们已经如何在private继承和复合之间做出抉择呢?答案是尽可能使用复合,必要时才使用private继承。看下面这个例子:
我们希望通过一个已有的计时类Timer
来统计Widget
类的性能情况,比如某个成员函数的调用频率等。用private继承和复合的实现分别如下:
class Timer { |
public继承 + 复合相比于private继承的好处在于:
- 可以阻止
Widget
的派生类再次重写onTick函数; - 若将
WidgetTimer
类的实现移到Widget
之外,并且Widget
中持有指针指向一个WidgetTimer
对象,则Widget
类的声明可以只带一个WidgetTimer
声明式,而不需要#include Timer.h
,从而实现解耦,降低编译依赖。
然而还有一种激进的情况会促使你选择private继承而非复合,那就是空白基类最优化(EBO,Empty Base Optimization)。所谓的空类(Empty Class)不带任何数据,即没有non-static成员变量,没有virtual函数,也没有virtual基类(但可以有typedefs,enums,static成员变量或non-virtual函数)。理论上,空类对象的大小应当为零,但由于技术理由,C++规定独立对象都必须有非零大小,所以C++会默默安插一个char(1字节)到空对象中。若该空类对象在其他类或结构体中,还要考虑内存对齐,则空类对象占用的空间将不止一个字节。而派生类对象中的基类成分不是独立的,不受上述规定的约束。故当对空间敏感时,可以通过private继承空类(的若干实现),来达到节约空间的目的,这便是空白基类最优化。此外,EBO只适用于单继承。一个例子如下:
|
条款40:明智而审慎地使用多重继承
多重继承(MI,Multiple Inheritance)是指派生类继承多个基类。在使用多重继承时,就要考虑如下几个问题:
- 从不同基类继承相同名称函数(或typedefs)时,会导致歧义问题,需要指定基类。
class B1 { |
- 当派生类继承的多个基类有更高层的共同的基类时,就形成了钻石型多重继承。这时来自共同基类的成员变量经由不同的继承路径都会产生一笔备份,若我们不希望产生这样的重复,则需要用到虚继承。但是虚继承是有代价的,为了保证虚继承的正确性(避免成员变量重复),编译器需要在背后付出更多代价,这会导致虚继承派生类占用空间更大或运行速度更慢。此外,虚继承时virtual base classes(无论直接还是间接)的初始化责任由继承体系中最低层的类负责。所以:
- 非必要不使用虚继承;
- 若必须使用virtual base classes,则尽可能不在其中包含数据成员,从而不必担心初始化问题。
class BB { |
虽然能用单一继承就不要使用多重继承,但多重继承也并非完全是洪水猛兽,一个合理使用多重继承的例子如下:
|