一.让自己习惯C++
条款02:尽量以const
,enum
,inline
替换#define
这个条款或许改为「宁可以编译器替换预处理器」比较好
1)以const
替换#define
1 |
|
- 调试的需要:
#define
的记号会被预处理器移走,记号名称可能没进入记号表内。因此当#define
的宏名称获得一个编译错误时,会引起困惑,浪费大量时间追踪错误。而AspectRatio
肯定会被编译器看到 - 更小的代码量:对浮点数而言,使用常量可能比使用
#define
导致较小量的代码,因为预处理器「盲目地将ASPECT_RATIO
替换为 1.653 可能导致目标码出现多份 1.653
但是,以常量替换#define
时要注意:
- 定义常量指针时:由于常量定义式通常被定义在头文件内,因此有必要将指针声明为const。如:
1
const char* const authorName = "Scott Meyers";
- class专属常量:class专属常量需要声明在class内部,并且被class使用:
1
2
3
4
5
6class GamePlayer{
static const int NumTurns = 5; //常量声明式
int scores[NumTurns]; //使用该常量
};
//通常定义出现在头文件中
const int GamePlayer::NumTurns; //NumTurns的定义
对于static
修饰的class专属整形常量,如果需要对该常量取地址或编译器坚持要看到一个定义式。那么必须提供类外定义。如果类内声明时提供了初始值,类外定义就不能再设初值。但是某些编译器可能不支持类内初始值,因此需要在类外定义时提供初始值,但是这样就不能像scores
成员一样,在类内使用该常量。因此,如果需要使用class专属常量,最好改用enum hack
2)以enum
替换#define
正如上面所提到的,编译器可能不支持类内初始值,因此改用enum hack
:
1 | class GamePlayer{ |
enum hack
的行为比较像#define
而不像const
。例如取一个const
的地址时合法的,但取一个enum
的地址就不合法,而取一个#define
的地址通常也不合法
3)以inline
替换#define
以#define
实现宏看起来像函数,并且不会导致函数调用带来的开销,但是可能引发错误:
1 |
|
使用inline
函数可以减轻为参数加上括号以及参数被核算多次等问题。同时,inline
可以实现一个「类内的private inline
函数」,但一般而言宏无法完成此事
条款03:尽可能使用const
1)const
修饰变量
如果变量本身不应该被修改,应该使用 const
修饰。这样编译器可以进行保护,确保这个变量不会被修改。
1 | char greeting[] = "Hello"; |
- 如果关键字
const
出现在星号左边,表示被指物是常量 - 如果出现在星号右边,表示指针自身是常量
2)const
修饰函数
- 修饰参数时,和修饰一般变量相同
- 修饰返回值,可以降低因客户错误而造成的意外
1
2
3
4
5Rational a, b, c;
...
if (a * b = c){ //其实是想做一个比较动作,使用const修饰返回值可以避免这种错误
...
}
如果 a 和 b 都是内置类型。这样的代码直截了当就是不合法。而一个「良好的用户自定义类型」的特征是他们避免与内置类型不兼容。因此对 operator *
的定义应该如下:
1 | const Rational operator*(const Rational& lhs, const Rational& rhs); |
3)const
修饰成员函数
const
修饰成员函数有2个好处:
- 可读性:使得接口容易被理解,可以知道哪个函数可以改动对象哪个函数不行
const
修饰的成员函数可以作用于const
对象
但是,使用const
修饰成员函数时需要注意,C++对常量性的定义是 bitwise constness,即函数const
成员函数不应该修改对象的任何成员变量。因此,如果成员变量是一个指针,那么不修改指针指向而修改指针所指之物,也符合 bitwise constness,因此如果不是从 bitwise constness 的角度,这样也是修改了对象:1
2
3
4
5
6
7
8
9
10
11
12class CTextBlock {
public:
char& operator[](std::size_t position) const // bitwise constness声明
{ return pText[position]; } // 但其实不恰当
private:
char* pText;
};
const CTextBlock cctb("Hello"); //声明一个常量对象
char *pc = &cctb[0]; //调用const operator[]取得一个指针,
//指向cctb的数据
*pc = 'J'; //cctb现在有了「Jello」这样的内容
还有一种 logical constness:一个 const
成员函数可以修改它所处理的对象内的某些 bits,但只有在客户端侦测不出的情况下才行:
1 | class CTextBlock { |
但是,C++对常量性的定义是bitwise constness的,所以这样的操作非法。解决办法是使用mutable
:
1 | class CTextBlock { |
总的来说,上面提到了 2 种「修改」 const
成员函数中修改对象(修改 const
对象)的方法。
最后,const
和 non-cons
t 版本的函数可能含有重复的代码,如果抽离出来单独成为一个成员函数还是有重复。如果希望去重,可以使用「运用 const
成员函数实现出其 non-const
孪生兄弟」的技术:
1 | class CTextBlock { |
条款04:确定对象被使用前已先被初始化
读取未初始化对象的后果:读取未被初始化的值会导致不明确的行为。在某些平台上,仅仅只是读取未初始化的值就可能让程序终止,更可能的情况是读入一些「半随机」bits,污染了正在进行读取动作的那个对象,最终导致不可预知的程序行为,以及许多令人不愉快的调试过程。
按对象的类型划分:
- 对于内置类型的对象:永远在使用前初始化
- 类类型的对象:初始化责任落在构造函数身上
- 效率上的问题:
- 类类型成员的初始化动作发生在构造函数本体之前。比起先调用
default
构造函数然后再调用copy assignment
操作符,单只调用一次copy
构造函数比较高效。因此,善用初始化列表有助于提升效率 - 内置类型成员的初始化不一定发生在赋值动作的的时间点之前。对于内置类型成员,一般为了保持一致也在初始化列表中给出初始值
- 类类型成员的初始化动作发生在构造函数本体之前。比起先调用
- 初始化顺序:成员的初始化顺序与类内声明顺序相同
- 效率上的问题:
按对象的作用域与生命周期划分:
static
对象non-local static
对象:C++对「定义于不同的编译单元内的non-local static
对象」的初始化相对次序并无明确定义global
对象- 定义于
namespace
作用域内的对象 classes
内、file 作用域内被声明为static
的对象
local static
对象:函数内的local static
对象会在「该函数被调用期间、首次遇上该对象的定义式」时被初始化- 函数内被声明为
static
的对象
- 函数内被声明为
因此,如果一个 non-local static
对象的初始化依赖于另外一个 non-local static
的初始化,那么可能造成错误。解决方法是使用 local static
对象替换 non-local static
对象(参考单例模式)。
二.构造/析构/赋值运算
条款05:了解C++默默编写并调用哪些函数
一般情况下,编译器会为类合成下列函数:
default
构造函数copy
构造函数:编译器生成的版本只是单纯地将来源对象的每一个non-static
成员变量拷贝到目标对象copy assignment
操作符:编译器生成的版本只是单纯地将来源对象的每一个non-static
成员变量拷贝到目标对象;- 析构函数:编译器生成的版本是
non-virtual
的。
更深层次的理解(对象模型第 2 章、第 5 章)
以下情况编译器不会合成copy assignment操作符:
- 含有引用成员:原因在于这种情况下,赋值的目的不明确。是修改引用还是修改引用的对象?如果是修改引用,这是被禁止的。因此编译器干脆拒绝这样的赋值行为;
- 含有 const 成员:const 对象不应该修改;
- 父类的 copy assignment 操作符被声明为 private:无法处理基类子对象,因此也就无法合成;
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
为什么要拒绝?比如,房产应该是独一无二的,这种情况下应该拒绝对象拷贝动作。
一般情况下,不声明相应函数即可拒绝。但是编译器会为类合成一些函数,因此需要显式拒绝。
还是以拒绝对象拷贝为例子,拒绝方法包括:
- 将
copy
构造函数或copy assignment
操作符声明为private
,并且不定义(这被用于 C++iostream
程序库中);- 这种情况下
member
函数和friend
函数还是可以调用,如果member
函数或friend
函数中执行了复制,会引发链接错误。可以使用一个基类,在基类中将copy
构造函数或copy assignment
操作符声明为private
,并且继承这个基类。这样可以将链接错误移至编译期,因为尝试拷贝时,编译器会试着生成一个copy
构造函数和一个copy assignment
操作符,这些函数的「编译器合成版」会尝试调用其基类的对应兄弟,而那些调用会被编译器拒绝,因为private
;
- 这种情况下
- 使用 delete(这个在书中没有提到);
条款07:为多态基类声明virtual
析构函数
- 为基类声明
virtual
析构函数:当派生类对象经由一个基类指针被删除,而该基类带有一个non-virtual
析构函数,结果未定义——实际执行时通常发生的是对象的derived
成分没有销毁,即「局部销毁」,造成资源泄露(因为存在这个问题,所以不要继承一个不被用作基类的类); class
不用作基类时,不要将析构函数声明为virtual
:virtual
会引入虚函数指针,这会增加空间开销,使得类无法被 C 函数使用,从而不再具有移植性;
条款08:别让异常逃离析构函数
C++并不禁止析构函数吐出异常,但是并不鼓励这样做
1)原因
如果析构函数吐出异常,程序可能过早结束(比如某个函数调用发生异常,在回溯寻找catch
过程中,每离开一个函数,这个函数内的局部对象会被析构,如果此时析构函数又抛出异常,前一个异常还没得到处理又来一个,因此一般会引起程序过早结束)。异常从析构函数中传播出去,可能会导致不明确的行为
2)如何解决
- 在析构函数中
catch
异常,然后调用abort
终止程序。通过abort
抢先置「不明确行为」于死地 - 在析构函数中
catch
异常,然后记录该失败,即吞掉异常(通常是个坏主意,因为这样压制了「某些动作失败」的重要信息。但是也比负担「草率结束程序」或」不明确行为带来的风险「好) - 重新设计接口,让客户能够在析构前主动调用可能引起异常的函数,然后析构函数中使用一个
bool
变量,根据用户是否主动调用来决定析构函数中是否应该调用可能引起异常的函数,让客户拥有主动权(如果客户没有主动调用,那么当发生异常时也不应该抱怨,因为已经给出了客户自己处理异常的机会)
条款09:绝不在构造和析构过程中调用virtual
函数
如果希望在继承体系中根据类型在构建对象时表现出不同行为,可以会想到在基类的构造函数中调用一个虚函数:
1 | class Transaction { //所有交易的基类 |
但是最终调用的 virtual
函数都是基类的版本。同时,因为是纯虚函数,除非定义该函数,否则将报链接错误。
在子类构造期间,virtual
函数绝不会下降到派生类阶层。取而代之的是,对象的作为就像隶属基类类型一样。即派生类对象的基类构造期间,对象的类型是基类而不是派生类;除此之外,若使用运行期类型信息(如 dynamic_cast
和 typeid
),也会把对象视为基类类型(这样对待是合理的:因为子类部分尚未初始化,如果调用的是子类的虚函数,通常会访问子类部分的数据,会引发安全问题)。
同样的道理也适用于析构函数。一旦派生类析构函数开始执行,对象内的派生类成员变量便呈现未定义值,所以 C++视它们仿佛不再存在。进入基类析构函数后对象就成为一个基类对象。
如果希望实现最初的功能,即根据类型产生不同日志记录,那么可以在派生类的成员初始化列表中,向基类传递一些类型相关的信息,基类构造函数根据这些信息生成不同的日志记录,此时日志记录的生成函数不再是 virtual
函数。
条款10:令operator=
返回一个reference to *this
这是为了实现「连锁赋值」。这个协议除了适用于 operator=
,还适用于 +=
、-=
、*=
。
这只是个协议,并无强制性,如果不遵循,代码一样可通过编译。
条款11:在operater=中处理「自我赋值」
考虑如下Widget类:
1 | class Bitmap {...}; |
下面的operator=
实现是一份不安全的实现,在自赋值时会出现问题:
1 | Widget& |
要处理自赋值,可以有以下几种方式:
在开头添加「证同测试」
1
2
3
4
5
6
7Widget& Widget::operator=(const Widget& rhs){
if (this == &rhs) return *this;
delete pb; // stop using current bitmap
pb = new Bitmap(*rhs.pb); // start using a copy of rhs's bitmap
return *this; // see Item 10
}
// 这样做虽然能处理自赋值,但不是异常安全的,如果 `new` 时发生异常,对象的 `pb` 将指向一块被删除的内存。通过确保异常安全来获得自赋值的回报
1
2
3
4
5
6
7Widget& Widget::operator=(const Widget& rhs){
Bitmap *pOrig = pb; // remember original pb
pb = new Bitmap(*rhs.pb); // make pb point to a copy of *pb
delete pOrig; // delete the original pb
return *this;
}
// 现在,如果new失败,pb会保持原状。同时也能处理自赋值。如果担心效率可以在开头加上「证同测试」。但是if判断也会引入开销,因此需要权衡自赋值发生的频率使用
copy and swap
技术1
2
3
4
5
6
7
8
9
10
11
12
13//参数为pass by reference
Widget& Widget::operator=(const Widget &rhs){
Widget temp(rhs);
swap(temp); // swap *this's data with
return *this; // the copy's
}
//参数为pass by value
//这种方式的缺点是代码不够清晰,但是将「copying动作「从函数本体内移至」函数参数构造阶段」
//却可令编译器有时生成更高效的代码
Widget& Widget::operator=(Widget rhs){
swap(rhs); // swap *this's data with
return *this; // the copy's
}
条款12:复制对象时勿忘其每一个成分
如果声明自己的 copying
函数,意思就是告诉编译器你并不喜欢缺省实现中的某些行为。编译器仿佛被冒犯似的,会以一种奇怪的方式回敬:如果你自己写出的 copying
函数代码不完全,它也不会告诉你。
copy
构造函数- 非继承中:当为类添加一个新成员时,
copy
构造函数也需要为新成员添加拷贝代码。否则会调用新成员的默认构造函数初始化新成员; - 继承中:在派生类的
copy
构造函数中,不要忘记调用基类的copy
构造函数拷贝基类部分。否则会调用基类的默认构造函数初始化基类部分;
- 非继承中:当为类添加一个新成员时,
copy
赋值运算符- 非继承中:当为类添加一个新成员时,
copy
赋值运算符中也需要为新成员添加赋值代码,否则新成员会保持不变; - 继承中:在派生类的
copy
赋值运算符中,不要忘记调用基类的copy
赋值运算符,否则基类部分会保持不变;
- 非继承中:当为类添加一个新成员时,
三.资源管理
条款13:以对象管理资源
当申请一块动态内存时,可能会发生泄漏:
- **忘记
delete
**; - 有
delete
,但是delete
之前跳出控制流:在代码的维护过程中,动态分配内存和delete
之间可能会加入return
之类的控制流变更语句,或者是可能引发异常的代码,这样可能会使程序执行不到delete
从而造成资源泄露
总结起来就是,手工 delete
一个是需要时刻记住 delete
,增加编码负担,另一个是即使明确 delete
,在 delete
之前控制流可能发生改变从而还是会造成资源泄露。
因此,一个好的办法是使用对象管理资源,包括下列两个关键想法:
- 获得资源后立刻放进管理对象:「以对象管理资源」的观念常被称为「资源取得时机便是初始化时机」(RAII);
- 管理对象运用析构函数确保资源被释放;
一个对象管理资源的例子是auto_ptr
:
1 | void f() |
对于对象管理资源,需要注意对象的复制行为:例如,复制逻辑可能是多个对象管理相同的资源,那么析构时就会重复 delete
。因此,如果是这种复制逻辑,那么应该引入引用计数,析构时根据引用计数决定是否 delete
。否则,一个资源就应该只由一个对象来管理,那么复制时就原来对象管理的资源就应该修改成 null
,而复制所得的新对象将取得资源的唯一拥有权(如 auto_ptr
)。
C++没有特别针对「动态分配数组」而设计的类似
auto_ptr
或tr1:: shared_ptr
那样的东西,甚至 TR1 中也没有。那是因为vector
和string
几乎总是可以取代动态分配而得的数组。因此当需要动态分配数组时,提倡使用vector
(可以使用unique_ptr
管理动态数组)。
条款14:在资源管理类中小心copying行为
并非所有资源都是动态内存,除此之外还有锁等资源,也应该通过「对象管理资源」来确保获取资源后能够正确的释放,根据资源的类型,和不同的需求,可能需要定义不同的 copy
行为:
- 禁止复制:比方说锁资源,管理锁资源的对象复制通常并不合理。因此应该禁止这类对象的复制,可以通过继承一个
copying
操作被声明为private
的基类来禁止复制,这点在条款 06 中有提到; - 对底层资源使用「引用计数法」:如果希望保有资源,直到它的最后一个使用者(某对象)被销毁。这种情况下复制
RAII
对象时,应该将资源的「被引用数」递增。tr1:: shared_ptr
便是如此(当资源引用计数减为 0 时,如果不希望删除资源,比方说锁资源,可以使用shared_ptr
的「删除器」); - 复制底部资源:这种情况下,希望在复制
RAII
对象时,同时复制其关联的底层资源。展现出一种「深拷贝」的行为; - 转移底部资源的拥有:如果希望任一时刻一个资源只由一个 RAII 对象管理,那么在复制 RAII 对象时,应该实现拥有权的「转移」,原
RAII
对象拥有的资源设为null
(如auto_ptr
);
条款15:在资源管理类中提供对原始资源的访问
API 往往要求访问原始资源(即被 RAII 对象管理的资源,而不是直接访问 RAII 对象),所以每一个 RAII 类应该提供一个「取得其所管理的资源」的办法。
取得 RAII 对象所管理资源的办法可以通过显式转换或隐式转换:
- **显式转换(比较安全,但不易用)**:如
shared_ptr
的get()
方法 - **隐式转换 (比较易用,但不安全)**:如
shared_ptr
的operator *
和operator->
如果通过实现隐式转换(比如,实现operator()
)来提供对元素资源的访问,可能不安全:
1 | //以下,Font是一个RAII对象,FontHandle是一个原始资源 |
在上面的例子中,如果实现了隐式转换,底层资源会被复制,如果 f1 销毁,f2 会成为「虚吊的」(dangle)。
是否该提供一个显式转换函数将 RAII 转换为其底层资源,或是应该提供隐式转换,答案主要取决于 RAII 被设计执行的特定工作,以及它被使用的情况。
条款16:成对使用new
和delete
时要采取相同形式
当使用new
和delete
时,发生2件事
new
- 内存被分配出来(通过名为
operator new
的函数); - 针对此内存会有一个(或更多)构造函数被调用;
- 内存被分配出来(通过名为
delete
- 针对此内存会有一个(或更多)析构函数被调用;
- 内存被释放(通过名为
operator delete
的函数);
单一对象的内存布局一般而言不同于数组的内存布局。更明确地说,数组所用的内存通常还包括「数组大小」的记录,以便 delete
知道需要调用多少次析构函数。
当使用 delete
时,唯一能够让 delete
知道内存中是否存在一个「数组大小记录」的办法是,由你来告诉它。即加上 []
,delete
便认为指针指向一个数组,否则它便认为指针指向单一对象。
因此,应该像这样使用 new
和 delete
。
1 | std::string* stringPtr1 = new std::string; |
- 如果对
stringPtr1
使用delete []
形式,结果未定义,但不太可能让人愉快。假设内存布局上,delete
会读取若干内存并将它解释为「数组大小」,然后开始多次调用析构函数,浑然不知它所处理的那块内存不但不是个数组,也或许并未持有它正忙着销毁的那种类型的对象; - 如果没有对
stringPtr2
使用delete []
形式,结果亦未定义,但可以猜想可能导致太少的析构函数被调用。犹有进者,这对内置类型如int
者亦未定义,即使这类类型并没有析构函数;
因此,**如果调用 new
时使用了 []
,必须在对应调用 delete
时也使用 []
;如果调用 new
时没使用 []
,那么也不该在对应调用 delete
时使用 []
**。
这点在 typedef
中尤其需要注意:
1 | typedef std::string AddressLines[4]; //每个人的地址有4行,每行是一个string |
为避免这类错误,最好尽量不要对数组形式做 typedef
动作。
条款 17:以独立语句将 new
的对象置入智能指针
考虑如下情况:
1 | func1(std::tr1::shared_ptr<原始资源类>(new 原始资源类),func2()); |
在调用func1
之前,编译器必须创建代码,做以下3件事
- 执行
func2
; - 执行
new
原始资源类创建一个原始资源; - 调用
tr1::shared_ptr
构造函数;
但是执行顺序弹性很大。如果执行顺序如下;
- 执行
new
原始资源类创建一个原始资源; - 执行
func2
; - 调用
tr1::shared_ptr
构造函数;
现在,如果2发生异常,那么因为1创建的资源未被置入tr1::shared_ptr
内,因此会发生内存泄露。也就是说,在「资源被创建」和「资源被转换为资源管理对象」两个时间点之间有可能发生异常干扰。因此,应该使用独立语句
1 | std::tr1::shared_ptr<原始资源类>(new 原始资源类) p; |
四.设计与声明
条款18:让接口容易被正确使用,不易被误用
- 通过引入新类型来防止误用 上面日期类的构造函数中,年月日都是
1
2
3
4
5class Date{
public:
Date(int month,int day,int year);
...
}int
,那么很容易传入顺序错误的参数。因此,可以因为 3 个表示年月日的新类:Year、Month、Day。从而防止这种问题。更进一步,为了使得传入的数据有效,比如月份,可以设计生成 12 个月份对象的static
成员函数,并将构造函数声明为private
强制要求通过调用static
成员函数得到月份对象。使用enum
没有那么安全,enum
可被拿来当作一个int
使用; - 除非有好的理由,否则应该尽量让你的
type
的行为与内置类型一致:如if(a * b = c)
对内置类型来说不合法,那么你的type
在实现operator *
时就应该返回一个const
对象; - 提供一致的接口:如 C++ STL 容器都提供
size()
返回容器大小,但是 Java 和. Net 对于不同容器大小接口可能不同,这会增加使用负担; - 返回「资源管理对象」而不是原始资源:如用
shared_ptr
管理资源时,客户可能会忘记使用智能指针,从而开启了忘记释放和重复释放的大门。通过修改接口的返回类型为智能指针,从而确保元素资源处于「资源管理对象」的掌控之中;
条款19:设计class
犹如设计type
在设计class
时,下列问题将导致class
你的设计规范:
- 新
type
的对象应该如何被创建和销毁? - 对象的初始化和对象的赋值该有什么样的差别?
- 新
type
的对象如果被passed by value
,意味着什么? - 什么是新
type
的「合法值」? - 你的新
type
需要配合某个继承体系吗? - 你的新
type
需要什么样的转换? - 什么样的操作符和函数对此新
type
而言是合理的? - 什么样的标准函数应该驳回?
- 谁该取用新
type
的成员? - 什么是新
type
的「未声明接口」? - 你的新
type
有多么一般化? - 你真的需要一个新
type
吗?
条款20:宁以pass-by-reference-to-const
替换pass-by-value
pass-by-reference-to-const
有下列好处:
- 更高的效率:如果一个类处于继承体系的底部,并且包含大量成员,
pass-by-value
会导致大量的构造函数被调用,在函数调用完成后,又有大量的析构函数被调用; - 防止继承中的对象切割:如果是
pass-by-value
,并且传入一个子类对象时,传入的子类对象会被切割,只保有基类对象的部分,从而无法表现多态;
reference
往往以指针实现出来,因此 pass by reference
通常意味真正传递的是指针。因此,对于内置类型,pass by value
往往比 pass by reference
的效率更高。**pass by value
同样适用于 STL 的迭代器和函数对象**。
并不是所有小型对象都是 pass-by-value
的合格候选者:
- 对象小并不意味着
copy
构造函数不昂贵。许多对象——包括大多数 STL 容器——内含的东西比一个指针多一些,但是复制这种对象却需承担「复制那些指针所指的每一样东西」。那将非常昂贵; - 即使
copy
构造函数不昂贵,还是可能有效率上的争议。某些编译器对待「内置类型」和「用户自定义类型」的态度截然不同,纵使两者拥有相同的底层表述,「用户自定义类型」也不会被编译器放入缓存器,因此pass by reference
更适合;
可以合理假设「
pass-by-value
并不昂贵」的唯一对象就是内置类型和 STL 的迭代器和函数对象。其它任何时候,宁以pass-by-reference-to-const
替换pass-by-value
。
条款21:必须返回对象时,别妄想返回其reference
必须返回对象的最常见例子是运算符函数:
1 | const Rational operator*(const Rational &lhs,const Rational &rhs); |
在必须返回对象时,不要企图返回reference
,可以通过反面来说,也就是如果返回reference
会是什么情况?
- **在
stack
上构造一个局部对象,返回局部对象的reference
**;注意!使用1
2
3
4
5const Rational& operator*(const Rational &lhs,const Rational &rhs)
{
Rational result(lhs.n * rhs.n,lhs.d * rhs.d);
return result;
}reference
的本意是避免构造新对象,但是一个新的对象result
还是经由构造函数构造。更严重的是,这个局部对象在函数调用完成后就被销毁了,reference
将指向一个被销毁的对象。 - **在
heap
上构造一个局部对象,返回这个对象的reference
**;这样虽然1
2
3
4
5
6
7const Rational& operator*(const Rational &lhs,const Rational &rhs)
{
Rational *result = new Rational(lhs.n * rhs.n,lhs.d * rhs.d);
return *result;
}
Rational w,x,y,z;
w = x * y *z;reference
不再引用一个被销毁的对象,但是因为了动态内存分配的开销,而且谁该为delete
负责也成为问题。同时,在上面的连乘例子中,会多次动态分配内存,但是只返回最后一次的指针,因此会造成资源泄露。 - 构造一个
static
局部对象,每次计算结果保存在这个对象中,返回其reference
首先,显而易见的问题是这个函数在多线程情况下是不安全的,多个线程会修改相同的1
2
3
4
5
6
7
8const Rational& operator*(const Rational &lhs,const Rational &rhs)
{
static Rational result
result = ...;
return result;
}
Rational w,x,y,z;
if((w * x) == (y * z)){...}static
对象;除此之外,在上面的if
判断中,不管传入的w,x,y,z
是什么,由于operator*
传回的reference
都指向同一个static
对象,因此上面的判断永远都会为true
。
条款22:将成员变量声明为private
1)为什么不能是public
3个原因:
- 语法一致性:如果成员变量和成员函数一样,都是
public
,那么调用时会困惑于该不该使用括号。如果想获取大小时使用size
,但是这到底是一个成员变量还是一个成员函数? - 更精准的控制:通过将成员变量声明为
private
,通过成员函数提供访问,可以实现更精准的访问控制1
2
3
4
5
6
7
8
9
10
11
12
13class AccessLevels{
public:
...
int getReadOnly() const {return readOnly;}
void setReadWrite(int value) {readWrite = value;}
int getReadWrite() const {return readWrite;}
void setWriteOnly(int value) {writeOnly = value;}
private:
int noAccess; //对此int无访问动作
int readOnly; //对此int做只读访问
int readWrite; //对此int做读写访问
int writeOnly; //对此int做只写访问
}; - **封装 (主要)**:
private
将成员变量封装,如果通过public
暴露,在需要改成员变量的大量实现代码中,当这个成员变量被修改或删除时,所有直接访问该成员变量的代码将会变得不可用;
2)那么protected
行不行
protected
成员变量和 public
成员变量的论点十分相同。「语法一致性」和「细微划分的访问控制」等理由也适用于 protected
数据。同时,**protected
也并不具备良好的封装性。
假设有一个 public
成员变量,而我们最终取消了它。所以使用它的客户代码都会被破坏。因此,public
成员变量完全没有封装性。假设有一个 protected
变量,而我们最终取消了它,所有使用它的派生类都会被破坏。因此,protected
成员变量也缺乏封装性。
因此,从封装的角度看,只有 private
能提供封装性**。
条款23:宁以non-member
、non-friend
替换member
函数
假设有个浏览器类,包含一些功能用来清除下载元素高速缓冲区、清除访问过的URLs的历史记录、以及移除系统中的所有cookies:
1 | class WebBrowser{ |
此时,如果想整个执行所有这些动作,那么有两种选择,一种实现成member
函数,一种实现成non-member
函数:
1 | class WebBrowser{ |
问题是应该如何选择?这个问题主要在于封装性。
如果某些东西被封装,它就不再可见。越多东西被封装,越少人可以看到它。越少人看到它,就有越大的弹性去变化它,因为我们的改变仅仅直接影响看到改变的那些人事物。
因此,对于对象内的代码。越少代码可以看到数据(也就是访问它),越多的数据可被封装,我们也就越能自由地改变对象数据。作为一种粗糙的测量,越多函数可访问它,数据的封装性就越低。
条款 22 所说,成员变量应该是 private
。能够访问 private
成员变量的函数只有 class
的 member
函数加上 friend
函数而已。如果要在一个 member
函数和一个 non-member
,non-friend
函数之间做选择,而且两者提供相同机能,那么,导致较大封装性的是 non-member
,non-friend
函数,也就是本条款这样选择的原因。
条款24:若所有参数皆需类型转换,请为此采用non-member函数
为class
支持隐式类型转换不是个好主意,但是在数值类型之间颇为合理。考虑有理数和内置整形之间的相乘运算。具有如下有理数:
1 | class Rational{ |
现在,有理数提供了 Int-to-Rational 的隐式转换方式,那么 operator*
应该实现成 member
,还是 non-member
?
1 | class Rational{ |
问题发生在混合运算上。如果实现成member,那么下面的混合运算只有一半行得通:
1 | result = oneHalf * 2; // OK |
因为内置类型 int
并没有相应的 class
,也就没有 operator*
成员函数。所以后者会出错。但是当实现为 non-member
时,具有 2 个参数,都能通过 int
转换为 Rational
,所以上面 2 行代码都能运行。因此,若所有参数皆需类型转换,请为此采用 non-member
函数。
条款25:考虑写出一个不抛出异常的swap函数
「以指针指向一个对象,内含真正数据」。这种设计的常见表现形式是所谓的「PIMPL (Pointer to IMPLementation)手法」。如下,WidgetImpl 包含了 Widget 的真正数据,而 Widget 只包含一个 WidgetImpl 类型的指针,指向一个 WidgetImpl 对象。这种设计特点,决定了 Widget 的 copying 行为应该表现出一种「深拷贝」的行为:
1 | class WidgetImpl { |
因此,如果使用标准库的 swap
交换 2 个 Widget 对象,会引起 WidgetImpl 对象的拷贝,由于其内含有 Widget 的大量数据,因此效率可能十分低。实际上这种情况下,交换 2 个指针就可以了。为此,我们可能实现出 swap
特化版来提升效率,但是由于其内直接访问 Widget 的 private
成员,因此无法通过编译。所以我们采用下图右下角的方案,在 Widget 类内实现一个 public
的 swap
函数,然后特化版的 swap
调用这个 public
的 swap
函数:
1 | class Widget { |
总结起来就是:
- 首先,如果
swap
的缺省实现对你的class
或class template
提供可接受的效率,那不需要做额外的事 - 否则,如果
swap
的缺省实现效率不足(那几乎总是意味着你的class
或template
使用了某种pimpl
手法),试着做以下事情:- 提供一个
public swap
成员函数,让它高效地置换你的类型的两个对象的值(这个public swap
成员函数绝不应该抛出异常。这个约束不可施行于非成员版,因为swap
缺省版是以copy
构造函数和copy assignment
操作符为基础,而一般情况下两者都允许抛出异常。因此当你写下一个自定义版本的swap
,往往提供的不只是高效置换对象值的方法,而且不抛出异常。一般而言这两个swap
特性是连在一起的,因为高效的swap
几乎总是基于对内置类型的操作,而内置类型上的操作绝对不会抛出异常) - 在你的
class
或template
所在的命名空间内提供一个non-member swap
,并令它调用上述swap
成员函数 - 如果你正编写一个
class
(而非class template
),为你的class
特化std::swap
。并令它先调用你的swap
成员函数
- 提供一个
- 最后,如果你调用
swap
,请确定包含一个using
声明,以便让std::swap
在你的函数内曝光可见,然后不加任何namespace
修饰符地调用swap
;
五.实现
条款26:尽可能延后变量定义式的出现时间
只要定义了一个变量而其类型带有一个构造函数或析构函数,那么
- 当程序的控制流到达这个变量定义式时,你便得承受构造成本;
- 当这个变量离开作用域时,你便得承受析构成本;
即使这个变量最终并未被使用,仍需耗费这些成本,所以你应尽可能避免这种情形,即延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。
当考虑循环时,有下列 2 种情况:
1 | // 定义于循环外 |
2种写法的成本如下;
- 做法A:1个构造函数 + 1个析构函数 + n个赋值操作
- 做法B:n个构造函数 + n个析构函数
从效率上看:如果 class 的一个赋值成本低于一组构成+析构成本,做法 A 大体而言比较高效,尤其当 n 比较大时。否则做法 B 或许更好。
从可理解性和维护性上看:A 造成名称 w 的作用域比做法 B 更大,可理解性和维护性相对较差。
因此,仅在一下情况考虑使用方法 A:
- 你知道赋值成本比「构造 + 析构」成本低;
- 你正在处理代码中效率高度敏感的部分;
条款27:尽量少做转型动作
转型分类:
- C风格的转型
1
2(T)expression //将expression转型为T
T(expression) //将expression转型为T - C++提供的新式转型
1
2
3
4const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)- **
static_cast
**:只要不包含底层const
,都可以使用。适合将较大算术类型转换成较小算术类型(主要用于将派生类对象转为基类对象); - **
const_cast
**:只能改变底层const
,例如指向const
的指针 (指向的对象不一定是常量,但是无法通过指针修改),如果指向的对象是常量,则这种转换在修改对象时,结果未定义; - **
reinterpret_cast
**:通常为算术对象的位模式提供较低层次上的重新解释。如将int*
转换成char*
。很危险! - **
dynamic_cast
:一种动态类型识别。转换的目标类型,即type
,是指针或者左右值引用,主要用于基类指针转换成派生类类型的指针(或引用)**,通常需要知道转换源和转换目标的类型。如果转换失败,返回0(转换目标类型为指针类型时)或抛出bad_cast
异常(转换目标类型为引用类型时)
- **
应该尽可能使用新式转型:
- 它们很容易在代码中被辨别出来(无论是人工还是使用工具如
grep
),因而得以简化「找出类型系统在哪个地点被破坏」的过程; - 各转型动作的目标越窄化,编译器越可能诊断出错误的运用;
尽量少做转型:
转型不只是告诉编译器把某种类型视为另一种类型这么简单。任何一个转型动作往往令编译器编译出运行期间执行的代码;
1
2
3
4
5
6
7
8
9//示例一
int x,y;
...
double d = static_cast<double>(x)/y;
//示例二
class Base {...};
class Derived : public Base {...};
Derived d;
Base *pd = &d; //隐式地将Derived*转换为Base*- 在示例一中:
int
转型为double
几乎肯定会产生一些代码,因为在大部分体系结构中,int
的底层表述不同于double
的底层表述; - 在示例二中:会有个偏移量在运行期被实施于
Derived*
指针身上,用以取得正确的Base*
地址;
- 在示例一中:
很容易写出似是而非的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class Window{
public:
virtual void onResize() {...}
...
}
//错误的做法
class SpecialWindow: public Window{
public:
virtual void onResize(){
static_cast<Window>(*this).onResize();
... //这里进行SpecialWindow专属行为
}
...
}
//正确的做法
class SpecialWindow: public Window{
public:
virtual void onResize(){
Window::onResize(); //调用Window::onResize作用于*this身上
... //这里进行SpecialWindow专属行为
}
...
}继承中的类型转换效率低
- C++通过
dynamic_cast
实现继承中的类型转换,dynamic_cast
的大多数实现效率都是相当慢的。因此,应该避免继承中的类型转换。一般需要dynamic_cast
,通常是因为想在一个认定为 derivedclass
对象身上执行derived class
操作,但是拥有的是一个「指向base
」的指针或引用。这种情况下有 2 种办法可以避免转型:- 使用容器并在其中存储直接指向
derived class
对象的指针:这种做法无法在同一个容器内存储指针「指向所有可能的各种派生类」。如果真要处理多种类型,可能需要多个容器,它们都必须具备类型安全性; - 将
derived class
中的操作上升到base class
内,成为virtual
函数,base class
提供一份缺省实现:缺省实现代码可能是个馊主意,条款 34 中有分析,但是也比使用dynamic_cast
来转型要好;
- 使用容器并在其中存储直接指向
- C++通过
条款28:避免返回handles
指向对象内部成分
引用、指针和迭代器统统都是所谓的 handles
。
1)增加封装性
如果成员函数返回 handles
,那么相当于成员变量的封装性从 private
上升到 public
。这与条款22 相悖。
2)使得「通过const修改对象的数据」成为可能
在条款25 提到过「PIMPL 手法」,即:「以指针指向一个对象,内含真正数据」,也就是对象只包含指针成员,实际数据通过这个指针指向。而在条款3 中也提到,C++对 const
成员函数的要求是,符合 bitwise constness。因此,const
成员函数返回一个这个指针所指对象的引用,并不会造成指针被修改,也就符合 bitwise constness,但是通过这个引用却可以改变对象实际的数据。
3)防止「虚吊」(dangle)发生
如果返回的 handles
指向一个临时对象,那么返回后临时对象销毁,handles
会成为「虚吊的」。只要 handle
被传出去,就会面临「handle
比其所指对象更长寿」的风险。
条款29:为「异常安全」而努力是值得的
考虑下面例子,有一个菜单类,changeBg
函数可以改变它的背景,切换背景计数,同时提供线程安全:
1 | class Menu{ |
1)异常安全的2个条件
异常安全有2个条件:
- 不泄露任何资源:即发生异常时,异常发生之前获得的资源都应该释放,不会因为异常而泄露。在上面的例子中,如果
new Image
发生异常,那么unlock
就不会调用,因此锁资源会泄露; - 不允许数据败坏:上面的例子也不符合,如果
new Image
异常,背景图片会被删除,计数也会改变。但是新背景并未设置成功;
对于资源泄露,条款13 讨论过以对象管理资源。锁资源也可以为 shared_ptr
指定「删除器」,当引用为 0 时,即异常发生,管理所资源的对象被销毁后,删除器会调用 unlock
。
对于数据败坏:见下文。
2)异常安全函数的3个保证
- 基本承诺:抛出异常后,对象仍然处于合法(valid)的状态,但不确定处于哪个状态;
- 强烈保证:如果抛出了异常,状态并不会发生任何改变。就像没调用这个函数一样;
- 不抛掷保证:这是最强的保证,函数总是能完成它所承诺的事情(作用于内置类型身上的所有操作都提供
no throw
保证。这是异常安全代码中一个必不可少的关键基础)
copy and swap
策略:「copy and swap
」设计策略通常能够为对象提供异常安全的「强烈保证」。当我们要改变一个对象时,先把它复制一份,然后去修改它的副本,改好了再与原对象交换。关于 swap 的详细讨论可以参见条款25。这种策略用在前面的例子中会像这样:
1 | class Menu{ |
copy and swap
策略能够为对象提供异常安全的「强烈保证」。但是一般而言,它并不保证整个函数有「强烈保证」。也就是说,如果某个函数使用 copy and swap
策略为某个对象提供了异常安全的「强烈保证」。但是这个函数可能调用其它函数,而这些函数可能改变一些全局状态(如数据库状态),那么整个函数就不是「强烈保证」。
函数提供的「异常安全保证」通常最高只等于其所调用的各个函数的「异常安全保证」中的最弱者。
除此之外,copy and swap
必须为每一个即将被改动的对象作出一个副本,从而可能造成时间和空间上的问题。
3)最终目标是什么
当「强烈保证」不切实际时(比如前面提到的全局状态改变难以保证,或者效率问题),就必须提供「基本保证」。现实中你或许会发现,可以为某些函数提供强烈保证,但效率和复杂度带来的成本会使它对许多人而言摇摇欲坠。
总的来说就是,应该为自己的函数努力实现尽可能高级别的异常安全,但是由于种种原因并不是说一定需要实现最高级别的异常安全,而是应该以此为目标而努力。
条款30:透彻了解inlining的里里外外
inline
的优缺点:
- 优点:
- 较少函数调用的开销
- 编译器对
inline
的优化
- 缺点:
- 目标代码的增加,程序体积增大,导致额外的换页行为,降低指令高速缓存装置的命中率
inline
提出方式包括 2 种:1)显式提出;2)隐式提出(类内实现成员函数)。inline
在大多数 C++程序中是编译期行为。inline
只是对编译器的一个申请,不是强制命令。大多数编译器提供了一个诊断级别:如果它们无法将你要求的函数 inline
化,会给出一个警告。
对 virtual
函数的调用也都会使 inlining
落空。因为 virtual
意味着等待,直到运行期才确定调用哪个函数,而 inline
意味着执行前,先将调用动作替换为被调用函数的本体。
如果程序要取某个 inline
函数的地址,编译器通常必须为此函数生成一个 outlined
函数本体。毕竟编译器没有能力提出一个指针指向并不存在的函数。
大部分的调试器面对 inline
函数都束手无策。因为无法在一个不存在的函数内设立断点。因此,一个合乎逻辑的策略是,一开始先不要将任何函数声明为 inline
,或至少将 inlining
施行范围局限在那些「一定称为 inline
」或「十分平淡无奇」的函数身上。
条款31:将文件间的编译依存关系将至最低
C++并没有把「将接口从实现中分离」这件事做得很好。例如:
1 |
|
如果没有前面 3 行引入头文件,那么编译无法通过。但是如此却会在 Person
定义文件和其含入文件之间形成了一种编译依存关系。如果这些头文件中有任何一个被改变,或这些文件所依赖的其它头文件有任何改变。那么每个含入 Person class
的文件就得重新编译,任何使用 Person class
的文件也必须重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。
**1)一个办法是,可以把 Person
分割为两个类:
- 一个只提供接口 (
Person
); - 一个负责实现接口 (
PersonImpl
);就是使用条款25 中的 PIMPL 手法:接口class
中只包含一个负责实现接口的class
的指针,因此任何改变都只是在负责实现接口的class
中进行。那么Person
的客户就完全与Date
,Address
,以及Person
的实现细目分离了。那些classes
的任何实现修改都不需要Person
客户端重新编译。此外,由于客户无法看到Person
的实现细目,也就不可能写出什么「取决于那些细目的代码」。这正是接口与实现分类。这种情况下,像Person
这样使用 PIMPL 的classes
往往被称为handle classes
;1
2
3
4
5
6
7
8
9
10
11class Person{
public:
Person(string& name);
string name() const;
private:
shared_ptr<PersonImpl> pImpl;
};
Person::Person(string& name): pImpl(new PersonImpl(name)){}
string Person::name(){
return pImpl->name();
}
**2)另一种制作 handle class
的办法是,令 Person
成为一种特殊的 abstract base class
,称为 interface class
。其目的是详细描述 derived classes
的接口,因此它通常不带成员变量,也没有构造函数,只有一个 virtual
析构函数以及一组 pure virtual
函数,用来叙述整个接口。
1 | class Person{ |
客户不能实例化它,只能使用它的引用和指针。然而客户一定需要某种方法来获得一个实例,比如工厂方法:
1 | class Person{ |
应该让头文件自我满足,万一做不到,则让它与其他头文件内的声明式相依。其他每一件事都源自于这个简单的设计策略:
- 如果使用
object references
或object pointers
可以完成任务,就不要使用objects
- 如果能够,尽量以
class
声明式替换class
定义式:1
2
3class Date;
Date today();
void clearAppointments(Date d); - 为声明式和定义式提供不同的头文件。为了促使这个准则,需要两个头文件:一个用于声明式,一个用于定义式。因此,上面的例子应该是这样:
1
2
3
Date today();
void clearAppointments(Date d);
六.继承与面向对象设计
条款 32:确定你的 public
继承塑模出 is-a
关系
public
继承隐含的寓意:每个派生类对象同时也是一个基类对象 (反之不成立),只不过基类比派生类表现出更一般化的概念,派生类比基类表现出更特殊化的概念。
因此,C++中,任何函数如果期望获得一个类型为基类的实参,都也愿意接收一个派生类对象。但是反之不成立。谨记这种 is-a
关系以及背后隐藏的规则可以防止因为「经验主义」而使用不合理的继承。
条款33:避免遮掩继承而来的名称
1)继承中的作用域嵌套
名字查找会从内层作用域向外层作用域延伸
2)名称遮掩会遮掩基类所有重载版本
派生类中同名的名称会遮掩基类中相同的名称,如果基类包含重载函数,所有重载函数都会被遮掩。
解决办法是使用 using
引入被遮掩的名字。
如果只想引入基类被遮掩函数中某个版本(注意,这种需求一般只在 private 继承中出现,因为如果只继承基类的部分操作,违背了条款32),可以直接定义一个同名同参的函数,然后在这个函数内调用基类的版本,做一个转调用。这实际上称为一种实现技术 (而不是引入) 更为恰当。
条款34:区分接口继承和实现继承
纯虚函数一般作为接口,基类一般不提供定义,但是基类可以为纯虚函数提供定义。派生类必须声明纯虚函数,如果想要使用纯虚函数,派生类必须提供一份定义,即使基类已经为该纯虚函数提供了定义。如果派生类不提供定义,仍然是一个抽象基类
- 声明一个
pure virtual
函数的目的是为了让derived classes
只继承函数接口; - 声明
impure virtual
函数的目的,是让derived classes
继承该函数的接口和缺省实现; - 声明
non-virtual
函数的目的是为了令derived classes
继承函数的接口及一份强制性实现;
1)pure virtual
函数
如果某个操作不同派生类应该表现出不同行为,并且没有相同的缺省实现,那么应该使用 pure virtual
函数,此时派生类只继承接口。
2)impure virtual
函数
如果某个操作不同派生类应该表现出不同行为,并且具有相同的缺省实现,那么应该使用 impure virtual
函数,此时派生类继承接口和缺省实现。
但是,允许 impure virtual
函数同时指定函数声明和缺省行为,却可能造成危险:假设引入了一个新的派生类,但是缺省行为并不适用于新的派生类,而新的派生类忘记重新定义新的行为,那么调用该操作将表现出缺省行为,这是不合理的。
由于任何派生类想要使用 pure virtual
函数都必须提供一份定义,那么如果想要使用缺省行为,可以直接在定义中调用基类的实现。否则,可以定制特殊的行为。因为是纯虚函数,只要不定义就无法使用,因此也可以避免前面的问题。
3)non-virtual
函数
如果某个操作在整个体系中,应该表现出一致的行为,那么应该使用 non-virtual
函数。此时派生类继承接口和一份强制性实现。
条款 35:考虑 virtual
函数以外的其他选择
在面向对象中,如果希望某个操作存在缺省算法,并且各派生类可以定制适合自己的操作。可以使用 public virtual
函数,这是最简单直白且容易想到的方法,但是除此之外,也存在其它可替代的方案。它们有各自的优缺点,应该将所有方案全部列入考入。
以一个例子来介绍其它几种可替代方案。在一个游戏人物的类中,存在一个健康值计算的函数,不同的角色可以提供不同的健康值计算方法,并且存在一个缺省实现。以传统的 public virtual
函数实现如下:
1 | class GameCharacter{ |
1)藉由Non-Virtual Interface手法实现Template Method模式
这种方案的主要思想是:保留 healthValue
为 public
成员,但是让其成为 non-virtual
,并调用一个 private
(也可以是 protected
) virtual
函数进行实际工作:
1 | class GameCharacter{ |
NVI 手法的一个优点是可以在真正操作进行的前后保证一些「事前」和「事后」工作一定会进行。如「事前」进行一些锁的分配,日志记录。「事后」进行解锁等操作。
NVI(Non-Virtual Interface)是一种设计模式,用于在C++中实现将虚函数的调用封装在非虚成员函数中,以提供更好的灵活性和安全性。
2)藉由Function Pointers实现Strategy模式
上面的方案本质还是使用 virtual
函数,人物的健康值计算 (操作) 还是与人物 (类) 相关。后面这几种方案,都是将任务的健康值计算 (操作) 与具体的每个人 (对象) 相关,并且可以每个人 (对象) 的健康值计算 (操作) 可以修改。
1 | class GameCharacter; //前置声明 |
每个人物(类)包含一个计算健康值的函数指针,每创建一个人(对象)时,可以为其指定不同的健康值计算函数。因此将操作和类分离。同时,如果提供修改函数指针成员的方法,每个对象还能使用不同的计算方法
3)藉由tr1::function完成Strategy模式
这种方案是前一种的加强,将函数指针改成任何可调用对象。因此允许任何与可调用声明相兼容(即可以通过类型转换与声明相符)的可调用物
1 | class GameCharacter; //前置声明 |
4)传统的Stategy模式
传统的 Stategy 模式做法会将健康计算函数做成一个分离的继承体系中的 virtual
成员函数,设计结果看起来像这样:
1 | class GameCharacter; //前置声明 |
这个方案的吸引力在于,熟悉标准 Strategy 模式的人很容易辨认它,而且它还提供「将一个既有的健康算法纳入使用」的可能性——只要为 HealthCalcFunc
继承体系添加一个 derived class
即可。
条款36:绝不重新定义继承而来的non-virtual
函数
从规范上说,条款34 提到,如果某个操作在整个继承体系应该是不变的,那么使用 non-virtual
函数,此时派生类从基类继承接口以及一份强制实现。如果派生类希望表现出不同行为,那么应该使用 virtual
函数。
另一方面,假设真的重新定义了继承而来的 non-virtual
函数,会表现出下列令人困惑的情况:
1 | class B{ |
你可能会觉得因为 pB
和 pD
指向的是相同的对象,因此调用的 non-virtual
函数也应该相同,但是事实并非如此。因为 non-virtual
函数是静态绑定,因此实际上调用的函数由指针或引用决定。
条款37:绝不重新定义继承而来的缺省参数值
条款36 论述了 non-virtual
函数不应该被重新定义,那么 non-virtual
函数中的参数也就不存在被重新定义的机会。因此这里主要针对的是 virtual
函数。
原因就在于,virtual
函数是动态绑定,而缺省参数值却是静态绑定。所以你可能调用了一个派生类的 virtual
函数,但是使用到的缺省参数,却是基类的。
1 | class Shape{ |
但是,即使派生类严格遵循基类的缺省参数,也存在问题:当基类的缺省参数发生变化时,派生类的所有缺省参数也需要跟着修改。因此,本质在于,不应该在virtual
函数中使用缺省参数,如果有这样的需求,那么这种场景就适合使用条款35中,public virtual
函数的几种替代方案,比如NVI手法:
1 | class Shape{ |
条款 38:通过复合塑模出 has-a
或「根据某物实现出」
复合是类型间的一种关系,当某种类型的对象含有另一种类型的对象,便是这种关系。
复合意味着 has-a
(有一个) 或 is-implemented-in-terms-of
(根据某物实现出)。
has-a
:1
2
3
4
5
6
7
8
9
10
11class Address {...};
class PhoneNumber {...};
class Person{
public:
...
private:
std::string name;
Address address;
PhoneNumber voiceNumber;
PhoneNumber faxNumber;
};- 根据某物实现出:
1
2
3
4
5
6
7template <class T, class Sequence = deque<T> >
class stack {
...
protected:
Sequence c; //底层容器
...
};
上面两者情况都应该使用复合,而不是 public
继承。在 has-a
中,每个人肯定不是一个地址,或者电话。显然不能是 is-a
的关系。而对于后者,由于每个栈只能从栈顶压入弹出元素,而队列不同,is-a
的性质是所有对基类为 true
的操作,对派生类也应该为 true
。所以 stack
也不应该通过 public
继承 deque
来实现,因此使用复合。
条款 39:明智而审慎地使用 private
继承
private
继承和 public
继承的不同之处:
- 编译器不会把子类对象转换为父类对象; 如果使用
1
2
3
4
5
6
7class Person { ... };
class Student: private Person { ... }; // private继承
void eat(const Person& p); // 任何人都会吃
Person p; // p是人
Student s; // s是学生
eat(p); // 没问题,p是人,会吃
eat(s); // 错误!难道学生不是人?!public
继承,编译器在必要的时候可以将Student
隐式转换成Person
,但是private
继承时不会,所以eat(s)
调用失败。从这个例子中表达了,private
继承并不表现出is-a
的关系。实际上**private
表现出的是is-implemented-in-terms-of
的关系。 - **父类成员(即使是
public
、protected
)都变成了private
**;
条款38提到,复合也是可以表现出is-implemented-in-terms-of
的关系,那么两者有什么区别?
1)private
继承
假设 Widget
类需要执行周期性任务,于是希望继承 Timer
的实现。因为 Widget
不是一个 Timer
,所以选择了 private
继承:
1 | class Timer { |
在 Widget
中重写虚函数 onTick
,使得 Widget
可以周期性地执行某个任务。
通过 private
继承来表现 is-implemented-in-terms-of
关系实现非常简单,而且下列情况也只能使用这种方式:
- 当
Widget
需要访问Timer
的protected
成员时。因为对象组合后只能访问public
成员,而private
继承后可以访问protected
成员。 - 当
Widget
需要重写Timer
的虚函数时。比如上面的例子中,需要重写onTick
。单纯的复合是做不到的。
2)复合
如果使用复合,上面的例子可以这样实现:
1 | class Widget { |
通过复合来表现 is-implemented-in-terms-of
关系,实现较为复杂,但是具有下列优点:
- 如果希望禁止
Widget
的子类重定义onTick
。因为派生类无法访问私有的WidgetTimer
类; - 可以减小
Widget
和Timer
的编译依赖。如果是private
继承,在定义Widget
的文件中势必需要引入#include "timer.h"
。但如果采用复合的方式,可以把WidgetTimer
放到另一个文件中,在Widget
中使用WidgetTimer*
并声明WidgetTimer
即可;
总的来说,在需要表现 is-implemented-in-terms-of
关系时。如果一个类需要访问基类的 protected
成员,或需要重新定义其一个或多个 virtual
函数,那么使用 private
继承。否则,在考虑过所有其它方案后,仍然认为 private
继承是最佳办法,才使用它。
条款40:明智而审慎地使用多重继承
使用多继承时,一个问题是不同基类可能具有相同名称,产生歧义(即使一个名字可访问,另一个不可访问)。
一般有两种方式使用多继承:
- 一般的多重继承
- 如果某个基类到派生类之间存在多条路径,那么派生类会包含重复的基类成员
- 虚继承(此时基类是虚基类)
- 如果某个基类到派生类之间存在多条路径,派生类只包含一份基类成员,但是这会带来额外开销
- 为避免重复,编译器必须提供一些机制,后果就是
virtual
继承的那些classes
所产生的对象往往比non-virtual
继承的体积大,访问virtual base classes
的成员变量时,速度也更慢; virtual base
的初始化由继承体系中的最底层class
负责,这会带来开销classes
若派生自virtual bases
而需要初始化,必须认知其virtual bases
——无论那些bases
距离多远- 当一个新
derived class
加入继承体系中,它必须承担其 virtual bases
的初始化责任
- 为避免重复,编译器必须提供一些机制,后果就是
- 如果某个基类到派生类之间存在多条路径,派生类只包含一份基类成员,但是这会带来额外开销
如果你有一个单一继承的设计方案,而它大约等价于一个多重继承方案,那么单一继承设计方案几乎一定比较受欢迎。如果你唯一能够提出的设计方案涉及多重继承,你应该更努力想一想——几乎可以说一定会有某些方案让单一继承行得通。然而多重继承有时候是完成任务的最简洁、最易维护、最合理的做法,果真如此就别害怕使用它。只要确定,你的确是在明智而审慎的情况下使用它
七.模板与泛型编程
条款41:了解隐式接口和编译器多态
面向对象设计中的类(class
)考虑的是显式接口(explicit interface
)和运行时多态, 而模板编程中的模板(template
)考虑的是隐式接口(implicit interface
)和编译期多态。
- 对类而言,显式接口是由函数签名表征的,运行时多态由虚函数实现;
- 对模板而言,隐式接口是由表达式的合法性表征的,编译期多态由模板初始化和函数重载的解析实现;
条款 42:了解 typename
的双重意义
以下代码中, typename
和 class
等价:
1 | template<class T> class Widget; |
但是如果在 template
中,遇到嵌套从属名称,需要明确声明是一种类型时,必须使用 typename
。考虑如下例子:
1 | template<typename C> |
我们认为 C::const_iterator
表示容器 C
的迭代器类型,因此上述代码定义一个该迭代器类型的指针。但是这是一种先入为主的思想。如果 C:: const_iterator
不是一个类型呢?比如恰巧有个 static
成员变量被命名为 const_iterator
,或如果 x
碰巧是个 global
变量名称?那样的话上述代码就不再是声明一个 local
变量,而是一个相乘动作。
因此,C++有个规则解决这种歧义:如果解析器在 template
中遭遇一个嵌套从属名称,它便假设这个名称不是个类型,除非你告诉它是。所以缺省情况下嵌套从属名称不是类型。那么怎么告诉它是一个类型,当然就是 typename
了,所以上述代码应该像这样:
1 | template<typename C> |
因此,规则是:除了下面 2 个例外,任何时候当你想要在 template
中指涉一个嵌套从属类型名称,就必须在紧临它的前一个位置放上关键字 typename
:
typename
不可出现在base classes list
内的嵌套从属名称之前;typename
也不可出现在成员初始值列表中作为base class
修饰符;1
2
3
4
5
6
7
8template<typename T>
class Derived : public Base<T>::Nested{ //typename不可出现在此
public:
explict Derived(int x) : Base<T>::Nested(x) //typename也不可出现在此
{
typename Base<T>::Nested temp; //这里必须使用typename
}
};
typename
相关规则在不同的编译器上有不同的实践。某些编译器接收的代码原本该有 typename
却遗漏了;原本不该有 typename
却出现了;还有少数编译器(通常是较旧版本)根本就拒绝 typename
。这意味 typename
和「嵌套从属名称」之间的互动,也会在移植性方面给你带来一些麻烦。
条款43:学习处理模板化基类内的名称
假设以下 MsgSender
类可以通过两种方式发送信息到各个公司:
1 | template<typename Company> |
假设我们有时候想要在每次送出信息时志记(log)某些信息。因此有了以下派生类:
1 | template<typename Company> |
现在问题是,如果有一个公司 CompanyZ
只支持加密传送,那么泛化的 MsgSender
就不适合,因此需要为其产生一个特化版的 MsgSender
:
1 | template<> |
因此,当 base class
被指定为 MsgSender<CompanyZ>
时,其内不包含 sendClear
方法,那么 derived class LoggingMsgSender
的 sendClearMsg
方法就会调用不存在 sendClear
。
因此,正是因为知道 base class templates
有可能被特化,而那么特化版本可能不提供和一般性 template
相同的接口。因此 C++往往拒绝在 templatized base classes
(模板化基类,本例的 MsgSender<Company>
)内寻找继承而来的名称(本例的 SendClear
)。
解决办法有 3 个,它们会通知编译器: 进入 base class
作用域查找继承而来的名称:
- **使用
this->
**;1
2
3
4
5
6
7
8
9
10
11
12template<typename Company>
class LoggingMsgSender : public MsgSender<Company>{
public:
...
void sendClearMsg(...)
{
//将「传送前「的信息写至log;
this->sendClear(...); //成立,假设sendClear将被继承
//将」传送后「的信息写至log;
}
...
}; - **使用
using
**;1
2
3
4
5
6
7
8
9
10
11
12
13
14template<typename Company>
class LoggingMsgSender : public MsgSender<Company>{
public:
//告诉编译器,请它假设sendClear位于base class内
using MsgSender<Company>::sendClear;
...
void sendClearMsg(...)
{
//将「传送前「的信息写至log;
sendClear(...); //成立,假设sendClear将被继承
//将」传送后「的信息写至log;
}
...
}; - 通过作用域符明确指出; 这种方法往往最不让人满意,因为如果被调用的是
1
2
3
4
5
6
7
8
9
10
11
12template<typename Company>
class LoggingMsgSender : public MsgSender<Company>{
public:
...
void sendClearMsg(...)
{
//将「传送前「的信息写至log;
MsgSender<Company>::sendClear(...); //成立,假设sendClear将被继承
//将」传送后「的信息写至log;
}
...
};virtual
函数,这样会关闭「virtual
绑定行为」。
要注意的是,它们只是通知编译器进去查找。如果找到了自然是没问题。但是如同上面的 CompanyZ
,如果基类还是不存在相应名称,编译器还是会报错
条款 44:将与参数无关的代码抽离 templates
模板提供的是编译期的多态,即使你的代码看起来非常简洁短小,生成的二进制文件也可能包含大量的冗余代码。因为模板每次实例化都会生成一个完整的副本,所以其中与模板参数无关的部分会造成代码膨胀。 把模板中参数无关的代码重构到模板外便可以有效地控制模板产生的代码膨胀:
- 对于「非类型模板参数」产生的代码膨胀,用函数参数或成员变量来替换模板参数即可消除冗余; 最后是父类如何访问矩阵数据。原本这些数据在派生类中,但是因为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30//非类型模板参数造成代码膨胀
template<typename T, int n>
class Square{
public:
void invert(); //求逆矩阵
};
//以下会实例化两个类:Square<double, 5>和Square<double, 10>
//会具现化两份invert。除了常量5和10,两个函数的其它部分完全相同
Square<double, 5> s1;
Square<double, 10> s2;
s1.invert();
s2.invert();
//以下,使用函数参数消除重复
template<typename T>
class SquareBase{
protected:
//以下函数只是作为避免代码重复的方法,并不应该被外界调用,
//同时,该函数希望被子类调用,因此使用protected
void invert(int size);
};
template<typename T, int n>
class Square:private SquareBase<T>{//只要T相同,都会使用同一份父类实例,
private: //因此,只有一份invert(int size)
using SquareBase<T>::invert;
public:
//调用父类invert的代价为零,因为Square::invert是隐式的inline函数
void invert(){ this->invert(n); }
}invert
核心代码转移到了父类,那么父类必须有办法访问这些数据。可以在调用SquareBase:: invert
时把内存地址也一起告知父类,但如果矩阵类中有很多函数都需要这些信息就需要为每个函数添加一个这样的参数。因此,可以把数据地址直接放在父类中。 - 对于类型模板参数产生的代码膨胀,可以让不同实例化的模板类共用同样的二进制表示;
int
和long
在多数平台都是一样的底层实现,然而模板却会实例化为两份,因为它们类型不同;List<int *>
,List<const int *>
,List<double *>
的底层实现也是一样的。但因为指针类型不同,也会实例化为多份模板类;
如果某些成员函数操作强型指针(T*
),应该令它们调用另一个操作无类型指针 (void*
) 的函数,后者完成实际工作。
条款45:运用成员函数模板接受所有兼容类型
需要使用成员函数模板的一个例子是构造函数和 copying
赋值运算符。例如,假设 SmartPtr
是一种智能指针,并且它是一个 template class
。现在有一个继承体系:
1 | class Top {...}; |
现在希望通过一个 SmartPtr<Bottom>
或 SmartPtr<Middle>
来初始化一个 SmartPtr<Top>
。如果是指针,即 Middle*
和 Bottom*
可以隐式转换成 Top*
,问题是:同一个 template
的不同具现体之间不存在什么与生俱来的固有关系,即使具现体之间具有继承关系。因此,**SmartPtr<Bottom>
或 SmartPtr<Middle>
并不能隐式转化成 SmartPtr<Top>
**。因此,我们需要一个构造函数模板,来实现这种转换:
1 | template<typename T> |
我们当然不希望一个 SmartPtr<Top>
可以转化成 SmartPtr<Bottom>
或 SmartPtr<Middle>
,
1 | 最后需要指明的是:**`member templates`并不改变语言规则**,而语言规则说,如果程序需要一个 `copy`构造函数,你却没声明它,编译器会为你暗自生成一个。因此,使用 `member templates` 实现一个泛化版的`copy`构造函数时,编译器也会合成一个「正常的」`copy`构造函数。 |
将 oneHalf
传递给 operator*
时,它将 T
推断为 int
,因此期待第二个参数也为 Rational
,但是第二个参数为 int
,前面我们说了,template
实参推导过程中从不将隐式类型转换函数纳入考虑。因此编译错误。
那么解决办法是什么?在 class template
将其声明为 friend
,从而具现化一个 operator*
,具现化后就可以不受 template
的限制了:
1 | template<typename T> |
如果上面只有函数声明,而函数定义在类外,那么会报链接错误。当传入第一个参数 oneHalt
时,会具现化 Rational<int>
,编译器也就知道了我们要调用传入两个 Rational<int>
的版本,但是那个函数只在类中进行了声明,并没有定义,不能依赖类外的 operator* template
提供定义,我们必须自己定义,所以会出现链接错误。解决方法就是像上面一样定义与类内。
这样看起来有点像是 member
函数,但是因为 friend
关键字,所以实际是 non-member
函数,如果去掉 friend
关键字,就成了 member
函数,但是此时参数也只能有 1 个,就不能实现所有参数的隐式转换。
上面的代码可能还有一个问题,虽然有 friend
,上述函数仍是隐式的 inline
。如果函数实体代码量较大,可以令 operator*
不做任何事,只调用一个定义与 class
外部的辅助函数(当然这里没必要,因为本身只有 1 行)
1 | template<typename T> class Rational; |
条款47:请使用traits classes
表现类型信息
Traits classes
使得「类型相关信息」在编译期可用。它们以templates
和「templates
特化」完成实现;- 整合重载技术后,
traits classes
有可能在编译期对类型执行if...else
测试;条款48:认识template元编程
- Template metaprogramming(TMP)是编写 template-based C++程序并执行于编译期的过程;
- Template metaprogram (模板元程序) 是以 C++写成、执行于 C++编译器内的程序;
TMP 的两个重要特点:1)基于 template;2)编译期执行。
TMP 有 2 个伟大的效力:
- 它让某些事情更容易。如果没有它,那些事情将是困难的,甚至不可能的;
- 执行于编译期,因此可将工作从运行期转移到编译期。会导致以下几个结果
- 某些原本在运行期才能侦测到的错误现在可在编译期找出来;
- 使用 TMP 的 C++程序可能在每一方面都更高效:较小的可执行文件、较短的运行期、较少的内存需求;
- 编译时间变长了;
traits
解法就是 TMP,traits
引发「编译器发生于类型身上的 if...else
计算」。
另一个 TMP 的例子是循环,TMP 并没有真正的循环构件,所以循环效果藉由递归完成。TMP 的递归甚至不是正常种类,因为 TMP 循环并不涉及递归函数调用,而是涉及「递归模板具现化」。以计算阶乘为例子:
1 | template<unsigned n> |
TMP能够达到以下目标(这部分可以等有实际需求了再去详细了解):
- 确保量度单位正确
- 优化矩阵运算
- 可以生成客户定制的设计模式实现品
八.定制new和delete
operator new
和operator delete
用来分配单一对象;arrays
所用的内存由operator new[]
分配出来,并由operator delete[]
归还;- STL 容器使用的堆内存由容器所拥有的分配器对象管理;
条款49:了解new-handler的行为
operator new
抛出异常以反映一个未获满足的内存需求之前,会先调用一个客户指定的错误处理函数,new_handler
,可以通过调用 std::set_new_handler()
来设置,std::set_new_handler()
定义在头文件 <new>
中:
1 | namespace std{ |
当 operator new
无法满足内存申请时,它会不断调用 new_handler
函数,直到找到足够内存。一个设计良好的 new_handler
函数必须做以下事情;
- 让更多内存可被使用:一个做法是程序一开始执行就分配一大块内存,而后当
new_handler
第一次被调用,将它们还给程序使用。这便造成operator new
内的下一次内存分配动作可能成功; - **安装另一个
new_handler
**:如果当前new_handler
无法取得更多可用内存,可用安装的另一个,下次operator new
时会调用新的new_handler
; - **卸除
new_handler
**:将nullptr
指针传给set_new_handler
; - 抛出
bad_alloc
(或派生自bad_alloc
) 的异常:这样的异常不会被operator new
捕获,因此会被传播到内存索求处; - 不返回:通常调用
abort
或exit
(abort
会设置程序非正常退出,exit
会设置程序正常退出,当存在未处理异常时 C++会调用terminate
,它会回调由std:: set_terminate
设置的处理函数,默认会调用abort
);
1)实现 class
专属的 new_handlers
1 | class NewHandlerHolder{ |
有了 NewHandlerSupport
这个模板基类后,给 Widget 添加 new_handler
支持只需要 public
继承即可:
1 | class Widget: public NewHandlerSupport<Widget>{ ... }; |
1 | ### nothrow new |
nothrow new
只能保证所调用的 no throw
版的 operator new
不抛出异常,但是构造也属于 new
的一个步骤,而它没法强制构造函数不抛出异常,所以并不能保证 new (std::nothrow) Widget
这样的表达式绝不导致异常。
条款 50:了解 new
和 delete
的合理替换时机
一般出于下列原因可能想要替换编译器提供的 operator new
或 operator delete
:
- 为了检测运用错误;
- 为了收集动态分配内存的使用统计信息;
- 为了增加分配和归还的速度;
- 为了降低缺省内存管理器带来的空间额外开销;
- 为了弥补缺省分配器中的非最佳齐位;
- 为了将相关对象成簇集中;
- 为了获得非传统的行为;
下面是一个「为了检测运用错误」而实现的简单的 operator new
的例子,通过在首部和尾部插入一个签名,返回中间内存块给程序使用,如果程序在使用内存时发生过在区块前或区块后写入的行为,那么签名就会被修改,因此可以检测这种行为:
1 | static const int signature = 0xDEADBEEF; // 边界符 |
这个例子主要是展示,它存在很多错误:
- 所有的
operator new
都应该内含一个循环,反复调用某个new_handler
函数,这里却没有; - C++要求所有
operator new
返回的指针都有适当的对齐。这里malloc
返回的指针是满足要求的,但是因为上述实现并不是直接返回malloc
的结果,而是返回一个int
偏移后的地址,因此无法保证它的安全;
条款 51:编写 new
和 delete
时需固守常规
前一条款是解释什么时候会想实现自己的 operator new
和 operator delete
,这个条款是解释当实现自己的 operator new
和 operator delete
时,必须遵守的规则。
1)operator new
需要遵守的规则
实现一致性的 operator new
必得返回正确的值,内存不足时必得调用 new_handler
函数,必须有对付零内存需求的准备,还需避免不慎掩盖正常形式的 new
。
下面是 non-member operator new
的伪码:
1 | void* operator new(std::size_t size) throw(std::bad_alloc) |
在继承中定制 member operator new
时,一般是针对某特定 class
的对象分配行为提供最优化,此时,并不是为了该 class
的任何 derived classes
。也就是说,针对 class X
而设计的 operator new
,其行为很典型地只为大小刚好为 sizeof (X)
的对象而设计。然而一旦被继承下去,有可能 base class
的 operator new
被调用用以分配 derived class
对象:
1 | class Base{ |
如果 Base class
专属的 operator new
并没有设计上述问题的处理方法,那么最佳做法是将「内存申请量错误」的调用行为改采标准 operator new
,像这样:
1 | void* Base::operator new(std::size_t size) throw(std::bad_alloc) |
2)operator delete
需要遵守的规则
operator delete
比起 operator new
更简单,需要记住的唯一事情就是 C++保证「删除 null 指针永远安全」:
1 | void operator delete(void* rawMemory) throw() |
member
版本也很简单,只需要多一个动作检查删除数量。万一 class
专属的 operator new
将大小有误的分配行为转交 ::operator new
执行,你也必须将大小有误的删除行为转交 ::operator delete
执行:
1 | void* Base::operator delete(void* rawMemory,std::size_t size) throw() |
如果即将被删除的对象派生自某个 base class
,而后者欠缺 virtual
析构函数,那么 C++传给 operator delete
的 size_t
数值可能不正确。这是「让你的 base classes
拥有 virtual
析构函数」的一个够好的理由。
条款52:写了placement new
也要写placement delete
placement new
是带有额外参数的 operator new
,但是通常都指「接受一个指针指向对象该被构造之处」的 operator new
。这个版本被纳入了 C++标准程序库,只要 #include <new>
就可以使用:
1 | void* operator new(std::size_t,void* pMemory) throw(); |
new
会先调用 operator new
,然后构造对象。如果对象构造过程中发生异常,那么需要调用相应的 operator delete
,否则会发生内存泄露。而 operator delete
必须和相应的 operator new
匹配
- 对于正常版本的
operator new
,匹配的operator delete
就是不带额外参数的版本; - 对于非正常版本的
operator new (placement new)
,匹配的operator delete
是带相应参数的版本 (placement delete
);
**placement delete
只有在「伴随 placement new
调用而触发的构造函数」出现异常时才会被调用。对着一个指针施行 delete
绝不会导致调用 placement delete
**。
这意味着如果要对所有与 placement new
相关的内存泄露宣战,我们必须同时提供一个正常的 operator delete
(用于构造期间无任何异常被抛出)和一个 placement
版本(用于构造期间有异常被抛出)。后者的额外参数必须和 operator new
一样。只要这样做,就再也不会因为难以察觉的内存泄露而失眠; 还需要注意名称掩盖的问题:
- 成员函数的名称会掩盖外围作用域中的相同名称
- 子类的名称会掩盖所有父类相同的名称
一个比较好的方法是:
1 | class StandardNewDeleteForms{ |