More Effective C++ 条款(1-11)总结
基础议题
条款1:仔细区别pointers和references
- 如果有一个变量,其目的是用来指向(代表)另一个对象,但是也有可能它不指向(代表)这个变量,那么应该使用
pointer
,因为可将pointer
设为null
,反之设计不允许变量为null
,那么使用reference
- 以下这是有害的行为,其结果不可预期(C++对此没有定义),编译器可以产生任何可能的输出
char *pc = 0; // 将 pointer 设定为null
char& rc = *pc; // 让 refercence 代表 null pointer 的 解引值
- 没有
null reference
, 使用reference
可能比pointers
更有效率,在使用reference
之前不需要测试其有效性
void printDouble(const double& rd)
{
cout << rd; // 不需要测试rd,它
} // 肯定指向一个double值
//相反,指针则应该总是被测试,防止其为空:
void printDouble(const double *pd)
{
if (pd) // 检查是否为NULL
{
cout << *pd;
}
}
pointers
可以被重新赋值,指向另一个对象,reference
却总是指向(代表)它最初获得的哪个对象- 实现某些操作符。如operator[],操作符应返回某种“能够被当作assignment赋值对象”
总结
当你知道你需要指向某个东西,而且绝不会改变指向其他东西,或是当你实现一个操作符而其语法需求无法由 pointers达成,你就应该选择 reference。任何其他时候,请采用 pointers
条款2:最好使用C++转型操作符
- 旧式的C转型方式,它几乎允许你将任何类型转换为任何其他类型,这是十分拙劣的
旧式转型存在的问题:
- 例如将
pointer-to-const-object
转型为一个pointer-to-non-const-object
(只改变对象的常量性),和将一个pointer-to-base-class-object
转型为一个pointer-to-derived-class-object
(完全改变一个对象的类型),其间有很大的差异。但是传统的C转型动作对此并无区分 - 难以辨识,旧式转型由一小对小括号加上一个对象名称(标识符)组成,而小括号和对象名称在C++的任何地方都有可能被使用
static_cast:
static_cast
基本上拥有与 C 旧式转型相同的威力与意义,以及相同的限制(如不能将struct转型为int)。- 不能移除表达式的常量性,由
const_cast
专司其职 - 其他新式 C++ 转型操作符适用于更集中(范围更狭窄)的目的
(type) expression // 原先 C 的转型写码形式
static_cast<type>(expression) // 使用 C++ 转型操作符
const_cast:
const_cast
用来改变表达式的常量性(constness)或变易性(volatileness),使用const_cast
,便是对人类(编译器)强调,通过这个转型操作符,你唯一打算改变的是某物的常量性或变易性。这项意愿将由编译器贯彻执行。如果将const_cast
应用于上述以外的用途,那么转型动作会被拒绝
#include <iostream>
using namespace std;
class Widget {};
class SpecialWidget : public Widget {};
void update(SpecialWidget* psw);
SpecialWidget sw; // sw是个 non-const 对象
const SpecialWidget& csw = sw; // csw 确实一个代表sw的 reference
// 并视之为一个const对象
update(&csw); // 错误!不能及那个const SpecialWidget*
// 传给一个需要SpecialWidget* 的函数
update(const_cast<SpecialWidget*>(&csw)); // 可!&csw的常量性被去除了
update((SpecialWidget*)&csw); // 可!但较难识别 C 旧式转型语法
const_cast
最常见的用途就是将某个对象的常量性去除掉
dynamic_cast:
- 用来转型继承体系重“安全的向下转型或跨系转型动作”。也就是说你可以利用
dynamic_cast
,将“指向base ckass objects
的pointers
或references
”转型为“指向derived(或sibling base)class objects
的pointers
或references
”,并得知转型是否成功。如果转型失败,会以一个null
指针或一个exception
(当转型对象是reference
)表现出来:
Widget *pw;
update(dynamic_cast<SpecialWidget*>(pw)); // 很好,传给update()一个指针,指向pw所指的
// pw所指的SpecialWidget--如果pw
// 真的指向这样的东西;否则传过去的
// 将是一个 null 指针
void updateViaRef(SpecialWidegt& rsw);
updateViaRef(dynamic_cast<SpecialWidegt&>(*pw)); // 很好,传给updateViaRef()的是
// pw所指的SpecialWidget--如果
// pw真的指向这样的东西;否则
// 抛出一个exception
dynamic_cast
只能用来协助你巡航于继承体系之中。它无法应用在缺乏虚函数(请看条款24)的类型身上,也不能改变类型的常量性(constness)- 如果不想为一个不涉及继承机制的类型执行转型动作,可使用
static_cast
;要改变常量性(constness),则必须使用const_cast
reinterpret_cast:
- 最后一个转型操作符是
reinterpret_cast
。这个操作符的转换结果几乎总是与编译平台息息相关。所以reinterpret_cast
不具移植性 reinterpret_cast
的最常用用途是转换"函数指针"类型。
typedef void (*FuncPtr)(); // FuncPtr是个指针,指向某个函数
// 后者无须任何自变量,返回值为voids
FuncPtr funcPtrArray[10]; // funcPtrArray 是个数组
// 内有10个FuncPtrs
假设由于某种原因,希望将以下函数的一个指针放进funcPtrArray中
int doSomething();
如果没有转型,不可能办到,因为doSomething
的类型与funcPtrArray
所能接受的不同。funcPtrArray
内各函数指针所指函数的返回值是void
,但doSomething
的返回值却是int
funcPtrArray[0] = &doSomething; //错误!类型不符
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething); //这样便可通过编译
某些情况下这样的转型可能会导致不正确的结果(如条款31),所以你应该尽量避免将函数指针转型。
补充:
More Effective C++
没有过多的对reinterpret_cast
操作符进行解释,但我觉得应该对它进行更多说明,因为它实在是太强大了,也应该对使用规则做出足够多的说明- reinterpret_cast通过重新解释底层位模式在类型之间进行转换。它将
expression
的二进制序列解释成new_type
,函数指针可以转成void*再转回来。reinterpret_cast
很强大,强大到可以随便转型。因为他是编译器面向二进制的转型,但安全性需要考虑。当其他转型操作符能满足需求时,reinterpret_cast
最好别用。 - 更多了解可看cpp reference reinterpret_cast
总结:
在程序中使用新式转型法,比较容易被解析(不论是对人类还是对工具而言),编译器也因此得以诊断转型错误(那是旧式转型法侦测不到的)。这些都是促使我们舍弃C旧式转型语法的重要因素
条款3:绝对不要以多态(polymorphically)方式处理数组
假设你有一个class BST
及一个继承自BST的class BalancedBST
;
class BST {};
class BalancedBST : public BST {};
现在考虑有个函数,用来打印BSTs数组中的每一个BST的内容
void printBSTArray(ostream& s, const BST array[], int numElements)
{
for (int i = 0 ; i < numElements; ++i)
{
s << array[i]; // 假设BST objects 有一个
// operator<< 可用
}
}
当你将一个由BST对象组成的数组传给此函数,没问题:
BST BSTArray[10];
printBSTArray(cout, BSTArray, 10); // 运行良好
然而如果你将一个BalancedBST
对象所组成的数组交给printBSTArray
函数,会发生什么事?
BalancedBST bBSTArray[10];
printBSTArrat(cout, bBSTArray, 10); // 可以正常运行吗?
- 此时就会发生错误,因为array[i]代表的时
*(array+i)
,编译器会认为数组中的每个元素时BST对象,所以array和array+i之间的距离一定是i*sizeof(BST) - 然后当传入由
BalancedBST
对象组成的数组,编译器会被误导。它仍假设数组中每一元素的大小是BST的大小,但其实每一元素的大小是BalancedBST的大小。因此当BalancedBST
的大小不等于BST
的大小时,会产生未定义的行为 - 当尝试通过一个·base class·指针,删除一个由derived class objects组成的数组,上述的问题还会再次出现,下面是你可能做出的错误尝试
void deleteArray(ostream& os,BST array[])
{
os << "Delete array,at address" <<
static_cast<void*>(array) << 'n';
delete []array;
}
编译器看到这样的句子
delete[] array;
会产生类似这样的代码,问题也就跟之前一样出现了
for(int i = the number of elements in the array-1; i >= 0; --i)
{
array[i].BST::~BST(); // 调用array[i]的 destructor
}
总结:
- 多态和指针算术不能混用,数组对象几乎总是涉及指针的算术运算,数组和多态不要混用
条款4:非必要不提供default constructor
后续看过条款43,再回头来补充
总结:
- 添加无意义的default constructors,也会影响classes的效率。如果class constructors可以确保对象的所有字段都会被正确地初始化,为测试行为所付出的时间和空间代价都可以免除。如果default constructors无法提供这种保证,那么最好避免让default constructors出现。虽然这可能会对classes的使用方式带来某种限制,但同时也带啦一种保证:当你真的使用了这样的classes,你可以预期它们所产生的对象会被完全地初始化,实现上亦富有效率
操作符
条款5:对定制的“类型转换函数”保持警觉
在你从未打算也未预期的情况下,此类函数可能会被调用,而其结果可能是不正确、不直观的程序行为,很难调试。
- 假如有以下这段代码,假设你忘记为
Rational
写一个operator<<
,那么你或许会认为以下打印动作不会成功,因为没有适当的operator<<
可以调用。但是你错了,编译器面对下述动作,发现不存在任何operator<<
可以接受一个Rational,但它会想尽办法(包括找出一系列可接受的隐式类型转换)让函数调用成功。
class Rational {
public:
Rational(int a = 0, int b = 1);
operator double() const; // 定义了一个将类转化为double的转换函数
// 将Rational 转换为double
private:
float val;
Rational r(1, 2);
double d = 0.5 * r;
cout << d << "\n"; // 0.25
cout << r; // 0.5
};
- "可被接受的转换程序定义"十分复杂,但本例中你的编译器发现,只要调用Rational::operator double,将r隐式转换为double,调用动作便能成功。解决办法就是以功能对等的另一个函数取代类型转换操作符,不妨以一个名为
asDouble
的函数取代operator double:
- 避免隐式类型转换带来的问题,使用关键词
explicit
。这个特性之所以被导入,就是为了解决隐式类型转换带来的问题。只要将constructors声明为explicit
,编译器便不能因隐式类型转换的需要而调用它们。
template<class T>
class Array{
public:
explicit Array(int size); // 注意,使用"explicit"
}
条款6:区别increment/decrement操作符的前置(prefix)和后置(postfix)形式
- 前置式返回一个
reference
,后置式返回一个const
对象 increment
操作符的前置式意义"increment and fetch"(累加然后取出),后置式意义"fetch and increment"(取出然后累加)
前置式:
// 前置式:累加然后取出(increment and fetch)
UPInt& UPInt::operator++()
{
*this += 1;
return *this;
}
后置式:
// 后置式:取出然后累加(fetch and increment)
const UPInt UPInt::operator++(int)
{
UPInt oldValue = *this;
++(*this);
return oldValue;
}
请注意后置式操作符并未动用其参数。是的,其参数的唯一目的只是为了区别前置式和后置式而已。ints
并不允许连续两次使用后置式increment
操作符。因此下列代码无法运行。
int i = 3;
i++++; // 错误!后置式返回const对象,因operator++为非const函数,所以无法执行第二次后置式increment操作
++++i; // 合法!前置式返回reference,i值前置式increment两次,i = 5
++++++++i; // 合法!前置式返回reference,i值前置式increment四次,i = 7
++i; // 调用 i.operator++();
i++; // 调用 i.operator++(0);
--i; // 调用 i.operator--();
i--; // 调用 i.operator--(0);
总结:
- 后置式
increment
函数,该函数必须产生一个临时对象,作为返回值之用。效率不如前置式。游戏引擎架构中说:前置式效率更好,但会打乱流水线
条款7:千万不要重载&&, || 和,操作符
当你重载&&,||操作符时,你正从根本层面改变整个游戏规则,因为从此"函数调用"语义会取代"骤死式 语义"
如果你将operator&&重载,下面这个式子:
if (expression1 && expression2) { }
会被编译器视为以下两者之一
if (expression1.operator&&(expression2)) // 假设operator&&是个 member function
if (operator&&(expression1, expression2)) // 假设operator&&是个 global function
虽然看起来没什么太大改变,但是"函数调用"语义和所谓的"骤死式"语义有两个重大的区别。第一,当函数调用动作被执行,所有参数值都必须评估完成,当调用操作符operator&&
和operator||
时,两个参数都已评估完成。没有骤死式语义。第二,C++语言规范并未明确定义函数调用做东中各参数的评估顺序,所以没办法知道expression1
和expression2
哪个会先被评估。这与骤死式评估法形成鲜明的对比,后者总是由左向右评估其自变量。
for (int i = 0, j = strlen(s) - 1; i < j ; ++i, --j)
表达式如果包含逗号,那么逗号左侧会先被评估,然后逗号的右侧再被评估;最后,整个逗号表达式的结果以逗号右侧的值为代表。面对上述循环的最后一个成分,编译器首先评估++i,然后是–j,而整个逗号表达式的结果是–j的返回值
- 如果将操作符写成一个
non-member funcion
,你绝对无法保证左侧表达式一定比右侧表达式更早被评估,因为两个表达式都被当做函数调用时的自变量,传递给该操作符函数,而你无法控制一个函数的自变量评估顺序。所以non-member
做法不可行。 - 剩下可能的做法是写成一个
member function
。但即便如此也不能保证逗号操作符的左操作数会先被评估,因为编译器并不强迫做这样的事情。因此,你"不能将逗号操作符重载,并保证其行为像它应该有的那样"。所以不要轻易地将他重载。
总结:
- 如果你没有什么好理由将某个操作符重载,就不要去做。面对
&&
,||
和,
,实在难有什么好理由,因为不管你多么努力,就是无法令其行为像它们应有的行为一样。
条款8:了解各种不同意义的new和delete
先说明new operator
和operator new
之间的差异。(此处所说的new operator
,即某些C++教程如C++ Primer所谓的new
expression)
string* ps = new string("Memory Management");
它的动作分为两方面。第一,它分配足够的内存,用来放置某类型的对象。第二,它调用一个constructor
(对象的构造函数),为刚才分配的内存中的那个对象设定初值。new operator
总是做这两件事,无论如何你不能改变其行为。
你能够改变的是用来容纳对象的那块内存的分配行为。new operator
调用某个函数,执行必要的内存分配动作,你可以重写或重载那个函数,改变其行为。这个函数的名称叫做operator new
函数operator new
通常声明如下:
void* operator new(size_t size);
其返回值是void*
。此函数返回一个指针,指向一块原始的、未设初值的内存(如果你喜欢,可以写一个新版的operator new
,在其返回内存指针之前先将那块内存设定初值。只不过这种行为颇为罕见就是了)
void *rawMemory = operator new(sizeof(string));
这里的operator new
将返回指针,指向一块足够容纳一个string
对象的内存
和malloc
一样,operator new
的唯一任务就是分配内存。它不知道什么是constructors
,operator new
只负责内存分配。取得operator new
返回的内存并将之转换为一个对象,是new operator
的责任
Placement new
有时候你真的会想直接调用一个constructor
,偶尔你会有一些分配好的原始内存,你需要在上面构建对象。有一个特殊版本的operator new
,称为placement new
,允许你那么做
如果你希望将对象产生于heap
,请使用new operator
。它不但会分配内存而且为该对象调用一个constructor
。如果你只是打算分配内存,请调用operator new
,那就没有任何constructor
会被调用。如果你打算在heap objects
产生时自己决定内存分配方式,请写一个operator new
,并使用new operator
,它将会自动调用你缩写的operator new
。
如果你打算在已分配(并拥有指针)的内存中构造对象,请使用placement new
删除和内存释放
如果你只打算处理原始的、未定初值的内存,应该完全回避new operator
和delete operator
,改调用operator new
取得内存并以operator delete
归还给系统:
void *buffer = operator new (50 * sizeof(char)); // 分配足够的内存,放置50个chars;没有调用任何ctors
operator delete(buffer); // 释放内存,没有调用任何dtors
如果你使用placement new
,在某内存块中产生对象,你应该避免对那块内存使用delete operator
。因为delete operator
会调用operator delete
,但是该内存包含的对象最初并非是由operator new
分配得来的。毕竟placement new
只是返回它所接收的指针而已。
总结:
new operator
和delete operator
都是内建操作符,无法为你所控制,但是它们所调用的内存分配/释放函数则不然。你可以修改它们完成任务的方式,至于它们的任务,已经被语言规范固定死了。
异常
条款9:利用destructors避免泄漏资源
为什么要使用exceptions
?
- 如果一个函数利用"设定状态变量"的方式或是利用"返回错误码"的方式发出一个异常信号,无法保证此函数的调用者会检查那个变量或检验那个错误码
- C程序员唯有以
setjmp
和longjmp
才能近似这样的行为。但是longjmp
在C++中有一个严重缺陷:当它调整栈(stack)的时候,无法调用局部(local)对象的destructors
。
关于setjmp和longjmp介绍可看这两篇:
将"一定得执行的清理代码"移到对象的destructor
内是个比较好的做法
void processAdoptions(istream& dataSource)
{
while (dataSource) {
auto_ptr<ALA> pa(readALA(dataSource));
pa->processAdoption();
}
}
上述代码
把资源封装在对象内,通常便可以在exceptions
出现时避免泄露资源
但如果exceptions
是在你正在取得资源的过程中抛出的,例如在一个"正在抓取资源"的class constructor
内,会发生什么事呢?如果exceptions
实在此类资源的自动析构过程中抛出的,又会发生什么事呢?此情况瞎constructor是否需要特殊设计?是的,它们需要,你可以在条款10和条款11中学到这些技术。
总结
条款9就是推荐用RAII的思想去管理和释放资源,避免 exceptions造成资源的泄漏。其次,条款中的例子auto_ptr,标准库中有所实现,对应auto_ptr,其在c++11引进,c++17中就被遗弃,故条款中的例子也可替换成std::unique_ptr
条款10:在constructor内阻止资源泄漏
假设有这样的设计
BookEntry::BookEntry(const string& name,
const string& address,
const string& imageFileName,
const string& audioClipFileName) : theName(name), theAddress(address),
theImage(0), theAudioClip(0)
{
if (imageFileName != "") {
theImage = new Image(imageFileName);
}
if (audioClipFileName != "") {
theAudioClip = new AudioClip(audioClipFileName);
}
BookEntry::~BookEntry()
{
delete theImage;W
delete theAudioClip;
}
}
看起来一切都很好,但其实不好,当程序执行BookEntry constructor
的以下部分,如果有个exception
被抛出,会发生什么事?
if (audioClipFileName != "") {
theAudioClip = new AudioClip(audioClipFileName);
}
当有exception
在BookEntry constructor
内抛出,就会被传播到正在产生BookEntry object
的那一端。控制权因而被移除BookEntry constructor
之外,theImage
不会被BookEntry destructor
删除,进而发生内存泄漏
C++只会析构已构造完成的对象。对象只有在其constructor
执行完毕才算是完全构造妥当。所以如果程序打算产生一个局部性的BookEntry object b
:
void testBookEntryClass()
{
BookEntry b("Addison-Wesley Publishing Company", "One Jacob Way, Reading, MA 01867");
}
而exception
在b的构造过程中被抛出,b的destructor
就不会被调用。
将b分配于heap
中,并在exception
出现时调用delete
:
void testBookEntryClass()
{
BookEntry *pb = 0;
try {
pb = new BookEntry("Addison-Wesley Publishing Company", "One Jacob Way, Reading, MA 01867");
// ...
}
catch (...) { // 捕获所有的exceptions
delete pb; // 当exception被抛出,删除pb
throw; // 将exception传给调用者
}
delete pb; // 正常情况下删除pb
}
所分配的Image object
依然会泄漏,同理,除非new动作成功,否则上述那个assignment
复制动作并不会施加于pb
身上,那么pb
会为nullptr
,而其内部theImage
就不会被destructors
删除
由于C++不自动清理那些"构造期间抛出exceptions
"的对象,所以必须自身对constructors
进行设计,修改如下
try {
if (imageFileName != "")
theImage = new Image(imageFileName);
}
catch (...) {
delete theImage;
throw;
// ...
}
接下来将theImage
和theAudioClip
都变成常量指针,此时又该如何考虑呢:
Image* const theImage;
AudioClip* const theAudioClip;
须构造函数列表初始化
- 一种办法是通过member function,将指针数据在函数内部完成
try/catch
- 还算"完美"地解决了问题,但是概念上应该由
constructor
完成的动作现在却散布于数个函数中,造成维护上的困扰
// theImage 首先被初始化,所以即使初始化失败亦无须担心
// 资源泄漏问题。因此本函数不必处理任何 exceptions
Image* BookEntry::initImage(const string& imageFileName)
{
if (imageFileName != "") return new Image(imageFileName);
else return 0;
}
// theAudioClip 第二个被初始化,所以如果在它初始化期间有
// exception 被抛出,它必须确定将 theImage 的资源释放掉
// 这就是为什么本函数使用 try...catch的原因
AudioClip* BookEntry::initAudioClip(const string& audioClipFileName)
{
try {
if (audioClipFileName != "")
return new AudioClip(audioClipFileName);
else
return 0;
}
catch (...) {
delete theImage;
throw;
}
}
- 一种办法参考条款9,使用有RAII特性的smart pointer
- 完美的解决方案,原书中对这种做法极力推崇
// auto_ptr 在 c++17 中淘汰
const auto_ptr<Image> theImage;
const auto_ptr<AudioClip> theAudioClip;
// 可改成
const std::unique_ptr<Image> theImage;
const std::unique_ptr<AudioClip> theAudioClip;
总结:
简单来说,条款10在条款9的结论上,讨论了 exception在constructors中出现的情况以及应对措施
如果你以auto_ptr对象来取代pointer class members,你便对你的constructors做了强化工事,免除了"exceptions"出现时发生资源泄露的危机,不在需要在destructors内晴子动手释放资源,并允许const member pointers得以和non-const member pointers有着一样优雅的处理方式
条款11:禁止异常(exception)流出destructors之外
两种情况下destructor
会被调用
- 第一种情况是当对象在正常状态下被销毁,当它离开了它的生存空间(scope)或是被明确地删除
- 第二种情况是当对象被
exception
处理机制————也就是exception
传播过程中的stack-unwinding
(栈展开机制)一一销毁
当destructors
被调用时,可能有一个以上的exception
正在作用之中
当如果控制权基于exceptions
的因素离开destructors
,而此时正有另一个exceptions
正在destructor
中处于作用状态,C++会调用terminate
函数将程序结束。
举个例子
class Session {
public:
Session();
~Session();
private:
static void logCreation(Session* objAddr);
static void logDestruction(Session* objAddr);
}
Session::~Session()
{
logDestruction(this);
}
这看起来很好,但是如果考虑一下logDestruction
抛出一个exception
。这个exception
并不会被Session
捕捉,所以会传播到destructors
的调用端,但是如果这个destructors
本身是因其他某个exception
而被调用的,terminate
函数便会被自动调用
一个有效的解决办法是:
Session::~Session()
{
try {
logDestruction(this);
}
catch (...) { }
}
try/catch
阻止了logDestruction
所抛出的exceptions
传出Session destructor
之外,此时,如果一个Session object因为栈展开(stack unwinding)而被销毁,则terminate并不会被调用
总结:
有两个好理由支持我们"全力阻止exceptions
传出des之外"
- 它可以避免
terminate
函数在exceptions
传播郭恒的栈展开机制中被调用 - 它可以协助确保
destructors
完成其应该完成的所有事情
每个理由本身的条件都足以让人信服,但集合在一起却又引起过重的负担。如果你认为能省悟,请看Herb Sutter的文章,尤其是标题为《Destructors That Throw and Why They’re Evil》的那篇(发表于C++ Report,1997/9,11,12)